feat: 完善服务端工程化、发布流程和自动化测试
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
@@ -8,11 +10,84 @@ from app.services import publish_service
|
||||
|
||||
router = APIRouter()
|
||||
PUBLISH_LOCK = asyncio.Lock()
|
||||
PUBLISH_JOBS: dict[str, dict] = {}
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def public_job(job: dict):
|
||||
return {
|
||||
"job_id": job["job_id"],
|
||||
"status": job["status"],
|
||||
"message": job["message"],
|
||||
"result": job.get("result"),
|
||||
"error": job.get("error"),
|
||||
"created_at": job["created_at"],
|
||||
"updated_at": job["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
async def run_publish_job(job_id: str, payload: dict):
|
||||
job = PUBLISH_JOBS[job_id]
|
||||
job.update(status="running", message="正在校验并发布版本", updated_at=utc_now_iso())
|
||||
try:
|
||||
result = await publish_service.publish_payload(payload)
|
||||
job.update(
|
||||
status="success",
|
||||
message=result.get("msg") or "发布完成",
|
||||
result=result,
|
||||
updated_at=utc_now_iso(),
|
||||
)
|
||||
except HTTPException as err:
|
||||
detail = err.detail if isinstance(err.detail, str) else str(err.detail)
|
||||
job.update(status="error", message=detail, error=err.detail, updated_at=utc_now_iso())
|
||||
except Exception as err:
|
||||
job.update(status="error", message=f"发布失败:{err}", error=str(err), updated_at=utc_now_iso())
|
||||
finally:
|
||||
if PUBLISH_LOCK.locked():
|
||||
PUBLISH_LOCK.release()
|
||||
|
||||
|
||||
@router.post("/admin/publish")
|
||||
async def admin_publish_version(request: Request, auth=Depends(require_permission("publish:manage"))):
|
||||
# 发布版本会写数据库、上传大量文件到 MinIO,并可能持续较长时间。
|
||||
# 同一时间只允许一个发布任务,避免两个版本并发写入导致目录、版本号或文件清单互相污染。
|
||||
if PUBLISH_LOCK.locked():
|
||||
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
||||
async with PUBLISH_LOCK:
|
||||
return await publish_service.publish_version(request)
|
||||
|
||||
|
||||
@router.post("/admin/publish/job")
|
||||
async def admin_publish_version_job(request: Request, auth=Depends(require_permission("publish:manage"))):
|
||||
if PUBLISH_LOCK.locked():
|
||||
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
||||
await PUBLISH_LOCK.acquire()
|
||||
try:
|
||||
payload = await publish_service.make_background_publish_payload(request)
|
||||
job_id = secrets.token_urlsafe(16)
|
||||
PUBLISH_JOBS[job_id] = {
|
||||
"job_id": job_id,
|
||||
"status": "queued",
|
||||
"message": "发布文件已接收,等待后台处理",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"created_at": utc_now_iso(),
|
||||
"updated_at": utc_now_iso(),
|
||||
}
|
||||
asyncio.create_task(run_publish_job(job_id, payload))
|
||||
return public_job(PUBLISH_JOBS[job_id])
|
||||
except Exception:
|
||||
if PUBLISH_LOCK.locked():
|
||||
PUBLISH_LOCK.release()
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/admin/publish/job/{job_id}")
|
||||
async def admin_publish_job_status(job_id: str, auth=Depends(require_permission("publish:manage"))):
|
||||
job = PUBLISH_JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="发布任务不存在或服务端已重启")
|
||||
return public_job(job)
|
||||
|
||||
@@ -45,6 +45,8 @@ def normalize_relative_path(raw_path: str) -> str:
|
||||
|
||||
@router.post("/api/v1/device/issue")
|
||||
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
|
||||
# 设备登记:客户端首次启动时用 License 换取服务端签名的设备凭证。
|
||||
# 后续更新接口依赖这个凭证里的 app_id/channel/device_id/license_id,而不是只相信客户端自报字段。
|
||||
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="设备登记参数无效")
|
||||
@@ -90,6 +92,8 @@ async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
|
||||
|
||||
@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
|
||||
@@ -176,6 +180,8 @@ async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
|
||||
|
||||
@router.post("/api/v1/update/download-url")
|
||||
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
|
||||
# 下载链接不直接暴露 MinIO 永久地址,而是按版本和设备凭证生成短期可用的预签名 URL。
|
||||
# 这样既能让客户端直接下载大文件,又能保留服务端授权控制。
|
||||
identity = request.state.device_identity
|
||||
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
|
||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||
@@ -213,6 +219,8 @@ async def report_download(request: Request, body: DownloadReportRequest = Body(.
|
||||
|
||||
@router.post("/api/v1/update/manifest")
|
||||
async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
|
||||
# Manifest 是某个版本的文件清单:路径、大小、SHA256、是否可执行。
|
||||
# 客户端必须先验证服务端签名,再按清单下载和校验文件,防止升级包被篡改。
|
||||
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)
|
||||
|
||||
@@ -17,6 +17,8 @@ def crash_health():
|
||||
|
||||
@router.post("/api/v1/crash-reports")
|
||||
async def create_crash_report(request: Request):
|
||||
# 崩溃报告接口给 SimCAE/CrashReporter 调用,接收 metadata、minidump 和可选附件。
|
||||
# 具体 token 校验、大小限制、落盘和幂等处理都集中在 crash_api_service。
|
||||
return await crash_api_service.create_crash_report(request)
|
||||
|
||||
|
||||
@@ -32,4 +34,5 @@ def download_crash_report_file(report_id: str, file_name: str, request: Request)
|
||||
|
||||
@router.post("/api/v1/symbols")
|
||||
async def upload_symbols(request: Request):
|
||||
# 符号包用于后续解析 minidump 堆栈;它和普通升级文件分开存储、分开授权。
|
||||
return await crash_api_service.upload_symbols(request)
|
||||
|
||||
Reference in New Issue
Block a user