feat(server): modularize backend and admin console
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -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": "渠道已保存"}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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": "授权已删除,历史记录仍会保留"}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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": "上报成功"}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user