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)
|
||||
|
||||
@@ -10,6 +10,8 @@ from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
|
||||
|
||||
|
||||
def license_key_encryption_material() -> bytes:
|
||||
# 数据库只存 License Key 的密文和 hash。
|
||||
# hash 用于客户端提交 License 时快速匹配;密文用于后台列表需要再次展示原始 Key 的场景。
|
||||
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
|
||||
if configured_secret:
|
||||
return configured_secret.encode("utf-8")
|
||||
|
||||
@@ -108,6 +108,8 @@ def should_skip_release_directory(part: str) -> bool:
|
||||
|
||||
def should_skip_release_file(relative_path: str) -> bool:
|
||||
folded = relative_path.casefold()
|
||||
# 发布包里不能带客户端本机运行态文件。
|
||||
# 这些文件包含 License、设备身份、策略缓存等机器相关数据,应由客户端运行时生成或服务端重新签发。
|
||||
runtime_protected_files = {
|
||||
"client.ini",
|
||||
"bootstrap.exe",
|
||||
@@ -163,6 +165,8 @@ def supported_archive_kind(filename: str) -> str:
|
||||
|
||||
|
||||
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
|
||||
# 解压压缩包时必须防止 Zip Slip / 路径穿越。
|
||||
# 任何 ../ 或绝对路径都不能写出临时解压目录。
|
||||
target = (root / relative_path).resolve()
|
||||
root_resolved = root.resolve()
|
||||
if target != root_resolved and root_resolved not in target.parents:
|
||||
@@ -304,6 +308,8 @@ def release_items_from_extracted_dir(extract_dir: Path, required_main_executable
|
||||
|
||||
|
||||
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
|
||||
# 有些压缩包会多包一层顶级目录,例如 SimCAE-1.0.0/bin/SimCAE.exe。
|
||||
# 如果所有文件都在同一个顶级目录下,并且能找到主程序,就自动剥掉这一层,降低发布者操作成本。
|
||||
if not items:
|
||||
return items
|
||||
required_main_key = required_main_executable.casefold()
|
||||
@@ -417,6 +423,7 @@ async def close_upload_safely(upload):
|
||||
|
||||
def ensure_publish_storage_space(next_file_size: int):
|
||||
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
|
||||
MINIO_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
|
||||
if available_bytes < required_bytes:
|
||||
raise HTTPException(
|
||||
@@ -431,6 +438,8 @@ def ensure_publish_storage_space(next_file_size: int):
|
||||
|
||||
|
||||
async def collect_publish_items(request: Request):
|
||||
# 发布入口支持两种方式:浏览器选择 Release 目录,或上传一个压缩发布包。
|
||||
# 这里先做请求大小、临时目录空间和 MinIO 存储空间检查,避免大包把服务器拖死。
|
||||
content_length_header = request.headers.get("content-length")
|
||||
try:
|
||||
content_length = int(content_length_header or 0)
|
||||
@@ -595,6 +604,7 @@ async def collect_publish_items(request: Request):
|
||||
"upload_items": upload_items,
|
||||
"archive_upload": archive_upload,
|
||||
"archive_temp_dir": archive_temp_dir,
|
||||
"job_temp_dir": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -610,11 +620,55 @@ def cleanup_path(path: Path | None):
|
||||
print(f"清理临时文件失败: {path}: {err}")
|
||||
|
||||
|
||||
async def publish_version(request: Request):
|
||||
async def make_background_publish_payload(request: Request):
|
||||
# 后台发布任务不能继续持有浏览器上传流,所以目录模式要先复制到服务端临时目录。
|
||||
# 压缩包模式在 collect_publish_items 中已经解压为本地文件,只需要保留解压目录到任务结束。
|
||||
payload = await collect_publish_items(request)
|
||||
job_temp_dir = Path(tempfile.mkdtemp(prefix="publish_job_", dir=UPLOAD_SPOOL_DIR))
|
||||
converted_items = []
|
||||
|
||||
try:
|
||||
for item in payload["upload_items"]:
|
||||
if "upload" not in item:
|
||||
converted_items.append(item)
|
||||
continue
|
||||
upload = item["upload"]
|
||||
target = ensure_inside_directory(job_temp_dir, item["path"])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
upload.file.seek(0)
|
||||
with target.open("wb") as output:
|
||||
shutil.copyfileobj(upload.file, output, length=1024 * 1024)
|
||||
except OSError as err:
|
||||
raise HTTPException(status_code=500, detail={"error": "publish_job_spool_failed", "msg": str(err)})
|
||||
finally:
|
||||
await close_upload_safely(upload)
|
||||
converted_items.append({"path": item["path"], "local_path": target})
|
||||
payload["upload_items"] = converted_items
|
||||
payload["job_temp_dir"] = job_temp_dir
|
||||
payload["archive_upload"] = None
|
||||
return payload
|
||||
except Exception:
|
||||
for item in payload.get("upload_items", []):
|
||||
upload = item.get("upload") if isinstance(item, dict) else None
|
||||
if upload is not None:
|
||||
await close_upload_safely(upload)
|
||||
archive_upload = payload.get("archive_upload")
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
cleanup_path(payload.get("archive_temp_dir"))
|
||||
cleanup_path(job_temp_dir)
|
||||
raise
|
||||
|
||||
|
||||
async def publish_payload(payload: dict):
|
||||
# 完整发布事务:
|
||||
# 1. 使用已收集的发布文件;2. 创建版本记录;3. 上传文件到 MinIO;4. 写入文件 hash/size。
|
||||
# 任何一步失败都会回滚数据库,并删除已经上传的对象前缀,避免出现“半个版本”。
|
||||
upload_items = payload["upload_items"]
|
||||
archive_upload = payload["archive_upload"]
|
||||
archive_temp_dir = payload["archive_temp_dir"]
|
||||
job_temp_dir = payload.get("job_temp_dir")
|
||||
app_id = payload["app_id"]
|
||||
channel = payload["channel"]
|
||||
version = payload["version"]
|
||||
@@ -689,3 +743,9 @@ async def publish_version(request: Request):
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
cleanup_path(archive_temp_dir)
|
||||
cleanup_path(job_temp_dir)
|
||||
|
||||
|
||||
async def publish_version(request: Request):
|
||||
payload = await collect_publish_items(request)
|
||||
return await publish_payload(payload)
|
||||
|
||||
@@ -21,11 +21,15 @@ def load_manifest_private_key():
|
||||
|
||||
|
||||
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
|
||||
# 签名之前必须先规范化 JSON:固定字段顺序、去掉空格、排除 signature 字段。
|
||||
# 否则同一份 Manifest 在不同环境下序列化结果不同,会导致客户端验签失败。
|
||||
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
|
||||
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def sign_manifest(manifest_obj: dict) -> str:
|
||||
# 服务端只保存私钥,客户端只分发公钥。
|
||||
# 客户端能验证 Manifest 确实由服务端签发,但无法伪造新的签名。
|
||||
json_text = canonical_manifest_bytes(manifest_obj)
|
||||
private_key = load_manifest_private_key()
|
||||
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
Reference in New Issue
Block a user