feat(server): modularize backend and admin console

This commit is contained in:
2026-07-14 01:38:41 +00:00
parent 9a6ac1e4ed
commit 95dd32c75a
261 changed files with 39289 additions and 2156 deletions
+1
View File
@@ -0,0 +1 @@
"""API route package."""
+33
View File
@@ -0,0 +1,33 @@
from fastapi import FastAPI
from app.api.routes import (
admin_app_channel,
admin_config,
admin_crash_report,
admin_device,
admin_license,
admin_logs,
admin_policy,
admin_publish,
admin_version,
client_update,
crash_api,
frontend,
)
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_policy.router)
app.include_router(admin_version.router)
app.include_router(admin_license.router)
app.include_router(admin_config.router)
app.include_router(admin_publish.router)
app.include_router(admin_app_channel.router)
app.include_router(admin_logs.router)
app.include_router(admin_crash_report.router)
app.include_router(admin_device.router)
app.include_router(client_update.router)
app.include_router(crash_api.router)
+1
View File
@@ -0,0 +1 @@
"""API route modules."""
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import app_channel_repository
from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest
from app.services.common_service import validate_channel_code
router = APIRouter(tags=["admin-app-channel"])
@router.get("/admin/app/list")
def admin_get_app_list(auth=Depends(admin_auth)):
return {"list": app_channel_repository.list_apps()}
@router.post("/admin/app/add")
def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
aid = body.app_id
aname = body.app_name
try:
app_channel_repository.add_app_with_default_channels(aid, aname)
except Exception as e:
return {"msg": f"创建失败:{str(e)}"}
return {"msg": "应用创建成功"}
@router.get("/admin/channel/list")
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
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)):
app_id = body.app_id.strip()
code = body.channel_code.strip()
name = body.display_name.strip()
enabled = bool(body.enabled)
order = int(body.sort_order or 100)
if not app_id or not validate_channel_code(code) or not name or len(name) > 64:
raise HTTPException(status_code=400, detail="渠道参数无效;代码仅允许字母、数字、下划线和连字符")
if not app_channel_repository.app_exists(app_id):
raise HTTPException(status_code=404, detail="应用不存在")
app_channel_repository.save_channel(app_id, code, name, enabled, order)
return {"msg": "渠道已保存"}
+110
View File
@@ -0,0 +1,110 @@
import json
import os
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
from app.repositories import app_channel_repository
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
from app.services.admin_config_service import (
PUBLISH_MAX_FIELDS,
PUBLISH_MAX_FILES,
PUBLISH_MAX_REQUEST_BYTES,
RELEASE_MAIN_EXECUTABLE,
TARGET_ARCH,
TARGET_PLATFORM,
default_client_config_values,
normalize_executable_name,
public_api_base_url,
)
from app.services.common_service import validate_channel_code
router = APIRouter(tags=["admin-config"])
@router.get("/admin/auth/check")
def admin_auth_check(auth=Depends(admin_auth)):
return {"code": 0, "msg": "管理员令牌有效"}
@router.post("/admin/token/change")
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(admin_auth)):
new_token = body.new_token.strip()
if len(new_token) < 8:
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
if len(new_token) > 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}"}
@router.get("/admin/runtime-config")
def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
return {
"release_main_executable": RELEASE_MAIN_EXECUTABLE,
"target_platform": TARGET_PLATFORM,
"target_arch": TARGET_ARCH,
"publish_max_request_bytes": PUBLISH_MAX_REQUEST_BYTES,
"publish_max_files": PUBLISH_MAX_FILES,
"publish_max_fields": PUBLISH_MAX_FIELDS,
"api_base_url": public_api_base_url(request),
"default_client_config": default_client_config_values(request),
}
@router.post("/admin/client-config/generate")
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
channel = body.channel.strip() or "stable"
current_version = body.current_version.strip() or "1.0.0"
client_protocol = max(1, int(body.client_protocol or 3))
api_base_url = (body.api_base_url.strip() or public_api_base_url(request)).rstrip("/")
if not app_id:
raise HTTPException(status_code=400, detail="请先选择或填写 App ID")
if not validate_channel_code(channel):
raise HTTPException(status_code=400, detail="渠道代码无效")
if not api_base_url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="api_base_url 必须以 http:// 或 https:// 开头")
app_row = app_channel_repository.get_app(app_id)
if not app_row:
raise HTTPException(status_code=404, detail="应用不存在,请先在应用管理中创建")
channel_row = app_channel_repository.get_channel(app_id, channel)
if not channel_row:
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
defaults = default_client_config_values(request)
main_executable = normalize_executable_name(body.main_executable) or defaults["main_executable"]
config = {
"app_id": app_id,
"app_name": app_row["app_name"],
"channel": channel,
"current_version": current_version,
"client_protocol": str(client_protocol),
"launch_token": defaults["launch_token"],
"license_key": body.license_key.strip(),
"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"],
"main_executable": main_executable,
"launcher_executable": defaults["launcher_executable"],
"updater_executable": defaults["updater_executable"],
"bootstrap_executable": defaults["bootstrap_executable"],
"health_check_timeout_ms": defaults["health_check_timeout_ms"],
"platform": defaults["platform"],
"arch": defaults["arch"],
}
return {
"config": config,
"json_text": json.dumps(config, ensure_ascii=False, indent=2),
}
+56
View File
@@ -0,0 +1,56 @@
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.repositories import crash_report_repository
router = APIRouter(tags=["admin-crash-report"])
@router.get("/admin/crash-report/list")
def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)):
limit = max(1, min(limit, 1000))
return {"list": crash_report_repository.list_crash_reports(limit)}
@router.get("/admin/crash-report/file/{report_id}/{file_name}")
def admin_crash_report_file(
report_id: str,
file_name: str,
request: Request,
X_Admin_Token: str = Header(""),
auth=Depends(admin_auth),
):
allowed = {
"metadata": "metadata.json",
"metadata.json": "metadata.json",
"minidump": "crash.dmp",
"crash.dmp": "crash.dmp",
"attachments": "attachments.zip",
"attachments.zip": "attachments.zip",
"server": "server.json",
"server.json": "server.json",
}
stored_name = allowed.get(file_name)
if not stored_name:
raise HTTPException(status_code=404, detail="崩溃报告文件不存在")
row = crash_report_repository.get_crash_report(report_id)
if not row:
raise HTTPException(status_code=404, detail="崩溃报告不存在")
target = Path(row["storage_path"]) / stored_name
if not target.is_file():
raise HTTPException(status_code=404, detail="崩溃报告文件不存在")
crash_report_repository.log_file_access(
report_id,
stored_name,
token_digest(X_Admin_Token)[:16],
request.client.host if request.client else "",
request.headers.get("user-agent", "")[:300],
)
return FileResponse(target, media_type="application/octet-stream", filename=stored_name)
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import device_repository
from app.schemas.device import DeviceSetDisabledRequest
router = APIRouter(tags=["admin-device"])
@router.get("/admin/device/list")
def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
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)):
device_id = body.device_id.strip()
disabled = bool(body.disabled)
reason = body.reason.strip()
updated = device_repository.set_device_disabled(device_id, disabled, reason)
if updated != 1:
raise HTTPException(status_code=404, detail="设备不存在")
return {"msg": "设备已禁用" if disabled else "设备已恢复", "device_id": device_id}
+81
View File
@@ -0,0 +1,81 @@
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth, 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
from app.services.license_service import decrypt_license_key, encrypt_license_key
router = APIRouter(tags=["admin-license"])
@router.post("/admin/license/create")
def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
channel = body.channel.strip()
customer = body.customer_name.strip()
max_devices = int(body.max_devices or 1)
valid_until = body.valid_until.strip()
try:
expiry = datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
if not app_id or not customer or not validate_channel_code(channel) or max_devices < 1 or expiry <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="授权参数无效")
license_id = "lic_" + secrets.token_hex(12)
license_key = "MARSCO-" + secrets.token_urlsafe(24)
result = license_repository.insert_license(
license_id,
token_digest(license_key),
encrypt_license_key(license_key),
customer,
app_id,
channel,
max_devices,
valid_until,
)
if result == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
return {"license_id": license_id, "license_key": license_key, "msg": "授权已创建;密钥已加密保存,可在授权列表中再次查看"}
@router.get("/admin/license/list")
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(admin_auth)):
rows = license_repository.list_licenses(app_id, include_deleted)
result = []
for r in rows:
license_key = decrypt_license_key(r["license_key_cipher"] if "license_key_cipher" in r.keys() else "")
result.append(
{k: r[k] for k in ("license_id", "customer_name", "app_id", "channel_code", "max_devices", "valid_until", "status", "created_at")}
| {"used_devices": r["used_devices"], "license_key": license_key, "license_key_available": bool(license_key)}
)
return {"list": result}
@router.post("/admin/license/set-status")
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth)):
status = body.status
license_id = body.license_id
if status not in ("active", "disabled"):
raise HTTPException(status_code=400, detail="授权状态无效")
updated = license_repository.set_license_status(license_id, status)
if updated != 1:
raise HTTPException(status_code=404, detail="授权不存在")
return {"msg": "授权状态已更新"}
@router.post("/admin/license/delete")
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(admin_auth)):
license_id = body.license_id.strip()
if not license_id:
raise HTTPException(status_code=400, detail="license_id 不能为空")
updated = license_repository.soft_delete_license(license_id)
if updated != 1:
raise HTTPException(status_code=404, detail="授权不存在")
return {"msg": "授权已删除,历史记录仍会保留"}
+76
View File
@@ -0,0 +1,76 @@
from fastapi import APIRouter, Body, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import log_repository
from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest
router = APIRouter(tags=["admin-logs"])
@router.get("/admin/report/list")
def admin_get_report_log(auth=Depends(admin_auth)):
return {"list": log_repository.list_upgrade_logs()}
@router.post("/admin/report/delete")
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
deleted = log_repository.delete_upgrade_log(log_id)
if deleted != 1:
raise HTTPException(status_code=404, detail="升级日志不存在")
return {"msg": "升级日志已删除", "deleted": deleted}
@router.post("/admin/report/clear")
def admin_clear_report_logs(auth=Depends(admin_auth)):
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)):
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)):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
deleted = log_repository.delete_download_log(log_id)
if deleted != 1:
raise HTTPException(status_code=404, detail="下载日志不存在")
return {"msg": "下载日志已删除", "deleted": deleted}
@router.post("/admin/download-log/clear")
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(admin_auth)):
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)):
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)):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
deleted = log_repository.delete_audit_log(log_id)
if deleted != 1:
raise HTTPException(status_code=404, detail="审计日志不存在")
return {"msg": "审计日志已删除", "deleted": deleted}
@router.post("/admin/audit-log/clear")
def admin_audit_log_clear(auth=Depends(admin_auth)):
deleted = log_repository.clear_audit_logs()
return {"msg": "审计日志已清空", "deleted": deleted}
+52
View File
@@ -0,0 +1,52 @@
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
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
router = APIRouter(tags=["admin-policy"])
@router.get("/admin/policy")
def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
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)})
return result
@router.post("/admin/policy/save")
def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
channel = body.channel.strip()
if not app_id or not validate_channel_code(channel):
raise HTTPException(status_code=400, detail="App ID 或渠道无效")
disabled = body.disabled_versions or []
if any(not isinstance(v, str) for v in disabled):
raise HTTPException(status_code=400, detail="disabled_versions 必须是版本字符串数组")
valid_until = body.valid_until.strip()
try:
datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
result, next_seq = policy_repository.save_policy(
app_id,
channel,
bool(body.force_update),
bool(body.allow_rollback),
bool(body.offline_allowed),
valid_until,
body.min_supported_version.strip(),
json.dumps(disabled, ensure_ascii=False),
body.message.strip(),
)
if result == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
return {"msg": "策略已保存并递增 policy_seq", "policy_seq": next_seq}
+18
View File
@@ -0,0 +1,18 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.security import admin_auth
from app.services import publish_service
router = APIRouter()
PUBLISH_LOCK = asyncio.Lock()
@router.post("/admin/publish")
async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
if PUBLISH_LOCK.locked():
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
async with PUBLISH_LOCK:
return await publish_service.publish_version(request)
+126
View File
@@ -0,0 +1,126 @@
import hashlib
import json
import os
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
import minio_tool
from app.core.security import admin_auth
from app.repositories import version_repository
from app.schemas.version import VersionIdRequest, VersionProtocolRequest
from app.services.common_service import is_executable_path
from app.services.signing_service import canonical_manifest_bytes, sign_manifest, sign_policy
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["admin-version"])
@router.post("/admin/version/offline-package")
def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
version_id = int(body.version_id or 0)
ver, rows = version_repository.get_version_with_files(version_id)
if not ver or not rows:
raise HTTPException(status_code=404, detail="版本或版本文件不存在")
files = [{"path": r["path"], "sha256": r["sha256"], "size": r["size"], "executable": is_executable_path(r["path"])} for r in rows]
manifest = {
"app_id": ver["app_id"],
"version": ver["version"],
"channel": ver["channel"],
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
"manifest_seq": version_id,
"created_at": ver["create_time"],
"files": files,
}
manifest_text = canonical_manifest_bytes(manifest).decode()
manifest_signature = sign_manifest(manifest)
offset = 0
entries = []
for r in rows:
entries.append({"path": r["path"], "offset": offset, "size": r["size"], "sha256": r["sha256"]})
offset += int(r["size"] or 0)
package = {
"format": "MUPD0001",
"app_id": ver["app_id"],
"channel": ver["channel"],
"version": ver["version"],
"version_id": version_id,
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"manifest_sha256": hashlib.sha256(manifest_text.encode()).hexdigest(),
"payload_size": offset,
"files": entries,
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
package_text, package_signature = sign_policy(package)
wrapper = json.dumps(
{
"package_text": package_text,
"package_signature": package_signature,
"manifest_text": manifest_text,
"manifest_signature": manifest_signature,
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
prefix = f"{ver['app_id']}/{ver['channel']}/{ver['version']}/files/"
def stream():
yield b"MUPD0001"
yield len(wrapper).to_bytes(8, "little")
yield wrapper
for r in rows:
yield from minio_tool.iter_file(prefix + r["path"])
filename = f"{ver['app_id']}_{ver['channel']}_{ver['version']}.upd"
return StreamingResponse(
stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"', "Content-Length": str(16 + len(wrapper) + offset)},
)
@router.get("/admin/version/list")
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
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)):
version_id = int(body.version_id or 0)
client_protocol = int(body.client_protocol or 0)
if version_id < 1 or client_protocol < 1:
raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数")
updated = version_repository.set_version_protocol(version_id, client_protocol)
if updated != 1:
raise HTTPException(status_code=404, detail="版本不存在")
return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol}
@router.post("/admin/version/set-latest")
def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
vid = body.version_id
result = version_repository.set_latest_version(vid)
if result == "not_found":
raise HTTPException(status_code=404, detail="版本不存在")
return {"msg": "已切换为渠道最新版本"}
@router.post("/admin/version/delete")
def admin_delete_version(body: VersionIdRequest, auth=Depends(admin_auth)):
vid = body.version_id
v_info = version_repository.get_version_for_delete(vid)
if not v_info:
raise HTTPException(status_code=404, detail="版本不存在")
aid, ch, ver = v_info["app_id"], v_info["channel"], v_info["version"]
was_latest = bool(v_info["latest"])
prefix = f"{aid}/{ch}/{ver}/files/"
minio_tool.remove_prefix(prefix)
promoted_version = version_repository.delete_version_and_promote(vid, aid, ch, was_latest)
return {"msg": "版本、云端文件、数据库记录全部删除完成", "promoted_latest": promoted_version}
+244
View File
@@ -0,0 +1,244 @@
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Body, HTTPException, Request
import minio_tool
from app.repositories import client_update_repository
from app.schemas.client_update import (
CheckUpdateRequest,
DeviceIssueRequest,
DownloadReportRequest,
DownloadUrlRequest,
ManifestRequest,
UpgradeReportRequest,
)
from app.services.common_service import is_executable_path, policy_row_to_dict, validate_channel_code, version_key
from app.services.signing_service import canonical_manifest_bytes, sign_device_identity, sign_manifest, sign_policy
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64")
SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["client-update"])
def normalize_relative_path(raw_path: str) -> str:
from pathlib import PurePosixPath
if not isinstance(raw_path, str):
raise HTTPException(status_code=400, detail="文件相对路径无效")
normalized = raw_path.replace(chr(92), "/")
if normalized.startswith("/"):
raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}")
normalized = normalized.strip("/")
if not normalized or chr(0) in normalized:
raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符")
path = PurePosixPath(normalized)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}")
if path.parts and ":" in path.parts[0]:
raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}")
return path.as_posix()
@router.post("/api/v1/device/issue")
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip()
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
raise HTTPException(status_code=400, detail="设备登记参数无效")
ip = request.client.host if request.client else ""
result = client_update_repository.issue_device(
app_id,
channel,
hashlib.sha256(body.license_key.strip().encode("utf-8")).hexdigest(),
installation_id,
body.machine_hash.strip().lower(),
ip,
)
if result["status"] == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result["status"] == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
if result["status"] == "license_invalid":
raise HTTPException(status_code=403, detail={"error": "license_invalid", "msg": "授权密钥无效、已过期或不适用于当前应用/渠道"})
if result["status"] == "device_disabled":
raise HTTPException(status_code=403, detail={"error": "device_disabled", "msg": result["reason"]})
if result["status"] == "license_conflict":
raise HTTPException(status_code=409, detail="该安装实例已绑定其他授权")
if result["status"] == "license_device_limit":
raise HTTPException(status_code=403, detail={"error": "license_device_limit", "msg": "授权设备数量已达到上限"})
issued_at = result["issued_at"]
license_row = result["license"]
identity = {
"device_id": result["device_id"],
"license_id": license_row["license_id"],
"app_id": app_id,
"channel": channel,
"installation_id": installation_id,
"credential_seq": result["credential_seq"],
"issued_at": issued_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"valid_until": license_row["valid_until"],
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
identity_text, signature = sign_device_identity(identity)
return {"identity": identity, "identity_text": identity_text, "signature": signature}
@router.post("/api/v1/update/check")
async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
context = client_update_repository.get_update_context(app_id, channel)
if context["status"] == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if context["status"] == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
ver = context["version"]
settings = policy_row_to_dict(context["policy"])
latest_ver = ver["version"] if ver else ""
version_id = int(ver["id"]) if ver else 0
target_protocol = int(ver["client_protocol"] or 1) if ver else 0
protocol_compatible = not ver or target_protocol >= body.client_protocol
upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver)
rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver)
rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible
need_update = upgrade_available or rollback_allowed
disabled = cur_ver in settings["disabled_versions"]
below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"])
allow_run = not disabled and not below_min
force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min
if disabled:
action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用"
elif below_min:
action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}"
elif force_update:
action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用"
elif rollback_allowed:
action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}"
elif rollback_candidate and not protocol_compatible:
action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级"
elif rollback_candidate:
action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级"
elif upgrade_available:
action, message = "optional_update", settings["message"] or "发现可用新版本"
else:
action, message = "allow", settings["message"] or "当前版本允许使用"
issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
policy = {
"app_id": app_id,
"channel": channel,
"current_version": cur_ver,
"policy_seq": settings["policy_seq"],
"allow_run": allow_run,
"force_update": force_update,
"allow_rollback": settings["allow_rollback"],
"rollback_targets": [],
"offline_allowed": settings["offline_allowed"],
"issued_at": issued_at,
"valid_until": settings["valid_until"],
"latest_version": latest_ver,
"min_supported_version": settings["min_supported_version"],
"disabled_versions": settings["disabled_versions"],
"message": message,
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
policy_text, policy_signature = sign_policy(policy)
policy["signature"] = policy_signature
return {
"allow_run": allow_run,
"action": action,
"message": message,
"need_update": need_update,
"release_available": bool(ver),
"current_version": cur_ver,
"latest_version": latest_ver,
"version_id": version_id,
"force_update": force_update,
"allow_rollback": settings["allow_rollback"],
"rollback": rollback_allowed,
"client_protocol": body.client_protocol,
"target_client_protocol": target_protocol,
"protocol_compatible": protocol_compatible,
"policy_seq": settings["policy_seq"],
"policy_valid_until": settings["valid_until"],
"policy": policy,
"policy_text": policy_text,
"policy_signature": policy_signature,
}
@router.post("/api/v1/update/download-url")
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")[:300]
result = client_update_repository.get_download_files(body.version_id, body.app_id, body.channel, body.version, identity, ip, ua)
if result["status"] != "ok":
raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配")
base_path = f"{body.app_id}/{body.channel}/{body.version}/files/"
return {
"files": [
{"path": row["path"], "url": minio_tool.get_url(base_path + row["path"]), "sha256": row["sha256"], "size": row["size"]}
for row in result["files"]
]
}
@router.post("/api/v1/update/download-report")
async def report_download(request: Request, body: DownloadReportRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
if body.result not in ("success", "fail"):
raise HTTPException(status_code=400, detail="下载结果无效")
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")[:300]
values = []
for item in body.files[:5000]:
path = normalize_relative_path(str(item.get("path") or ""))
size = max(0, int(item.get("size") or 0))
values.append((identity["device_id"], identity["license_id"], body.app_id, body.version, body.channel, path, size, body.result, ip, ua))
logged = client_update_repository.insert_download_report_logs(values)
return {"code": 0, "logged": logged}
@router.post("/api/v1/update/manifest")
async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
if not ver:
raise HTTPException(status_code=404, detail="Version not found")
files = [{"path": row["path"], "sha256": row["sha256"], "size": row["size"], "executable": is_executable_path(row["path"])} for row in rows]
manifest = {
"app_id": ver["app_id"],
"version": ver["version"],
"channel": ver["channel"],
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
"manifest_seq": int(body.version_id),
"created_at": ver["create_time"],
"files": files,
}
manifest_text = canonical_manifest_bytes(manifest).decode("utf-8")
signature = sign_manifest(manifest)
manifest["signature"] = signature
return {"manifest": manifest, "manifest_text": manifest_text, "signature": signature}
@router.post("/api/v1/update/report")
async def report(request: Request, body: UpgradeReportRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["device_id"] != body.device_id:
raise HTTPException(status_code=403, detail="上报身份与设备凭证不匹配")
client_update_repository.insert_upgrade_log(body.device_id, body.from_version, body.to_version, body.result)
return {"code": 0, "msg": "上报成功"}
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter, FastAPI, Request
from app.services import crash_api_service
router = APIRouter()
def register_exception_handlers(app: FastAPI):
app.add_exception_handler(crash_api_service.CrashApiException, crash_api_service.crash_api_exception_handler)
@router.get("/api/v1/health")
def crash_health():
return crash_api_service.health_payload()
@router.post("/api/v1/crash-reports")
async def create_crash_report(request: Request):
return await crash_api_service.create_crash_report(request)
@router.get("/api/v1/crash-reports/{report_id}")
def get_crash_report(report_id: str, request: Request):
return crash_api_service.get_crash_report_detail(report_id, request)
@router.get("/api/v1/crash-reports/{report_id}/files/{file_name}")
def download_crash_report_file(report_id: str, file_name: str, request: Request):
return crash_api_service.download_crash_report_file(report_id, file_name, request)
@router.post("/api/v1/symbols")
async def upload_symbols(request: Request):
return await crash_api_service.upload_symbols(request)
+68
View File
@@ -0,0 +1,68 @@
from fastapi import APIRouter, FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from app.core.config import configured_path
router = APIRouter(include_in_schema=False)
ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "legacy/admin.html")
ADMIN_UI_DIST_PATH = configured_path("ADMIN_UI_DIST_PATH", "admin-ui/dist")
ADMIN_UI_INDEX_PATH = ADMIN_UI_DIST_PATH / "index.html"
ADMIN_UI_ASSETS_PATH = ADMIN_UI_DIST_PATH / "assets"
def mount_admin_assets(app: FastAPI):
if ADMIN_UI_ASSETS_PATH.is_dir():
app.mount(
"/assets",
StaticFiles(directory=str(ADMIN_UI_ASSETS_PATH)),
name="admin_ui_assets",
)
@router.get("/")
@router.get("/admin.html")
def admin_page():
if ADMIN_UI_INDEX_PATH.is_file():
return FileResponse(ADMIN_UI_INDEX_PATH, headers={"Cache-Control": "no-store"})
if not ADMIN_HTML_PATH.is_file():
raise HTTPException(status_code=404, detail="管理页面文件不存在")
return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"})
@router.get("/legacy-admin.html")
def legacy_admin_page():
if not ADMIN_HTML_PATH.is_file():
raise HTTPException(status_code=404, detail="旧管理页面文件不存在")
return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"})
@router.get("/favicon.ico")
def favicon():
icon_path = ADMIN_UI_DIST_PATH / "favicon.ico"
if icon_path.is_file():
return FileResponse(icon_path, headers={"Cache-Control": "public, max-age=3600"})
return Response(status_code=204)
@router.get("/platform-config.json")
def admin_platform_config():
config_path = ADMIN_UI_DIST_PATH / "platform-config.json"
if not config_path.is_file():
raise HTTPException(status_code=404, detail="前端平台配置不存在")
return FileResponse(config_path, headers={"Cache-Control": "no-store"})
@router.get("/logo.svg")
def admin_logo():
logo_path = ADMIN_UI_DIST_PATH / "logo.svg"
if not logo_path.is_file():
raise HTTPException(status_code=404, detail="前端 Logo 不存在")
return FileResponse(logo_path, headers={"Cache-Control": "public, max-age=3600"})
@router.get("/health")
def health_check():
return {"status": "ok"}