feat: 优化发布清单和安装结构自动识别
This commit is contained in:
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
||||
|
||||
from app.api.routes import (
|
||||
admin_app_channel,
|
||||
admin_auth,
|
||||
admin_config,
|
||||
admin_crash_report,
|
||||
admin_device,
|
||||
@@ -20,6 +21,7 @@ def include_api_routes(app: FastAPI):
|
||||
frontend.mount_admin_assets(app)
|
||||
crash_api.register_exception_handlers(app)
|
||||
app.include_router(frontend.router)
|
||||
app.include_router(admin_auth.router)
|
||||
app.include_router(admin_policy.router)
|
||||
app.include_router(admin_version.router)
|
||||
app.include_router(admin_license.router)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import app_channel_repository
|
||||
from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest
|
||||
from app.services.common_service import validate_channel_code
|
||||
@@ -10,12 +10,12 @@ router = APIRouter(tags=["admin-app-channel"])
|
||||
|
||||
|
||||
@router.get("/admin/app/list")
|
||||
def admin_get_app_list(auth=Depends(admin_auth)):
|
||||
def admin_get_app_list(auth=Depends(require_permission("app:view"))):
|
||||
return {"list": app_channel_repository.list_apps()}
|
||||
|
||||
|
||||
@router.post("/admin/app/add")
|
||||
def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
|
||||
def admin_add_app(body: AppCreateRequest, auth=Depends(require_permission("app:manage"))):
|
||||
aid = body.app_id
|
||||
aname = body.app_name
|
||||
try:
|
||||
@@ -26,12 +26,12 @@ def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.get("/admin/channel/list")
|
||||
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
|
||||
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(require_permission("app:view"))):
|
||||
return {"list": app_channel_repository.list_channels(app_id, include_disabled)}
|
||||
|
||||
|
||||
@router.post("/admin/channel/save")
|
||||
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(admin_auth)):
|
||||
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(require_permission("channel:manage"))):
|
||||
app_id = body.app_id.strip()
|
||||
code = body.channel_code.strip()
|
||||
name = body.display_name.strip()
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.core.security import (
|
||||
AdminPrincipal,
|
||||
ROLE_NAMES,
|
||||
ROLE_PERMISSIONS,
|
||||
admin_auth,
|
||||
decode_jwt_token,
|
||||
hash_password,
|
||||
normalize_roles,
|
||||
parse_utc_text,
|
||||
require_permission,
|
||||
token_digest,
|
||||
token_response_for_user,
|
||||
verify_password,
|
||||
)
|
||||
from app.repositories import admin_user_repository
|
||||
from app.schemas.admin_auth import (
|
||||
AdminLoginRequest,
|
||||
AdminUserCreateRequest,
|
||||
AdminUserResetPasswordRequest,
|
||||
AdminUserStatusRequest,
|
||||
AdminUserUpdateRequest,
|
||||
ChangePasswordRequest,
|
||||
RefreshTokenRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(tags=["admin-auth"])
|
||||
|
||||
VALID_ROLES = set(ROLE_PERMISSIONS.keys())
|
||||
|
||||
|
||||
def validate_username(username: str) -> str:
|
||||
value = username.strip()
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.@-]{3,64}", value):
|
||||
raise HTTPException(status_code=400, detail="用户名只能包含字母、数字、下划线、点、@ 和短横线,长度 3-64")
|
||||
return value
|
||||
|
||||
|
||||
def validate_password(password: str) -> str:
|
||||
value = password.strip()
|
||||
if len(value) < 8:
|
||||
raise HTTPException(status_code=400, detail="密码至少需要 8 个字符")
|
||||
if len(value) > 128:
|
||||
raise HTTPException(status_code=400, detail="密码不能超过 128 个字符")
|
||||
return value
|
||||
|
||||
|
||||
def validate_roles(roles: list[str]) -> list[str]:
|
||||
result = []
|
||||
for role in roles:
|
||||
value = str(role).strip()
|
||||
if value:
|
||||
result.append(value)
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="至少选择一个角色")
|
||||
invalid = [role for role in result if role not in VALID_ROLES]
|
||||
if invalid:
|
||||
raise HTTPException(status_code=400, detail=f"未知角色:{', '.join(invalid)}")
|
||||
return result
|
||||
|
||||
|
||||
def public_user(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"display_name": row["display_name"] or row["username"],
|
||||
"roles": normalize_roles(row["roles"]),
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"last_login_at": row["last_login_at"],
|
||||
}
|
||||
|
||||
|
||||
def role_options() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"value": role,
|
||||
"label": ROLE_NAMES.get(role, role),
|
||||
"permissions": permissions,
|
||||
}
|
||||
for role, permissions in ROLE_PERMISSIONS.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(body: AdminLoginRequest, request: Request):
|
||||
username = body.username.strip()
|
||||
password = body.password
|
||||
row = admin_user_repository.get_user(username)
|
||||
if not row or row["status"] != "active" or not verify_password(password, row["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
admin_user_repository.mark_login(username)
|
||||
return token_response_for_user(row, request)
|
||||
|
||||
|
||||
@router.post("/refresh-token")
|
||||
def refresh_token(body: RefreshTokenRequest, request: Request):
|
||||
payload = decode_jwt_token(body.refreshToken.strip(), "refresh")
|
||||
token_id = str(payload.get("jti") or "")
|
||||
username = str(payload.get("sub") or "")
|
||||
row = admin_user_repository.get_refresh_token(token_id)
|
||||
if not row or row["username"] != username or row["revoked_at"]:
|
||||
raise HTTPException(status_code=401, detail="刷新令牌无效")
|
||||
if row["token_hash"] != token_digest(body.refreshToken.strip()):
|
||||
raise HTTPException(status_code=401, detail="刷新令牌无效")
|
||||
if parse_utc_text(row["expires_at"]) <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=401, detail="刷新令牌已过期")
|
||||
user_row = admin_user_repository.get_user(username)
|
||||
if not user_row or user_row["status"] != "active":
|
||||
raise HTTPException(status_code=401, detail="管理员账号不存在或已禁用")
|
||||
admin_user_repository.revoke_refresh_token(token_id)
|
||||
return token_response_for_user(user_row, request)
|
||||
|
||||
|
||||
@router.get("/admin/auth/check")
|
||||
def admin_auth_check(principal: AdminPrincipal = Depends(admin_auth)):
|
||||
return {
|
||||
"code": 0,
|
||||
"msg": "管理员登录凭证有效",
|
||||
"username": principal.username,
|
||||
"nickname": principal.display_name,
|
||||
"roles": principal.roles,
|
||||
"permissions": principal.permissions,
|
||||
"auth_type": principal.auth_type,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/user/list")
|
||||
def admin_user_list(principal: AdminPrincipal = Depends(require_permission("admin:manage"))):
|
||||
return {
|
||||
"code": 0,
|
||||
"list": [public_user(row) for row in admin_user_repository.list_users()],
|
||||
"roles": role_options(),
|
||||
"current_username": principal.username,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/user/create")
|
||||
def admin_user_create(
|
||||
body: AdminUserCreateRequest,
|
||||
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
|
||||
):
|
||||
username = validate_username(body.username)
|
||||
password = validate_password(body.password)
|
||||
roles = validate_roles(body.roles)
|
||||
display_name = body.display_name.strip() or username
|
||||
try:
|
||||
admin_user_repository.create_user(
|
||||
username,
|
||||
hash_password(password),
|
||||
display_name,
|
||||
json.dumps(roles, ensure_ascii=False),
|
||||
"active",
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
return {"code": 0, "msg": "管理员用户已创建"}
|
||||
|
||||
|
||||
@router.post("/admin/user/update")
|
||||
def admin_user_update(
|
||||
body: AdminUserUpdateRequest,
|
||||
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
|
||||
):
|
||||
username = validate_username(body.username)
|
||||
row = admin_user_repository.get_user(username)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="管理员用户不存在")
|
||||
roles = validate_roles(body.roles)
|
||||
old_roles = normalize_roles(row["roles"])
|
||||
if "super_admin" in old_roles and "super_admin" not in roles:
|
||||
active_super_admins = admin_user_repository.count_active_super_admins()
|
||||
if row["status"] == "active" and active_super_admins <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能移除最后一个可用超级管理员")
|
||||
display_name = body.display_name.strip() or username
|
||||
admin_user_repository.update_user_profile(
|
||||
username,
|
||||
display_name,
|
||||
json.dumps(roles, ensure_ascii=False),
|
||||
)
|
||||
return {"code": 0, "msg": "管理员用户已更新"}
|
||||
|
||||
|
||||
@router.post("/admin/user/status")
|
||||
def admin_user_status(
|
||||
body: AdminUserStatusRequest,
|
||||
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
|
||||
):
|
||||
username = validate_username(body.username)
|
||||
status = body.status.strip()
|
||||
if status not in {"active", "disabled"}:
|
||||
raise HTTPException(status_code=400, detail="状态只能是 active 或 disabled")
|
||||
row = admin_user_repository.get_user(username)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="管理员用户不存在")
|
||||
roles = normalize_roles(row["roles"])
|
||||
if status == "disabled" and "super_admin" in roles:
|
||||
active_super_admins = admin_user_repository.count_active_super_admins()
|
||||
if row["status"] == "active" and active_super_admins <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能禁用最后一个可用超级管理员")
|
||||
if username == principal.username and status == "disabled":
|
||||
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
|
||||
admin_user_repository.set_user_status(username, status)
|
||||
if status == "disabled":
|
||||
admin_user_repository.revoke_refresh_tokens_for_user(username)
|
||||
return {"code": 0, "msg": "管理员用户状态已更新"}
|
||||
|
||||
|
||||
@router.post("/admin/user/password/reset")
|
||||
def admin_user_reset_password(
|
||||
body: AdminUserResetPasswordRequest,
|
||||
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
|
||||
):
|
||||
username = validate_username(body.username)
|
||||
new_password = validate_password(body.new_password)
|
||||
row = admin_user_repository.get_user(username)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="管理员用户不存在")
|
||||
admin_user_repository.update_password(username, hash_password(new_password))
|
||||
admin_user_repository.revoke_refresh_tokens_for_user(username)
|
||||
return {"code": 0, "msg": "管理员密码已重置"}
|
||||
|
||||
|
||||
@router.post("/admin/user/password/change")
|
||||
def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
principal: AdminPrincipal = Depends(require_permission("admin:access")),
|
||||
):
|
||||
if principal.auth_type == "legacy_token":
|
||||
raise HTTPException(status_code=400, detail="兼容令牌登录不能修改用户密码,请使用用户名密码登录")
|
||||
new_password = body.new_password.strip()
|
||||
if len(new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="新密码至少需要 8 个字符")
|
||||
if len(new_password) > 128:
|
||||
raise HTTPException(status_code=400, detail="新密码不能超过 128 个字符")
|
||||
row = admin_user_repository.get_user(principal.username)
|
||||
if not row or not verify_password(body.current_password, row["password_hash"]):
|
||||
raise HTTPException(status_code=403, detail="当前密码错误")
|
||||
admin_user_repository.update_password(principal.username, hash_password(new_password))
|
||||
return {"code": 0, "msg": "密码已更新,请重新登录"}
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.core.config import ENV_FILE_PATH
|
||||
from app.core.security import admin_auth, persist_env_value, set_admin_token, token_digest
|
||||
from app.core.security import persist_env_value, require_permission, set_admin_token, token_digest
|
||||
from app.repositories import app_channel_repository, license_repository
|
||||
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
|
||||
from app.services.admin_config_service import (
|
||||
@@ -53,29 +53,33 @@ def client_config_license_warning(license_key: str, app_id: str, channel: str) -
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/admin/auth/check")
|
||||
def admin_auth_check(auth=Depends(admin_auth)):
|
||||
return {"code": 0, "msg": "管理员令牌有效"}
|
||||
def normalize_install_root(value: str, fallback: str = "..") -> str:
|
||||
normalized = str(value or fallback).strip().replace("\\", "/")
|
||||
if normalized in ("", "."):
|
||||
return "."
|
||||
if normalized == "..":
|
||||
return ".."
|
||||
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
|
||||
|
||||
|
||||
@router.post("/admin/token/change")
|
||||
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(admin_auth)):
|
||||
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(require_permission("admin:manage"))):
|
||||
new_token = body.new_token.strip()
|
||||
if len(new_token) < 8:
|
||||
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
|
||||
raise HTTPException(status_code=400, detail="新兼容令牌至少需要 8 个字符")
|
||||
if len(new_token) > 128:
|
||||
raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符")
|
||||
raise HTTPException(status_code=400, detail="新兼容令牌不能超过 128 个字符")
|
||||
try:
|
||||
persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}")
|
||||
set_admin_token(new_token)
|
||||
os.environ["ADMIN_TOKEN"] = new_token
|
||||
return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
|
||||
return {"code": 0, "msg": f"兼容管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
|
||||
|
||||
|
||||
@router.get("/admin/runtime-config")
|
||||
def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
|
||||
def admin_runtime_config(request: Request, auth=Depends(require_permission("config:view"))):
|
||||
return {
|
||||
"release_main_executable": RELEASE_MAIN_EXECUTABLE,
|
||||
"target_platform": TARGET_PLATFORM,
|
||||
@@ -89,7 +93,7 @@ def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/client-config/generate")
|
||||
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(admin_auth)):
|
||||
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(require_permission("config:view"))):
|
||||
app_id = body.app_id.strip()
|
||||
channel = body.channel.strip() or "stable"
|
||||
current_version = body.current_version.strip() or "1.0.0"
|
||||
@@ -110,6 +114,7 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
|
||||
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
|
||||
|
||||
defaults = default_client_config_values(request)
|
||||
install_root = normalize_install_root(body.install_root, defaults["install_root"])
|
||||
main_executable = platform_executable_path(
|
||||
normalize_executable_name(body.main_executable),
|
||||
defaults["main_executable"],
|
||||
@@ -126,12 +131,11 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
|
||||
"client_protocol": str(client_protocol),
|
||||
"launch_token": defaults["launch_token"],
|
||||
"license_key": license_key,
|
||||
"api_base_url": api_base_url,
|
||||
"client_token": defaults["client_token"],
|
||||
"request_timeout_ms": defaults["request_timeout_ms"],
|
||||
"temp_folder": defaults["temp_folder"],
|
||||
"device_id": "",
|
||||
"install_root": defaults["install_root"],
|
||||
"install_root": install_root,
|
||||
"main_executable": main_executable,
|
||||
"launcher_executable": defaults["launcher_executable"],
|
||||
"updater_executable": defaults["updater_executable"],
|
||||
@@ -141,10 +145,15 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
|
||||
"arch": defaults["arch"],
|
||||
}
|
||||
crash_test_config = crash_report_test_values(api_base_url)
|
||||
server_config = {
|
||||
"api_base_url": api_base_url,
|
||||
}
|
||||
return {
|
||||
"config": config,
|
||||
"json_text": json.dumps(config, ensure_ascii=False, indent=2),
|
||||
"license_warning": license_warning,
|
||||
"server_config": server_config,
|
||||
"server_config_text": json.dumps(server_config, ensure_ascii=False, indent=2),
|
||||
"crash_test_config": crash_test_config,
|
||||
"crash_test_text": crash_report_test_text(api_base_url),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.security import admin_auth, token_digest
|
||||
from app.core.security import AdminPrincipal, require_permission
|
||||
from app.repositories import crash_report_repository
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ router = APIRouter(tags=["admin-crash-report"])
|
||||
|
||||
|
||||
@router.get("/admin/crash-report/list")
|
||||
def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)):
|
||||
def admin_crash_report_list(limit: int = 300, auth=Depends(require_permission("crash:view"))):
|
||||
limit = max(1, min(limit, 1000))
|
||||
return {"list": crash_report_repository.list_crash_reports(limit)}
|
||||
|
||||
@@ -21,8 +21,7 @@ def admin_crash_report_file(
|
||||
report_id: str,
|
||||
file_name: str,
|
||||
request: Request,
|
||||
X_Admin_Token: str = Header(""),
|
||||
auth=Depends(admin_auth),
|
||||
auth: AdminPrincipal = Depends(require_permission("crash:view")),
|
||||
):
|
||||
allowed = {
|
||||
"metadata": "metadata.json",
|
||||
@@ -49,7 +48,7 @@ def admin_crash_report_file(
|
||||
crash_report_repository.log_file_access(
|
||||
report_id,
|
||||
stored_name,
|
||||
token_digest(X_Admin_Token)[:16],
|
||||
auth.actor_hash,
|
||||
request.client.host if request.client else "",
|
||||
request.headers.get("user-agent", "")[:300],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import device_repository
|
||||
from app.schemas.device import DeviceSetDisabledRequest
|
||||
|
||||
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-device"])
|
||||
|
||||
|
||||
@router.get("/admin/device/list")
|
||||
def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
|
||||
def admin_device_list(app_id: str = "", auth=Depends(require_permission("device:view"))):
|
||||
return {"list": device_repository.list_devices(app_id)}
|
||||
|
||||
|
||||
@router.post("/admin/device/set-disabled")
|
||||
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(admin_auth)):
|
||||
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(require_permission("device:manage"))):
|
||||
device_id = body.device_id.strip()
|
||||
disabled = bool(body.disabled)
|
||||
reason = body.reason.strip()
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth, token_digest
|
||||
from app.core.security import require_permission, token_digest
|
||||
from app.repositories import license_repository
|
||||
from app.schemas.license import LicenseCreateRequest, LicenseDeleteRequest, LicenseStatusRequest
|
||||
from app.services.common_service import validate_channel_code
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["admin-license"])
|
||||
|
||||
|
||||
@router.post("/admin/license/create")
|
||||
def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
|
||||
def admin_license_create(body: LicenseCreateRequest, auth=Depends(require_permission("license:manage"))):
|
||||
app_id = body.app_id.strip()
|
||||
channel = body.channel.strip()
|
||||
customer = body.customer_name.strip()
|
||||
@@ -46,7 +46,7 @@ def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.get("/admin/license/list")
|
||||
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(admin_auth)):
|
||||
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(require_permission("license:view"))):
|
||||
rows = license_repository.list_licenses(app_id, include_deleted)
|
||||
result = []
|
||||
for r in rows:
|
||||
@@ -59,7 +59,7 @@ def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Dep
|
||||
|
||||
|
||||
@router.post("/admin/license/set-status")
|
||||
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth)):
|
||||
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(require_permission("license:manage"))):
|
||||
status = body.status
|
||||
license_id = body.license_id
|
||||
if status not in ("active", "disabled"):
|
||||
@@ -71,7 +71,7 @@ def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth
|
||||
|
||||
|
||||
@router.post("/admin/license/delete")
|
||||
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(require_permission("license:manage"))):
|
||||
license_id = body.license_id.strip()
|
||||
if not license_id:
|
||||
raise HTTPException(status_code=400, detail="license_id 不能为空")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import log_repository
|
||||
from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest
|
||||
|
||||
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-logs"])
|
||||
|
||||
|
||||
@router.get("/admin/report/list")
|
||||
def admin_get_report_log(auth=Depends(admin_auth)):
|
||||
def admin_get_report_log(auth=Depends(require_permission("log:view"))):
|
||||
return {"list": log_repository.list_upgrade_logs()}
|
||||
|
||||
|
||||
@router.post("/admin/report/delete")
|
||||
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
|
||||
log_id = int(body.id or 0)
|
||||
if log_id < 1:
|
||||
raise HTTPException(status_code=400, detail="日志 ID 无效")
|
||||
@@ -25,19 +25,19 @@ def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/report/clear")
|
||||
def admin_clear_report_logs(auth=Depends(admin_auth)):
|
||||
def admin_clear_report_logs(auth=Depends(require_permission("log:manage"))):
|
||||
deleted = log_repository.clear_upgrade_logs()
|
||||
return {"msg": "升级日志已清空", "deleted": deleted}
|
||||
|
||||
|
||||
@router.get("/admin/download-log/list")
|
||||
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(admin_auth)):
|
||||
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(require_permission("log:view"))):
|
||||
limit = max(1, min(limit, 1000))
|
||||
return {"list": log_repository.list_download_logs(app_id, limit)}
|
||||
|
||||
|
||||
@router.post("/admin/download-log/delete")
|
||||
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
|
||||
log_id = int(body.id or 0)
|
||||
if log_id < 1:
|
||||
raise HTTPException(status_code=400, detail="日志 ID 无效")
|
||||
@@ -48,19 +48,19 @@ def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/download-log/clear")
|
||||
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(admin_auth)):
|
||||
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(require_permission("log:manage"))):
|
||||
app_id = (body.app_id if body else "").strip()
|
||||
deleted = log_repository.clear_download_logs(app_id)
|
||||
return {"msg": "下载日志已清空", "deleted": deleted}
|
||||
|
||||
|
||||
@router.get("/admin/audit-log/list")
|
||||
def admin_audit_log_list(limit: int = 300, auth=Depends(admin_auth)):
|
||||
def admin_audit_log_list(limit: int = 300, auth=Depends(require_permission("log:view"))):
|
||||
return {"list": log_repository.list_audit_logs(max(1, min(limit, 1000)))}
|
||||
|
||||
|
||||
@router.post("/admin/audit-log/delete")
|
||||
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
|
||||
log_id = int(body.id or 0)
|
||||
if log_id < 1:
|
||||
raise HTTPException(status_code=400, detail="日志 ID 无效")
|
||||
@@ -71,6 +71,6 @@ def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/audit-log/clear")
|
||||
def admin_audit_log_clear(auth=Depends(admin_auth)):
|
||||
def admin_audit_log_clear(auth=Depends(require_permission("log:manage"))):
|
||||
deleted = log_repository.clear_audit_logs()
|
||||
return {"msg": "审计日志已清空", "deleted": deleted}
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import policy_repository
|
||||
from app.schemas.policy import PolicySaveRequest
|
||||
from app.services.common_service import policy_row_to_dict, validate_channel_code
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["admin-policy"])
|
||||
|
||||
|
||||
@router.get("/admin/policy")
|
||||
def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
|
||||
def admin_get_policy(app_id: str, channel: str, auth=Depends(require_permission("policy:view"))):
|
||||
row = policy_repository.get_policy(app_id, channel)
|
||||
result = policy_row_to_dict(row)
|
||||
result.update({"app_id": app_id, "channel": channel, "saved": bool(row)})
|
||||
@@ -21,7 +21,7 @@ def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/policy/save")
|
||||
def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
|
||||
def admin_save_policy(body: PolicySaveRequest, auth=Depends(require_permission("policy:manage"))):
|
||||
app_id = body.app_id.strip()
|
||||
channel = body.channel.strip()
|
||||
if not app_id or not validate_channel_code(channel):
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.services import publish_service
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ PUBLISH_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@router.post("/admin/publish")
|
||||
async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
|
||||
async def admin_publish_version(request: Request, auth=Depends(require_permission("publish:manage"))):
|
||||
if PUBLISH_LOCK.locked():
|
||||
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
||||
async with PUBLISH_LOCK:
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import minio_tool
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import version_repository
|
||||
from app.schemas.version import VersionIdRequest, VersionProtocolRequest
|
||||
from app.services.common_service import is_executable_path
|
||||
@@ -22,7 +22,7 @@ router = APIRouter(tags=["admin-version"])
|
||||
|
||||
|
||||
@router.post("/admin/version/offline-package")
|
||||
def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
def admin_offline_package(body: VersionIdRequest, auth=Depends(require_permission("version:view"))):
|
||||
version_id = int(body.version_id or 0)
|
||||
ver, rows = version_repository.get_version_with_files(version_id)
|
||||
if not ver or not rows:
|
||||
@@ -87,12 +87,12 @@ def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.get("/admin/version/list")
|
||||
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
|
||||
def admin_get_version_list(app_id: str, auth=Depends(require_permission("version:view"))):
|
||||
return {"list": version_repository.list_versions(app_id)}
|
||||
|
||||
|
||||
@router.post("/admin/version/set-protocol")
|
||||
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_auth)):
|
||||
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(require_permission("version:manage"))):
|
||||
version_id = int(body.version_id or 0)
|
||||
client_protocol = int(body.client_protocol or 0)
|
||||
if version_id < 1 or client_protocol < 1:
|
||||
@@ -104,7 +104,7 @@ def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_
|
||||
|
||||
|
||||
@router.post("/admin/version/set-latest")
|
||||
def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
def admin_set_latest(body: VersionIdRequest, auth=Depends(require_permission("version:manage"))):
|
||||
vid = body.version_id
|
||||
result = version_repository.set_latest_version(vid)
|
||||
if result == "not_found":
|
||||
@@ -113,7 +113,7 @@ def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/version/delete")
|
||||
def admin_delete_version(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
def admin_delete_version(body: VersionIdRequest, auth=Depends(require_permission("version:manage"))):
|
||||
vid = body.version_id
|
||||
v_info = version_repository.get_version_for_delete(vid)
|
||||
if not v_info:
|
||||
|
||||
+8
-4
@@ -19,14 +19,18 @@ async def admin_audit_middleware(request: Request, call_next):
|
||||
finally:
|
||||
if should_audit:
|
||||
try:
|
||||
token = request.headers.get("X-Admin-Token", "")
|
||||
principal = getattr(request.state, "admin_principal", None)
|
||||
token = request.headers.get("authorization", "") or request.headers.get("X-Admin-Token", "")
|
||||
conn = db.get_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO admin_audit_logs
|
||||
(actor_hash,action,method,path,target,result,status_code,ip,user_agent)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(actor_hash,actor_username,actor_roles,auth_type,action,method,path,target,result,status_code,ip,user_agent)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
token_digest(token)[:16] if token else "anonymous",
|
||||
principal.actor_hash if principal else (token_digest(token)[:16] if token else "anonymous"),
|
||||
principal.username if principal else "anonymous",
|
||||
",".join(principal.roles) if principal else "",
|
||||
principal.auth_type if principal else "",
|
||||
request.url.path.removeprefix("/admin/"),
|
||||
request.method,
|
||||
request.url.path,
|
||||
|
||||
+287
-6
@@ -1,20 +1,245 @@
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
|
||||
from app.core.config import ENV_FILE_PATH, settings
|
||||
from app.repositories import admin_user_repository
|
||||
|
||||
|
||||
if not settings.admin_token:
|
||||
raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
|
||||
|
||||
JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "").strip() or settings.admin_token
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ADMIN_ACCESS_TOKEN_EXPIRE_MIN", "120"))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("ADMIN_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
PASSWORD_HASHER = PasswordHasher()
|
||||
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"super_admin": ["*:*:*"],
|
||||
"release_admin": [
|
||||
"admin:access",
|
||||
"app:manage",
|
||||
"channel:manage",
|
||||
"version:manage",
|
||||
"publish:manage",
|
||||
"policy:manage",
|
||||
"device:view",
|
||||
"log:view",
|
||||
"config:view",
|
||||
],
|
||||
"license_admin": [
|
||||
"admin:access",
|
||||
"license:manage",
|
||||
"device:view",
|
||||
"config:view",
|
||||
"log:view",
|
||||
],
|
||||
"auditor": [
|
||||
"admin:access",
|
||||
"app:view",
|
||||
"version:view",
|
||||
"device:view",
|
||||
"license:view",
|
||||
"log:view",
|
||||
"crash:view",
|
||||
"config:view",
|
||||
],
|
||||
"crash_admin": [
|
||||
"admin:access",
|
||||
"crash:view",
|
||||
"log:view",
|
||||
],
|
||||
}
|
||||
|
||||
ROLE_NAMES = {
|
||||
"super_admin": "超级管理员",
|
||||
"release_admin": "发布管理员",
|
||||
"license_admin": "授权管理员",
|
||||
"auditor": "审计人员",
|
||||
"crash_admin": "崩溃报告管理员",
|
||||
}
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0)
|
||||
|
||||
|
||||
def utc_text(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_utc_text(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPrincipal:
|
||||
username: str
|
||||
display_name: str
|
||||
roles: list[str]
|
||||
permissions: list[str]
|
||||
auth_type: str
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
return permission_allowed(self.permissions, permission)
|
||||
|
||||
@property
|
||||
def actor_hash(self) -> str:
|
||||
return token_digest(self.username)[:16]
|
||||
|
||||
|
||||
def role_permissions(roles: list[str]) -> list[str]:
|
||||
permissions: list[str] = []
|
||||
for role in roles:
|
||||
permissions.extend(ROLE_PERMISSIONS.get(role, []))
|
||||
seen = set()
|
||||
result = []
|
||||
for item in permissions:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def permission_allowed(permissions: list[str], permission: str) -> bool:
|
||||
if "*:*:*" in permissions or permission in permissions:
|
||||
return True
|
||||
parts = permission.split(":")
|
||||
if len(parts) >= 2 and f"{parts[0]}:*" in permissions:
|
||||
return True
|
||||
if len(parts) == 2 and parts[1] == "view" and f"{parts[0]}:manage" in permissions:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return PASSWORD_HASHER.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return PASSWORD_HASHER.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def normalize_roles(raw_roles: str | list[str] | None) -> list[str]:
|
||||
if isinstance(raw_roles, list):
|
||||
roles = [str(item).strip() for item in raw_roles]
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(raw_roles or "[]")
|
||||
except json.JSONDecodeError:
|
||||
parsed = []
|
||||
roles = [str(item).strip() for item in parsed if str(item).strip()]
|
||||
return roles or ["super_admin"]
|
||||
|
||||
|
||||
def principal_from_user_row(row, auth_type: str = "jwt") -> AdminPrincipal:
|
||||
roles = normalize_roles(row["roles"])
|
||||
return AdminPrincipal(
|
||||
username=row["username"],
|
||||
display_name=row["display_name"] or row["username"],
|
||||
roles=roles,
|
||||
permissions=role_permissions(roles),
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
|
||||
def legacy_admin_principal() -> AdminPrincipal:
|
||||
return AdminPrincipal(
|
||||
username="legacy-admin-token",
|
||||
display_name="兼容管理员令牌",
|
||||
roles=["super_admin"],
|
||||
permissions=role_permissions(["super_admin"]),
|
||||
auth_type="legacy_token",
|
||||
)
|
||||
|
||||
|
||||
def create_jwt_token(username: str, token_type: str, expires_delta: timedelta, roles: list[str] | None = None) -> tuple[str, str, datetime]:
|
||||
now = utc_now()
|
||||
expires_at = now + expires_delta
|
||||
token_id = secrets.token_urlsafe(24)
|
||||
payload = {
|
||||
"sub": username,
|
||||
"typ": token_type,
|
||||
"jti": token_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
if roles is not None:
|
||||
payload["roles"] = roles
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return token, token_id, expires_at
|
||||
|
||||
|
||||
def decode_jwt_token(token: str, expected_type: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="无效登录凭证")
|
||||
if payload.get("typ") != expected_type:
|
||||
raise HTTPException(status_code=401, detail="登录凭证类型错误")
|
||||
return payload
|
||||
|
||||
|
||||
def token_response_for_user(row, request: Request | None = None) -> dict:
|
||||
principal = principal_from_user_row(row)
|
||||
access_token, _, access_expires = create_jwt_token(
|
||||
principal.username,
|
||||
"access",
|
||||
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
principal.roles,
|
||||
)
|
||||
refresh_token, refresh_id, refresh_expires = create_jwt_token(
|
||||
principal.username,
|
||||
"refresh",
|
||||
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
admin_user_repository.save_refresh_token(
|
||||
refresh_id,
|
||||
principal.username,
|
||||
token_digest(refresh_token),
|
||||
utc_text(refresh_expires),
|
||||
request.headers.get("user-agent", "")[:300] if request else "",
|
||||
request.client.host if request and request.client else "",
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"avatar": "",
|
||||
"username": principal.username,
|
||||
"nickname": principal.display_name,
|
||||
"roles": principal.roles,
|
||||
"permissions": principal.permissions,
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expires": utc_text(access_expires),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def quote_env_value(value: str) -> str:
|
||||
unsafe_chars = set(" \t\n\r#\"'")
|
||||
if value and not any(ch in unsafe_chars for ch in value):
|
||||
@@ -46,10 +271,66 @@ def persist_env_value(path: Path, key: str, value: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def admin_auth(X_Admin_Token: str = Header("")):
|
||||
if not secrets.compare_digest(X_Admin_Token, settings.admin_token):
|
||||
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
|
||||
return True
|
||||
def legacy_admin_auth(X_Admin_Token: str = Header("")):
|
||||
# Backward-compatible token-only auth for old scripts.
|
||||
token = X_Admin_Token if isinstance(X_Admin_Token, str) else ""
|
||||
if token and secrets.compare_digest(token, settings.admin_token):
|
||||
return legacy_admin_principal()
|
||||
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
|
||||
|
||||
|
||||
def current_admin(
|
||||
request: Request,
|
||||
Authorization: str = Header(""),
|
||||
X_Admin_Token: str = Header(""),
|
||||
) -> AdminPrincipal:
|
||||
authorization = Authorization if isinstance(Authorization, str) else ""
|
||||
admin_token = X_Admin_Token if isinstance(X_Admin_Token, str) else ""
|
||||
if authorization.lower().startswith("bearer "):
|
||||
payload = decode_jwt_token(authorization[7:].strip(), "access")
|
||||
username = str(payload.get("sub") or "")
|
||||
row = admin_user_repository.get_user(username)
|
||||
if not row or row["status"] != "active":
|
||||
raise HTTPException(status_code=401, detail="管理员账号不存在或已禁用")
|
||||
principal = principal_from_user_row(row)
|
||||
request.state.admin_principal = principal
|
||||
return principal
|
||||
principal = legacy_admin_auth(admin_token)
|
||||
request.state.admin_principal = principal
|
||||
return principal
|
||||
|
||||
|
||||
admin_auth = current_admin
|
||||
|
||||
|
||||
def require_permission(permission: str) -> Callable:
|
||||
def permission_dependency(principal: AdminPrincipal = Depends(current_admin)):
|
||||
if not principal.has_permission(permission):
|
||||
raise HTTPException(status_code=403, detail=f"缺少权限:{permission}")
|
||||
return principal
|
||||
|
||||
return permission_dependency
|
||||
|
||||
|
||||
def ensure_default_admin_user():
|
||||
username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
password = os.getenv("ADMIN_PASSWORD", "").strip() or settings.admin_token
|
||||
display_name = os.getenv("ADMIN_DISPLAY_NAME", "超级管理员").strip() or username
|
||||
if not admin_user_repository.has_any_user():
|
||||
admin_user_repository.create_user(
|
||||
username,
|
||||
hash_password(password),
|
||||
display_name,
|
||||
json.dumps(["super_admin"], ensure_ascii=False),
|
||||
"active",
|
||||
)
|
||||
print(f"已初始化默认管理员账号:{username}")
|
||||
return
|
||||
row = admin_user_repository.get_user(username)
|
||||
if not row:
|
||||
return
|
||||
if row["status"] != "active":
|
||||
admin_user_repository.set_user_status(username, "active")
|
||||
|
||||
|
||||
ADMIN_TOKEN = settings.admin_token
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import db
|
||||
|
||||
|
||||
def list_users():
|
||||
conn = db.get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT id,username,display_name,roles,status,created_at,updated_at,last_login_at
|
||||
FROM admin_users
|
||||
ORDER BY id ASC"""
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def has_any_user() -> bool:
|
||||
conn = db.get_conn()
|
||||
row = conn.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
|
||||
conn.close()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def get_user(username: str):
|
||||
conn = db.get_conn()
|
||||
row = conn.execute("SELECT * FROM admin_users WHERE username=?", (username,)).fetchone()
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
|
||||
def create_user(username: str, password_hash: str, display_name: str, roles: str, status: str = "active") -> None:
|
||||
conn = db.get_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO admin_users(username,password_hash,display_name,roles,status)
|
||||
VALUES(?,?,?,?,?)""",
|
||||
(username, password_hash, display_name, roles, status),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_user_profile(username: str, display_name: str, roles: str) -> int:
|
||||
conn = db.get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE admin_users SET display_name=?,roles=?,updated_at=datetime('now') WHERE username=?",
|
||||
(display_name, roles, username),
|
||||
)
|
||||
conn.commit()
|
||||
count = cur.rowcount
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def update_password(username: str, password_hash: str) -> int:
|
||||
conn = db.get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE admin_users SET password_hash=?,updated_at=datetime('now') WHERE username=?",
|
||||
(password_hash, username),
|
||||
)
|
||||
conn.commit()
|
||||
count = cur.rowcount
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def count_active_super_admins() -> int:
|
||||
conn = db.get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT roles FROM admin_users WHERE status='active'"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
count = 0
|
||||
for row in rows:
|
||||
if "super_admin" in (row["roles"] or ""):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def set_user_status(username: str, status: str) -> int:
|
||||
conn = db.get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE admin_users SET status=?,updated_at=datetime('now') WHERE username=?",
|
||||
(status, username),
|
||||
)
|
||||
conn.commit()
|
||||
count = cur.rowcount
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def revoke_refresh_tokens_for_user(username: str) -> int:
|
||||
conn = db.get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE admin_refresh_tokens SET revoked_at=datetime('now') WHERE username=? AND revoked_at IS NULL",
|
||||
(username,),
|
||||
)
|
||||
conn.commit()
|
||||
count = cur.rowcount
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def mark_login(username: str) -> None:
|
||||
conn = db.get_conn()
|
||||
conn.execute("UPDATE admin_users SET last_login_at=datetime('now') WHERE username=?", (username,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_refresh_token(token_id: str, username: str, token_hash: str, expires_at: str, user_agent: str, ip: str) -> None:
|
||||
conn = db.get_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO admin_refresh_tokens(token_id,username,token_hash,expires_at,user_agent,ip)
|
||||
VALUES(?,?,?,?,?,?)""",
|
||||
(token_id, username, token_hash, expires_at, user_agent, ip),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_refresh_token(token_id: str):
|
||||
conn = db.get_conn()
|
||||
row = conn.execute("SELECT * FROM admin_refresh_tokens WHERE token_id=?", (token_id,)).fetchone()
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
|
||||
def revoke_refresh_token(token_id: str) -> int:
|
||||
conn = db.get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE admin_refresh_tokens SET revoked_at=datetime('now') WHERE token_id=? AND revoked_at IS NULL",
|
||||
(token_id,),
|
||||
)
|
||||
conn.commit()
|
||||
count = cur.rowcount
|
||||
conn.close()
|
||||
return count
|
||||
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refreshToken: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: str = ""
|
||||
roles: list[str] = ["auditor"]
|
||||
|
||||
|
||||
class AdminUserUpdateRequest(BaseModel):
|
||||
username: str
|
||||
display_name: str = ""
|
||||
roles: list[str]
|
||||
|
||||
|
||||
class AdminUserStatusRequest(BaseModel):
|
||||
username: str
|
||||
status: str
|
||||
|
||||
|
||||
class AdminUserResetPasswordRequest(BaseModel):
|
||||
username: str
|
||||
new_password: str
|
||||
@@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel):
|
||||
client_protocol: int = 3
|
||||
license_key: str = ""
|
||||
api_base_url: str = ""
|
||||
install_root: str = ""
|
||||
main_executable: str = ""
|
||||
|
||||
@@ -57,7 +57,6 @@ def default_client_main_executable() -> str:
|
||||
|
||||
def default_client_config_values(request: Request) -> dict:
|
||||
return {
|
||||
"api_base_url": public_api_base_url(request),
|
||||
"client_token": VALID_CLIENT_TOKEN or "",
|
||||
"launch_token": os.getenv("CLIENT_LAUNCH_TOKEN", "SimCAE_Launch_Token_2026_ChangeMe_32Bytes"),
|
||||
"request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
|
||||
|
||||
@@ -37,6 +37,41 @@ PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
|
||||
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
|
||||
|
||||
|
||||
def normalize_install_root(raw_value: str) -> str:
|
||||
value = str(raw_value or os.getenv("CLIENT_INSTALL_ROOT", "..")).strip().replace(chr(92), "/")
|
||||
if value in ("", "."):
|
||||
return "."
|
||||
if value == "..":
|
||||
return ".."
|
||||
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
|
||||
|
||||
|
||||
def normalize_publish_executable(raw_value: str) -> str:
|
||||
value = str(raw_value or "").strip().replace(chr(92), "/").strip("/")
|
||||
if not value:
|
||||
value = RELEASE_MAIN_EXECUTABLE.rsplit("/", 1)[-1]
|
||||
if TARGET_PLATFORM == "linux" and value.casefold().endswith(".exe"):
|
||||
value = value[:-4]
|
||||
elif TARGET_PLATFORM == "windows":
|
||||
parts = value.split("/")
|
||||
leaf = parts[-1]
|
||||
if "." not in leaf:
|
||||
leaf += ".exe"
|
||||
parts[-1] = leaf
|
||||
value = "/".join(parts)
|
||||
return normalize_relative_path(value)
|
||||
|
||||
|
||||
def release_main_executable_for_install(install_root: str, main_executable: str) -> str:
|
||||
normalized_install_root = normalize_install_root(install_root)
|
||||
normalized_main = normalize_publish_executable(main_executable)
|
||||
if normalized_install_root == ".":
|
||||
return normalized_main
|
||||
if normalized_main.casefold().startswith("bin/"):
|
||||
return normalized_main
|
||||
return normalize_relative_path(f"bin/{normalized_main}")
|
||||
|
||||
|
||||
def normalize_relative_path(raw_path: str) -> str:
|
||||
if not isinstance(raw_path, str):
|
||||
raise HTTPException(status_code=400, detail="文件相对路径无效")
|
||||
@@ -256,7 +291,7 @@ def extract_release_archive(archive_path: Path, filename: str, extract_dir: Path
|
||||
raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar")
|
||||
|
||||
|
||||
def release_items_from_extracted_dir(extract_dir: Path):
|
||||
def release_items_from_extracted_dir(extract_dir: Path, required_main_executable: str):
|
||||
items = []
|
||||
for path in extract_dir.rglob("*"):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
@@ -265,13 +300,13 @@ def release_items_from_extracted_dir(extract_dir: Path):
|
||||
if should_skip_release_path(rel_path):
|
||||
continue
|
||||
items.append({"path": rel_path, "local_path": path})
|
||||
return normalize_release_item_roots(items)
|
||||
return normalize_release_item_roots(items, required_main_executable)
|
||||
|
||||
|
||||
def normalize_release_item_roots(items: list[dict]):
|
||||
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
|
||||
if not items:
|
||||
return items
|
||||
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
|
||||
required_main_key = required_main_executable.casefold()
|
||||
lower_paths = [item["path"].casefold() for item in items]
|
||||
if required_main_key in lower_paths:
|
||||
return items
|
||||
@@ -298,7 +333,7 @@ def normalize_release_item_roots(items: list[dict]):
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_release_items(items: list[dict]):
|
||||
def validate_release_items(items: list[dict], required_main_executable: str):
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
|
||||
if len(items) > PUBLISH_MAX_FILES:
|
||||
@@ -322,15 +357,15 @@ def validate_release_items(items: list[dict]):
|
||||
if not filtered:
|
||||
raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件")
|
||||
|
||||
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
|
||||
required_main_key = required_main_executable.casefold()
|
||||
if required_main_key not in seen_paths:
|
||||
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
|
||||
if nested_main:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{RELEASE_MAIN_EXECUTABLE} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
|
||||
detail=f"{required_main_executable} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {RELEASE_MAIN_EXECUTABLE}")
|
||||
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {required_main_executable}")
|
||||
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
|
||||
if nested_main:
|
||||
raise HTTPException(
|
||||
@@ -461,6 +496,9 @@ async def collect_publish_items(request: Request):
|
||||
app_id = form.get("app_id")
|
||||
channel = form.get("channel") or ""
|
||||
version = form.get("version")
|
||||
install_root = normalize_install_root(str(form.get("install_root") or ""))
|
||||
main_executable = str(form.get("main_executable") or "")
|
||||
required_main_executable = release_main_executable_for_install(install_root, main_executable)
|
||||
try:
|
||||
client_protocol = int(form.get("client_protocol") or 2)
|
||||
except (TypeError, ValueError):
|
||||
@@ -526,8 +564,8 @@ async def collect_publish_items(request: Request):
|
||||
extract_dir = archive_temp_dir / "extracted"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
extract_release_archive(archive_path, archive_name, extract_dir)
|
||||
raw_items = release_items_from_extracted_dir(extract_dir)
|
||||
upload_items = validate_release_items(raw_items)
|
||||
raw_items = release_items_from_extracted_dir(extract_dir, required_main_executable)
|
||||
upload_items = validate_release_items(raw_items, required_main_executable)
|
||||
extracted_total = sum(item["local_path"].stat().st_size for item in upload_items)
|
||||
check_extracted_publish_limits(extracted_total, len(upload_items))
|
||||
else:
|
||||
@@ -540,7 +578,7 @@ async def collect_publish_items(request: Request):
|
||||
for index, file in enumerate(files):
|
||||
raw_path = relative_paths[index] if relative_paths else file.filename
|
||||
raw_items.append({"upload": file, "path": str(raw_path)})
|
||||
upload_items = validate_release_items(raw_items)
|
||||
upload_items = validate_release_items(raw_items, required_main_executable)
|
||||
except Exception:
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
@@ -552,6 +590,8 @@ async def collect_publish_items(request: Request):
|
||||
"channel": str(channel),
|
||||
"version": str(version),
|
||||
"client_protocol": client_protocol,
|
||||
"install_root": install_root,
|
||||
"required_main_executable": required_main_executable,
|
||||
"upload_items": upload_items,
|
||||
"archive_upload": archive_upload,
|
||||
"archive_temp_dir": archive_temp_dir,
|
||||
|
||||
Reference in New Issue
Block a user