feat: 优化发布清单和安装结构自动识别

This commit is contained in:
2026-07-15 06:33:33 +00:00
parent 0548692db0
commit 4df439b5ce
28 changed files with 1702 additions and 198 deletions
+2
View File
@@ -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)
+5 -5
View File
@@ -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()
+248
View File
@@ -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": "密码已更新,请重新登录"}
+21 -12
View File
@@ -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),
}
+4 -5
View File
@@ -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],
)
+3 -3
View File
@@ -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()
+5 -5
View File
@@ -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 不能为空")
+10 -10
View File
@@ -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 -3
View File
@@ -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 -2
View File
@@ -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:
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+135
View File
@@ -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
+38
View File
@@ -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
+1
View File
@@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel):
client_protocol: int = 3
license_key: str = ""
api_base_url: str = ""
install_root: str = ""
main_executable: str = ""
-1
View File
@@ -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"),
+51 -11
View File
@@ -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,