2026-07-01 03:32:41 +00:00
from fastapi import FastAPI , Body , Request , HTTPException , UploadFile , File , Form , Header , Depends
from fastapi.staticfiles import StaticFiles
2026-07-07 09:48:01 +00:00
from fastapi.responses import FileResponse , JSONResponse , StreamingResponse
2026-07-01 03:32:41 +00:00
from pydantic import BaseModel
import os
import sqlite3
from pathlib import Path , PurePosixPath
from dotenv import load_dotenv
from contextlib import asynccontextmanager
2026-07-09 09:00:51 +00:00
import asyncio
2026-07-01 03:32:41 +00:00
import db
import minio_tool
import hashlib
import secrets
import base64
import json
2026-07-07 09:48:01 +00:00
import uuid
2026-07-01 03:32:41 +00:00
from datetime import datetime , timezone , timedelta
from fastapi.middleware.cors import CORSMiddleware
import socket
import subprocess
import time
import shutil
import tempfile
import urllib.request
2026-07-09 09:00:51 +00:00
import zipfile
import tarfile
2026-07-01 03:32:41 +00:00
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
# 加载环境变量
2026-07-07 09:48:01 +00:00
BASE_DIR = Path ( __file__ ) . resolve () . parent
2026-07-09 09:00:51 +00:00
ENV_FILE_PATH = Path ( os . getenv ( "ENV_FILE_PATH" , str ( BASE_DIR / ".env" ))) . expanduser ()
if not ENV_FILE_PATH . is_absolute ():
ENV_FILE_PATH = BASE_DIR / ENV_FILE_PATH
load_dotenv ( ENV_FILE_PATH )
2026-07-07 09:48:01 +00:00
def env_bool ( name : str , default : bool = False ) -> bool :
value = os . getenv ( name )
return default if value is None else value . strip () . lower () in { "1" , "true" , "yes" , "on" }
def configured_path ( name : str , default : str ) -> Path :
path = Path ( os . getenv ( name , default )) . expanduser ()
return path if path . is_absolute () else BASE_DIR / path
SERVER_HOST = os . getenv ( "SERVER_HOST" , "0.0.0.0" )
SERVER_PORT = int ( os . getenv ( "SERVER_PORT" , "8000" ))
2026-07-01 03:32:41 +00:00
VALID_CLIENT_TOKEN = os . getenv ( "CLIENT_API_TOKEN" )
2026-07-07 09:48:01 +00:00
TARGET_PLATFORM = os . getenv ( "TARGET_PLATFORM" , "windows" )
TARGET_ARCH = os . getenv ( "TARGET_ARCH" , "x64" )
2026-07-09 09:00:51 +00:00
RELEASE_MAIN_EXECUTABLE = os . getenv ( "RELEASE_MAIN_EXECUTABLE" , "MainApp.exe" ) . strip () . replace ( chr ( 92 ), "/" ) . strip ( "/" )
2026-07-07 09:48:01 +00:00
SIGNING_KEY_ID = os . getenv ( "SIGNING_KEY_ID" , "manifest-key-v1" )
2026-07-01 03:32:41 +00:00
# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。
2026-07-07 09:48:01 +00:00
UPLOAD_SPOOL_DIR = configured_path ( "UPLOAD_SPOOL_DIR" , "upload_spool" )
2026-07-01 03:32:41 +00:00
UPLOAD_SPOOL_DIR . mkdir ( parents = True , exist_ok = True )
tempfile . tempdir = str ( UPLOAD_SPOOL_DIR )
2026-07-07 09:48:01 +00:00
MINIO_DATA_DIR = configured_path ( "MINIO_DATA_DIR" , "minio_data" )
UPLOAD_SPACE_RESERVE = int ( os . getenv ( "UPLOAD_SPACE_RESERVE_MB" , "256" )) * 1024 * 1024
2026-07-09 09:00:51 +00:00
PUBLISH_MAX_REQUEST_BYTES = int ( os . getenv ( "PUBLISH_MAX_REQUEST_MB" , "4096" )) * 1024 * 1024
PUBLISH_MAX_FILES = int ( os . getenv ( "PUBLISH_MAX_FILES" , "20000" ))
PUBLISH_MAX_FIELDS = int ( os . getenv ( "PUBLISH_MAX_FIELDS" , str ( PUBLISH_MAX_FILES + 100 )))
PUBLISH_LOCK = asyncio . Lock ()
2026-07-07 09:48:01 +00:00
# SimCAE 崩溃报告私有存储与上传限制
CRASH_STORAGE_ROOT = configured_path ( "CRASH_STORAGE_ROOT" , "crash_storage" )
CRASH_METADATA_MAX_BYTES = int ( os . getenv ( "CRASH_METADATA_MAX_KB" , "256" )) * 1024
CRASH_MINIDUMP_MAX_BYTES = int ( os . getenv ( "CRASH_MINIDUMP_MAX_MB" , "128" )) * 1024 * 1024
CRASH_ATTACHMENTS_MAX_BYTES = int ( os . getenv ( "CRASH_ATTACHMENTS_MAX_MB" , "64" )) * 1024 * 1024
CRASH_REQUEST_MAX_BYTES = int ( os . getenv ( "CRASH_REQUEST_MAX_MB" , "200" )) * 1024 * 1024
CRASH_SYMBOLS_MAX_BYTES = int ( os . getenv ( "CRASH_SYMBOLS_MAX_MB" , "512" )) * 1024 * 1024
CRASH_SERVICE_VERSION = os . getenv ( "CRASH_SERVICE_VERSION" , "1.0.0" )
# 后台管理令牌:当前版本直接使用 .env 中的 ADMIN_TOKEN 明文配置。
ADMIN_TOKEN = os . getenv ( "ADMIN_TOKEN" , "" ) . strip ()
if not ADMIN_TOKEN :
raise RuntimeError ( "请在环境变量或 .env 中配置 ADMIN_TOKEN" )
2026-07-01 03:32:41 +00:00
def token_digest ( token : str ) -> str :
return hashlib . sha256 ( token . encode ( "utf-8" )) . hexdigest ()
2026-07-09 09:00:51 +00:00
def quote_env_value ( value : str ) -> str :
unsafe_chars = set ( " \t\n\r # \" '" )
if value and not any ( ch in unsafe_chars for ch in value ):
return value
return '"' + value . replace ( ' \\ ' , ' \\\\ ' ) . replace ( '"' , ' \" ' ) + '"'
def persist_env_value ( path : Path , key : str , value : str ) -> bool :
line = f " { key } = { quote_env_value ( value ) } \n "
if path . exists ():
lines = path . read_text ( encoding = "utf-8" ) . splitlines ( keepends = True )
replaced = False
for index , item in enumerate ( lines ):
stripped = item . lstrip ()
if stripped . startswith ( "#" ) or "=" not in stripped :
continue
name = stripped . split ( "=" , 1 )[ 0 ] . strip ()
if name == key :
lines [ index ] = line
replaced = True
break
if not replaced :
if lines and not lines [ - 1 ] . endswith (( " \n " , " \r " )):
lines [ - 1 ] += " \n "
lines . append ( line )
path . write_text ( "" . join ( lines ), encoding = "utf-8" )
return True
path . write_text ( line , encoding = "utf-8" )
return True
2026-07-07 09:48:01 +00:00
CRASH_REPORT_TOKEN = os . getenv ( "CRASH_REPORT_TOKEN" ) or os . getenv ( "SIMCAE_CRASH_CLIENT_TOKEN" ) or VALID_CLIENT_TOKEN
CRASH_SYMBOL_TOKEN = os . getenv ( "CRASH_SYMBOL_TOKEN" ) or os . getenv ( "SIMCAE_SYMBOL_TOKEN" ) or ADMIN_TOKEN
CRASH_ADMIN_TOKEN = os . getenv ( "CRASH_ADMIN_TOKEN" )
2026-07-01 03:32:41 +00:00
# ========== 生命周期初始化 ==========
@asynccontextmanager
async def lifespan ( app : FastAPI ):
print ( "===== 进程启动,开始初始化数据库 =====" )
2026-07-07 09:48:01 +00:00
db_file = db . DB_FILE
sql_file_path = db . SQL_FILE
2026-07-01 03:32:41 +00:00
conn = sqlite3 . connect ( db_file , check_same_thread = False )
conn . row_factory = sqlite3 . Row
cur = conn . cursor ()
if sql_file_path . exists ():
with open ( sql_file_path , "r" , encoding = "utf-8" ) as f :
full_sql = f . read ()
cur . executescript ( full_sql )
2026-07-07 09:48:01 +00:00
version_columns = { row [ 1 ] for row in cur . execute ( "PRAGMA table_info(versions)" ) . fetchall ()}
if "client_protocol" not in version_columns :
cur . execute ( "ALTER TABLE versions ADD COLUMN client_protocol INTEGER NOT NULL DEFAULT 1" )
device_columns = { row [ 1 ] for row in cur . execute ( "PRAGMA table_info(devices)" ) . fetchall ()}
if "license_id" not in device_columns : cur . execute ( "ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''" )
if "channel" not in device_columns : cur . execute ( "ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'" )
for app_row in cur . execute ( "SELECT app_id FROM apps" ) . fetchall ():
if not cur . execute ( "SELECT 1 FROM channels WHERE app_id=? LIMIT 1" , ( app_row [ 0 ],)) . fetchone ():
cur . executemany ( "INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)" ,
[( app_row [ 0 ], "stable" , "正式版" , 1 , 10 ), ( app_row [ 0 ], "preview" , "预览版" , 1 , 20 ), ( app_row [ 0 ], "dev" , "开发版" , 1 , 30 )])
2026-07-01 03:32:41 +00:00
conn . commit ()
print ( f "✅ 成功执行tables.sql,创建数据表与测试数据" )
else :
print ( f "❌ 错误:当前目录找不到 tables.sql 文件!" )
conn . close ()
print ( "===== 数据库初始化完成 =====" )
2026-07-07 09:48:01 +00:00
CRASH_STORAGE_ROOT . mkdir ( parents = True , exist_ok = True )
( CRASH_STORAGE_ROOT / "reports" ) . mkdir ( parents = True , exist_ok = True )
( CRASH_STORAGE_ROOT / "symbols" ) . mkdir ( parents = True , exist_ok = True )
2026-07-01 03:32:41 +00:00
start_minio_if_needed ()
yield
print ( "服务进程正常退出" )
def is_minio_running ( endpoint : str ) -> bool :
try :
if endpoint . startswith ( "http://" ) or endpoint . startswith ( "https://" ):
base_url = endpoint . rstrip ( "/" )
else :
2026-07-07 09:48:01 +00:00
scheme = "https" if env_bool ( "MINIO_SECURE" ) else "http"
base_url = f " { scheme } :// { endpoint . rstrip ( '/' ) } "
2026-07-01 03:32:41 +00:00
health_url = base_url + "/minio/health/live"
2026-07-07 09:48:01 +00:00
with urllib . request . urlopen ( health_url , timeout = float ( os . getenv ( "MINIO_HEALTH_TIMEOUT_SEC" , "2" ))) as resp :
2026-07-01 03:32:41 +00:00
return resp . status == 200
except Exception :
return False
def download_minio_binary ( target_path : Path ) -> str | None :
2026-07-07 09:48:01 +00:00
url = os . getenv ( "MINIO_DOWNLOAD_URL" , "https://dl.min.io/server/minio/release/linux-amd64/minio" )
2026-07-01 03:32:41 +00:00
try :
print ( f "MinIO 二进制未找到,尝试下载到 { target_path } " )
target_path . parent . mkdir ( parents = True , exist_ok = True )
with urllib . request . urlopen ( url , timeout = 30 ) as response , open ( target_path , 'wb' ) as out_file :
out_file . write ( response . read ())
target_path . chmod ( 0o755 )
abs_path = str ( target_path . resolve ())
print ( f "已下载 MinIO 二进制到 { abs_path } " )
return abs_path
except Exception as err :
print ( f "自动下载 MinIO 失败: { err } " )
return None
def start_minio_if_needed ():
2026-07-07 09:48:01 +00:00
if not env_bool ( "MINIO_AUTO_START" , True ):
return
2026-07-01 03:32:41 +00:00
endpoint = os . getenv ( 'MINIO_ENDPOINT' )
if not endpoint :
return
if is_minio_running ( endpoint ):
print ( f "MinIO 已在 { endpoint } 运行,跳过启动" )
return
minio_cmd = shutil . which ( 'minio' )
if not minio_cmd :
minio_bin = Path ( os . getenv ( 'MINIO_BIN_PATH' , 'minio' ))
2026-07-07 09:48:01 +00:00
minio_cmd = str ( minio_bin . resolve ()) if minio_bin . exists () else None
if not minio_cmd and env_bool ( "MINIO_AUTO_DOWNLOAD" , False ):
minio_cmd = download_minio_binary ( minio_bin )
2026-07-01 03:32:41 +00:00
if not minio_cmd :
2026-07-07 09:48:01 +00:00
print ( "MinIO 二进制未找到,且未启用或未完成自动下载" )
2026-07-01 03:32:41 +00:00
return
2026-07-07 09:48:01 +00:00
data_dir = MINIO_DATA_DIR
2026-07-01 03:32:41 +00:00
data_dir . mkdir ( parents = True , exist_ok = True )
2026-07-07 09:48:01 +00:00
minio_address = os . getenv ( "MINIO_LISTEN_ADDRESS" , ":9000" )
print ( f "尝试自动启动 MinIO: { minio_cmd } server { data_dir } --address { minio_address } " )
2026-07-01 03:32:41 +00:00
try :
2026-07-07 09:48:01 +00:00
subprocess . Popen ([ minio_cmd , 'server' , str ( data_dir ), '--address' , minio_address ], stdout = subprocess . DEVNULL , stderr = subprocess . DEVNULL )
time . sleep ( float ( os . getenv ( "MINIO_START_WAIT_SEC" , "5" )))
2026-07-01 03:32:41 +00:00
if is_minio_running ( endpoint ):
print ( "MinIO 已启动成功" )
else :
print ( "MinIO 启动失败,仍然无法连接" )
except Exception as err :
print ( f "启动 MinIO 失败: { err } " )
# Manifest signing key path
2026-07-07 09:48:01 +00:00
MANIFEST_PRIVATE_KEY_PATH = str ( configured_path ( "MANIFEST_PRIVATE_KEY_PATH" , "keys/manifest_private_key.pem" ))
2026-07-01 03:32:41 +00:00
# 创建APP绑定生命周期
2026-07-07 09:48:01 +00:00
app = FastAPI ( title = os . getenv ( "SERVICE_TITLE" , "软件自动升级服务" ), lifespan = lifespan )
2026-07-09 09:00:51 +00:00
ADMIN_HTML_PATH = configured_path ( "ADMIN_HTML_PATH" , "admin.html" )
2026-07-07 09:48:01 +00:00
@app.get ( "/" , include_in_schema = False )
@app.get ( "/admin.html" , include_in_schema = False )
def 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" })
@app.get ( "/health" , include_in_schema = False )
def health_check ():
return { "status" : "ok" }
2026-07-01 03:32:41 +00:00
# 本地上传备份文件目录(MinIO 不通时会保存到这里)
2026-07-07 09:48:01 +00:00
app . mount ( "/static" , StaticFiles ( directory = str ( minio_tool . LOCAL_UPLOAD_ROOT )), name = "static" )
2026-07-01 03:32:41 +00:00
# 跨域配置(给admin前端页面用)
2026-07-07 09:48:01 +00:00
cors_origins = [ item . strip () for item in os . getenv ( "CORS_ALLOW_ORIGINS" , "*" ) . split ( "," ) if item . strip ()]
2026-07-01 03:32:41 +00:00
app . add_middleware (
CORSMiddleware ,
2026-07-07 09:48:01 +00:00
allow_origins = cors_origins ,
2026-07-01 03:32:41 +00:00
allow_credentials = True ,
allow_methods = [ "*" ],
allow_headers = [ "*" ],
)
2026-07-07 09:48:01 +00:00
# 管理后台写操作统一审计;不保存令牌明文和请求正文。
@app.middleware ( "http" )
async def admin_audit_middleware ( request : Request , call_next ):
2026-07-09 09:00:51 +00:00
should_audit = ( request . url . path . startswith ( "/admin/" ) and request . method not in ( "GET" , "HEAD" , "OPTIONS" )
and not request . url . path . startswith ( "/admin/audit-log/" ))
2026-07-07 09:48:01 +00:00
status_code = 500
try :
response = await call_next ( request ); status_code = response . status_code ; return response
finally :
if should_audit :
try :
token = request . headers . get ( "X-Admin-Token" , "" )
conn = db . get_conn (); conn . execute ( """INSERT INTO admin_audit_logs
(actor_hash,action,method,path,target,result,status_code,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?)""" ,
( token_digest ( token )[: 16 ] if token else "anonymous" , request . url . path . removeprefix ( "/admin/" ),
request . method , request . url . path , request . url . query [: 500 ],
"success" if 200 <= status_code < 400 else "fail" , status_code ,
request . client . host if request . client else "" , request . headers . get ( "user-agent" , "" )[: 300 ])); conn . commit (); conn . close ()
except Exception as audit_error :
print ( "管理员审计日志写入失败:" , audit_error )
2026-07-01 03:32:41 +00:00
# ===================== 全局客户端鉴权中间件 =====================
@app.middleware ( "http" )
async def auth_middleware ( request : Request , call_next ):
path = request . url . path
# 后台接口、文档接口、静态回退链接全部跳过客户端token校验
2026-07-07 09:48:01 +00:00
skip_paths = ( "/admin" , "/docs" , "/openapi.json" , "/redoc" , "/static" , "/health" , "/api/v1/health" , "/api/v1/crash-reports" , "/api/v1/symbols" )
if path == "/" or path . startswith ( skip_paths ):
2026-07-01 03:32:41 +00:00
response = await call_next ( request )
return response
client_token = request . headers . get ( "X-Client-Token" )
if client_token != VALID_CLIENT_TOKEN :
2026-07-07 09:48:01 +00:00
return JSONResponse ( status_code = 401 , content = { "detail" : "非法客户端,令牌校验失败" })
if path != "/api/v1/device/issue" :
credential = request . headers . get ( "X-Device-Credential" , "" )
if not credential :
return JSONResponse ( status_code = 401 , content = { "detail" : {
"error" : "device_credential_required" , "msg" : "缺少设备身份凭证"
}})
try :
request . state . device_identity = verify_device_credential ( credential )
except HTTPException as exc :
return JSONResponse ( status_code = exc . status_code , content = { "detail" : exc . detail })
2026-07-01 03:32:41 +00:00
response = await call_next ( request )
return response
# ========== 请求体模型 ==========
class CheckUpdateReq ( BaseModel ):
app_id : str
current_version : str
channel : str
2026-07-07 09:48:01 +00:00
client_protocol : int = 1
2026-07-01 03:32:41 +00:00
class DownloadUrlReq ( BaseModel ):
app_id : str
channel : str
version : str
version_id : int
files : list [ str ]
class ManifestReq ( BaseModel ):
app_id : str
channel : str
version : str
version_id : int
class ReportReq ( BaseModel ):
2026-07-07 09:48:01 +00:00
app_id : str
2026-07-01 03:32:41 +00:00
device_id : str
from_version : str
to_version : str
result : str
class ChangeAdminTokenReq ( BaseModel ):
new_token : str
2026-07-07 09:48:01 +00:00
class DownloadReportReq ( BaseModel ):
app_id : str
channel : str
version : str
result : str
files : list [ dict ]
class DeviceIssueReq ( BaseModel ):
app_id : str
channel : str
license_key : str
installation_id : str
machine_hash : str = ""
2026-07-01 03:32:41 +00:00
# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头
def admin_auth ( X_Admin_Token : str = Header ( "" )):
2026-07-07 09:48:01 +00:00
if not secrets . compare_digest ( X_Admin_Token , ADMIN_TOKEN ):
2026-07-01 03:32:41 +00:00
raise HTTPException ( status_code = 403 , detail = "后台密钥错误,禁止访问" )
return True
2026-07-07 09:48:01 +00:00
@app.get ( "/admin/auth/check" )
def admin_auth_check ( auth = Depends ( admin_auth )):
return { "code" : 0 , "msg" : "管理员令牌有效" }
2026-07-01 03:32:41 +00:00
# Manifest signing helpers
def load_manifest_private_key ():
key_path = Path ( MANIFEST_PRIVATE_KEY_PATH )
if not key_path . exists ():
raise FileNotFoundError ( f "Manifest private key not found: { key_path } " )
key_data = key_path . read_bytes ()
return load_pem_private_key ( key_data , password = None )
def canonical_manifest_bytes ( manifest_obj : dict ) -> bytes :
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 :
json_text = canonical_manifest_bytes ( manifest_obj )
private_key = load_manifest_private_key ()
signature = private_key . sign (
json_text ,
padding . PKCS1v15 (),
hashes . SHA256 ()
)
return base64 . b64encode ( signature ) . decode ( "ascii" )
def canonical_signed_bytes ( obj : dict ) -> bytes :
copy = { k : obj [ k ] for k in obj if k != "signature" }
return json . dumps ( copy , sort_keys = True , separators = ( "," , ":" ), ensure_ascii = False ) . encode ( "utf-8" )
def sign_policy ( policy_obj : dict ) -> tuple [ str , str ]:
payload = canonical_signed_bytes ( policy_obj )
signature = load_manifest_private_key () . sign ( payload , padding . PKCS1v15 (), hashes . SHA256 ())
return payload . decode ( "utf-8" ), base64 . b64encode ( signature ) . decode ( "ascii" )
2026-07-07 09:48:01 +00:00
def sign_device_identity ( identity : dict ) -> tuple [ str , str ]:
payload = canonical_signed_bytes ( identity )
signature = load_manifest_private_key () . sign ( payload , padding . PKCS1v15 (), hashes . SHA256 ())
return payload . decode ( "utf-8" ), base64 . b64encode ( signature ) . decode ( "ascii" )
def verify_device_credential ( encoded : str ) -> dict :
try :
wrapper = json . loads ( base64 . b64decode ( encoded , validate = True ) . decode ( "utf-8" ))
identity_text = wrapper [ "identity_text" ]
signature = base64 . b64decode ( wrapper [ "signature" ], validate = True )
load_manifest_private_key () . public_key () . verify ( signature , identity_text . encode (), padding . PKCS1v15 (), hashes . SHA256 ())
identity = json . loads ( identity_text )
if any ( not identity . get ( k ) for k in ( "device_id" , "license_id" , "app_id" , "channel" , "installation_id" , "credential_seq" , "valid_until" )):
raise ValueError ( "missing field" )
except Exception :
raise HTTPException ( status_code = 401 , detail = { "error" : "invalid_device_credential" , "msg" : "设备凭证格式或签名无效" })
conn = db . get_conn (); row = conn . execute ( "SELECT * FROM devices WHERE device_id=?" , ( identity [ "device_id" ],)) . fetchone ()
if not row :
conn . close (); raise HTTPException ( status_code = 401 , detail = { "error" : "device_not_registered" , "msg" : "设备未登记" })
if row [ "disabled" ]:
reason = row [ "disabled_reason" ] or "设备已被管理员禁用" ; conn . close ()
raise HTTPException ( status_code = 403 , detail = { "error" : "device_disabled" , "msg" : reason })
if row [ "app_id" ] != identity [ "app_id" ] or row [ "installation_id" ] != identity [ "installation_id" ] or int ( row [ "credential_seq" ]) != int ( identity [ "credential_seq" ]):
conn . close (); raise HTTPException ( status_code = 401 , detail = { "error" : "device_credential_revoked" , "msg" : "设备凭证已失效" })
license_row = conn . execute ( "SELECT * FROM licenses WHERE license_id=?" , ( identity . get ( "license_id" , "" ),)) . fetchone ()
now = datetime . now ( timezone . utc )
try : license_expiry = datetime . fromisoformat (( license_row [ "valid_until" ] if license_row else "" ) . replace ( "Z" , "+00:00" ))
except ValueError : license_expiry = now - timedelta ( seconds = 1 )
if not license_row or license_row [ "status" ] != "active" or license_expiry <= now :
conn . close (); raise HTTPException ( status_code = 403 , detail = { "error" : "license_invalid" , "msg" : "授权不存在、已禁用或已过期" })
if license_row [ "app_id" ] != identity [ "app_id" ] or license_row [ "channel_code" ] != identity . get ( "channel" ):
conn . close (); raise HTTPException ( status_code = 403 , detail = { "error" : "license_scope_mismatch" , "msg" : "授权应用或渠道不匹配" })
conn . execute ( "UPDATE devices SET last_seen_at=datetime('now') WHERE device_id=?" , ( identity [ "device_id" ],)); conn . commit (); conn . close ()
return identity
2026-07-01 03:32:41 +00:00
def policy_row_to_dict ( row ) -> dict :
if not row :
return {
"policy_seq" : 1 , "force_update" : False , "allow_rollback" : False ,
"offline_allowed" : True , "valid_until" : "2099-12-31T23:59:59Z" ,
"min_supported_version" : "" , "disabled_versions" : [], "message" : ""
}
try :
disabled = json . loads ( row [ "disabled_versions" ] or "[]" )
except ( TypeError , json . JSONDecodeError ):
disabled = []
return {
"policy_seq" : int ( row [ "policy_seq" ]), "force_update" : bool ( row [ "force_update" ]),
"allow_rollback" : bool ( row [ "allow_rollback" ]), "offline_allowed" : bool ( row [ "offline_allowed" ]),
"valid_until" : row [ "valid_until" ], "min_supported_version" : row [ "min_supported_version" ] or "" ,
"disabled_versions" : disabled if isinstance ( disabled , list ) else [], "message" : row [ "message" ] or ""
}
2026-07-07 09:48:01 +00:00
def validate_channel_code ( code : str ) -> bool :
return bool ( code ) and len ( code ) <= 32 and code [ 0 ] . isalnum () and all ( c . isalnum () or c in "_-" for c in code )
def require_channel ( conn , app_id : str , channel : str , require_enabled : bool = True ):
row = conn . execute ( "SELECT * FROM channels WHERE app_id=? AND channel_code=?" , ( app_id , channel )) . fetchone ()
if not row : raise HTTPException ( status_code = 400 , detail = { "error" : "channel_not_found" , "msg" : f "渠道 { channel } 不存在" })
if require_enabled and not row [ "enabled" ]: raise HTTPException ( status_code = 403 , detail = { "error" : "channel_disabled" , "msg" : f "渠道 { channel } 已停用" })
return row
2026-07-01 03:32:41 +00:00
def is_executable_path ( path : str ) -> bool :
return path . lower () . endswith (( ".exe" , ".dll" ))
def version_key ( version : str ):
parts = []
for part in version . split ( "." ):
digits = "" . join ( ch for ch in part if ch . isdigit ())
parts . append ( int ( digits ) if digits else 0 )
return tuple ( parts )
def normalize_relative_path ( raw_path : str ) -> str :
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 ()
2026-07-07 09:48:01 +00:00
def validate_release_path ( relative_path : str ):
folded = relative_path . casefold ()
parts = PurePosixPath ( folded ) . parts
for part in parts [: - 1 ]:
2026-07-09 09:00:51 +00:00
if should_skip_release_directory ( part ):
2026-07-07 09:48:01 +00:00
raise HTTPException (
status_code = 400 ,
detail = f "发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: { relative_path } "
)
2026-07-09 09:00:51 +00:00
def should_skip_release_directory ( part : str ) -> bool :
folded = part . casefold ()
blocked_directories = { ".git" , ".vs" , "cmakefiles" , "debug" , "update" , "update_temp" }
return folded in blocked_directories or folded . startswith ( "build" ) or folded . endswith ( "_autogen" )
def should_skip_release_file ( relative_path : str ) -> bool :
folded = relative_path . casefold ()
runtime_protected_files = {
"client.ini" ,
"bootstrap.exe" ,
"config/app_config.json" ,
"config/local_state.json" ,
"config/client_identity.dat" ,
"config/version_policy.dat" ,
}
if folded in runtime_protected_files :
return True
if folded . startswith ( "bin/" ) and folded [ 4 :] in runtime_protected_files :
return True
if any ( folded . endswith ( "/" + protected ) for protected in runtime_protected_files ):
return True
if any ( folded . endswith ( "/bin/" + protected ) for protected in runtime_protected_files ):
return True
return folded . endswith (( ".pdb" , ".ilk" , ".obj" ))
def should_skip_release_path ( relative_path : str ) -> bool :
folded = relative_path . casefold ()
parts = PurePosixPath ( folded ) . parts
if any ( should_skip_release_directory ( part ) for part in parts [: - 1 ]):
return True
return should_skip_release_file ( relative_path )
def calc_local_file_sha256 ( path : Path , chunk_size : int = 1024 * 1024 ):
sha = hashlib . sha256 ()
total = 0
try :
with path . open ( "rb" ) as stream :
while True :
chunk = stream . read ( chunk_size )
if not chunk :
break
sha . update ( chunk )
total += len ( chunk )
except OSError as err :
raise HTTPException ( status_code = 500 , detail = { "error" : "local_file_read_failed" , "msg" : str ( err ), "path" : str ( path )})
return sha . hexdigest (), total
def supported_archive_kind ( filename : str ) -> str :
name = ( filename or "" ) . casefold ()
if name . endswith ( ".zip" ):
return "zip"
if name . endswith (( ".tar.gz" , ".tgz" , ".tar.bz2" , ".tbz2" )):
return "tar"
if name . endswith ( ".rar" ):
return "rar"
return ""
def ensure_inside_directory ( root : Path , relative_path : str ) -> Path :
target = ( root / relative_path ) . resolve ()
root_resolved = root . resolve ()
if target != root_resolved and root_resolved not in target . parents :
raise HTTPException ( status_code = 400 , detail = f "压缩包包含不安全路径: { relative_path } " )
return target
def check_extracted_publish_limits ( total_size : int , file_count : int ):
if PUBLISH_MAX_FILES > 0 and file_count > PUBLISH_MAX_FILES :
raise HTTPException ( status_code = 413 , detail = f "压缩包解压后文件数量过多: { file_count } ,当前上限为 { PUBLISH_MAX_FILES } " )
if PUBLISH_MAX_REQUEST_BYTES > 0 and total_size > PUBLISH_MAX_REQUEST_BYTES :
raise HTTPException ( status_code = 413 , detail = publish_space_detail (
"publish_extracted_too_large" ,
f "压缩包解压后总大小过大,当前上限为 { PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024 } MB" ,
total_size ,
PUBLISH_MAX_REQUEST_BYTES ,
))
def copy_member_stream_to_path ( source , target : Path , size : int | None , limits : dict ):
target . parent . mkdir ( parents = True , exist_ok = True )
total = 0
with target . open ( "wb" ) as output :
while True :
chunk = source . read ( 1024 * 1024 )
if not chunk :
break
output . write ( chunk )
total += len ( chunk )
limits [ "total_size" ] += len ( chunk )
check_extracted_publish_limits ( limits [ "total_size" ], limits [ "file_count" ])
if size is not None and size >= 0 and total != size :
raise HTTPException ( status_code = 400 , detail = f "压缩包文件大小异常: { target . name } " )
def extract_zip_archive ( archive_path : Path , extract_dir : Path ):
limits = { "total_size" : 0 , "file_count" : 0 }
try :
with zipfile . ZipFile ( archive_path ) as archive :
for info in archive . infolist ():
if info . is_dir ():
continue
rel_path = normalize_relative_path ( info . filename )
if should_skip_release_path ( rel_path ):
continue
target = ensure_inside_directory ( extract_dir , rel_path )
limits [ "file_count" ] += 1
check_extracted_publish_limits ( limits [ "total_size" ] + max ( info . file_size , 0 ), limits [ "file_count" ])
with archive . open ( info , "r" ) as source :
copy_member_stream_to_path ( source , target , info . file_size , limits )
except zipfile . BadZipFile :
raise HTTPException ( status_code = 400 , detail = "zip 发布包无效或已损坏" )
def extract_tar_archive ( archive_path : Path , extract_dir : Path ):
limits = { "total_size" : 0 , "file_count" : 0 }
try :
with tarfile . open ( archive_path , mode = "r:*" ) as archive :
for member in archive . getmembers ():
if not member . isfile ():
continue
rel_path = normalize_relative_path ( member . name )
if should_skip_release_path ( rel_path ):
continue
target = ensure_inside_directory ( extract_dir , rel_path )
limits [ "file_count" ] += 1
check_extracted_publish_limits ( limits [ "total_size" ] + max ( member . size , 0 ), limits [ "file_count" ])
source = archive . extractfile ( member )
if source is None :
raise HTTPException ( status_code = 400 , detail = f "无法读取压缩包文件: { member . name } " )
with source :
copy_member_stream_to_path ( source , target , member . size , limits )
except tarfile . TarError :
raise HTTPException ( status_code = 400 , detail = "tar 发布包无效或已损坏" )
def extract_rar_archive ( archive_path : Path , extract_dir : Path ):
commands = []
if shutil . which ( "bsdtar" ):
commands . append ([ "bsdtar" , "-xf" , str ( archive_path ), "-C" , str ( extract_dir )])
if shutil . which ( "unrar" ):
commands . append ([ "unrar" , "x" , "-o+" , "-idq" , str ( archive_path ), str ( extract_dir ) + os . sep ])
if shutil . which ( "7z" ):
commands . append ([ "7z" , "x" , "-y" , f "-o { extract_dir } " , str ( archive_path )])
if not commands :
raise HTTPException (
status_code = 400 ,
detail = "服务器未安装 rar 解压工具,无法解压 .rar。请安装 bsdtar/unrar/7z,或上传 zip/tar.gz/tar.bz2 发布包"
)
last_error = ""
for command in commands :
try :
result = subprocess . run ( command , stdout = subprocess . PIPE , stderr = subprocess . PIPE , text = True , timeout = 3600 )
except Exception as err :
last_error = str ( err )
continue
if result . returncode == 0 :
return
last_error = ( result . stderr or result . stdout or "" ) . strip ()
raise HTTPException ( status_code = 400 , detail = f "rar 发布包解压失败: { last_error or 'unknown error' } " )
def extract_release_archive ( archive_path : Path , filename : str , extract_dir : Path ):
kind = supported_archive_kind ( filename )
if kind == "zip" :
extract_zip_archive ( archive_path , extract_dir )
elif kind == "tar" :
extract_tar_archive ( archive_path , extract_dir )
elif kind == "rar" :
extract_rar_archive ( archive_path , extract_dir )
else :
raise HTTPException ( status_code = 400 , detail = "压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar" )
def release_items_from_extracted_dir ( extract_dir : Path ):
items = []
for path in extract_dir . rglob ( "*" ):
if path . is_symlink () or not path . is_file ():
continue
rel_path = normalize_relative_path ( path . relative_to ( extract_dir ) . as_posix ())
if should_skip_release_path ( rel_path ):
continue
items . append ({ "path" : rel_path , "local_path" : path })
return normalize_release_item_roots ( items )
def normalize_release_item_roots ( items : list [ dict ]):
if not items :
return items
required_main_key = RELEASE_MAIN_EXECUTABLE . casefold ()
lower_paths = [ item [ "path" ] . casefold () for item in items ]
if required_main_key in lower_paths :
return items
nested_main = next (( path for path in lower_paths if path . endswith ( "/" + required_main_key )), None )
if not nested_main :
return items
top_levels = set ()
for item in items :
parts = PurePosixPath ( item [ "path" ]) . parts
if not parts :
continue
top_levels . add ( parts [ 0 ])
if len ( top_levels ) != 1 :
return items
prefix = next ( iter ( top_levels )) + "/"
normalized = []
for item in items :
if not item [ "path" ] . startswith ( prefix ):
return items
stripped = item [ "path" ][ len ( prefix ):]
if stripped :
clone = dict ( item )
clone [ "path" ] = stripped
normalized . append ( clone )
return normalized
def validate_release_items ( items : list [ dict ]):
if not items :
raise HTTPException ( status_code = 400 , detail = "未选择任何文件,请选择程序文件夹或压缩发布包" )
if len ( items ) > PUBLISH_MAX_FILES :
raise HTTPException ( status_code = 413 , detail = f "文件数量过多: { len ( items ) } ,当前上限为 { PUBLISH_MAX_FILES } " )
seen_paths = set ()
filtered = []
for item in items :
rel_path = normalize_relative_path ( item [ "path" ])
if should_skip_release_path ( rel_path ):
continue
validate_release_path ( rel_path )
path_key = rel_path . casefold ()
if path_key in seen_paths :
raise HTTPException ( status_code = 400 , detail = f "存在重复文件路径: { rel_path } " )
seen_paths . add ( path_key )
clone = dict ( item )
clone [ "path" ] = rel_path
filtered . append ( clone )
if not filtered :
raise HTTPException ( status_code = 400 , detail = "发布内容为空;请确认发布目录或压缩包中包含程序文件" )
required_main_key = RELEASE_MAIN_EXECUTABLE . casefold ()
if required_main_key not in seen_paths :
nested_main = next (( path for path in seen_paths if path . endswith ( "/" + required_main_key )), None )
if nested_main :
raise HTTPException ( status_code = 400 , detail = f " { RELEASE_MAIN_EXECUTABLE } 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: { nested_main } " )
raise HTTPException ( status_code = 400 , detail = f "发布目录中缺少 { RELEASE_MAIN_EXECUTABLE } " )
nested_main = next (( path for path in seen_paths if path . endswith ( "/" + required_main_key )), None )
if nested_main :
raise HTTPException (
status_code = 400 ,
detail = f "发布目录中存在嵌套的重复主程序 { nested_main } ,请使用干净的 Release 输出目录"
)
return filtered
2026-07-01 03:32:41 +00:00
# 文件sha256工具
def calc_sha256 ( data : bytes ):
sha = hashlib . sha256 ()
sha . update ( data )
return sha . hexdigest ()
2026-07-07 09:48:01 +00:00
2026-07-09 09:00:51 +00:00
def calc_upload_file_sha256 ( upload , chunk_size : int = 1024 * 1024 ):
stream = getattr ( upload , "file" , None )
if stream is None :
raise HTTPException ( status_code = 400 , detail = "上传文件对象无效" )
sha = hashlib . sha256 ()
total = 0
try :
stream . seek ( 0 )
while True :
chunk = stream . read ( chunk_size )
if not chunk :
break
if isinstance ( chunk , str ):
chunk = chunk . encode ( "utf-8" )
sha . update ( chunk )
total += len ( chunk )
stream . seek ( 0 )
except OSError as err :
raise HTTPException ( status_code = 500 , detail = { "error" : "upload_file_read_failed" , "msg" : str ( err )})
return sha . hexdigest (), total
async def close_upload_safely ( upload ):
try :
close = getattr ( upload , "close" , None )
if close :
result = close ()
if hasattr ( result , "__await__" ):
await result
return
except Exception as err :
print ( f "close upload temp file failed: { err } " )
stream = getattr ( upload , "file" , None )
if stream is not None :
try :
stream . close ()
except Exception as err :
print ( f "close upload stream failed: { err } " )
def publish_space_detail ( error : str , msg : str , required_bytes : int , available_bytes : int ):
return {
"error" : error ,
"msg" : msg ,
"required_bytes" : required_bytes ,
"available_bytes" : available_bytes ,
"required_mb" : round ( required_bytes / 1024 / 1024 , 2 ),
"available_mb" : round ( available_bytes / 1024 / 1024 , 2 ),
}
def ensure_publish_storage_space ( next_file_size : int ):
required_bytes = max ( next_file_size , 0 ) + UPLOAD_SPACE_RESERVE
available_bytes = shutil . disk_usage ( MINIO_DATA_DIR ) . free
if available_bytes < required_bytes :
raise HTTPException ( status_code = 507 , detail = publish_space_detail (
"storage_space_insufficient" ,
"版本存储空间不足,已停止发布。请删除旧版本、清理磁盘或扩容后重试" ,
required_bytes ,
available_bytes ,
))
2026-07-07 09:48:01 +00:00
class CrashApiException ( Exception ):
def __init__ ( self , status_code : int , code : str , message : str , retryable : bool = False ):
self . status_code = status_code
self . code = code
self . message = message
self . retryable = retryable
@app.exception_handler ( CrashApiException )
async def crash_api_exception_handler ( request : Request , exc : CrashApiException ):
return JSONResponse (
status_code = exc . status_code ,
content = { "error" : { "code" : exc . code , "message" : exc . message , "retryable" : exc . retryable }},
)
def utc_now_text () -> str :
return datetime . now ( timezone . utc ) . replace ( microsecond = 0 ) . isoformat () . replace ( "+00:00" , "Z" )
def crash_api_error ( status_code : int , code : str , message : str , retryable : bool = False ):
raise CrashApiException ( status_code , code , message , retryable )
def bearer_token_from_request ( request : Request ) -> str :
value = request . headers . get ( "authorization" , "" )
if not value . lower () . startswith ( "bearer " ):
crash_api_error ( 401 , "unauthorized" , "missing bearer token" , False )
token = value [ 7 :] . strip ()
if not token :
crash_api_error ( 401 , "unauthorized" , "missing bearer token" , False )
return token
def require_crash_bearer ( request : Request , allowed_roles : set [ str ]) -> str :
token = bearer_token_from_request ( request )
roles : set [ str ] = set ()
if CRASH_REPORT_TOKEN and secrets . compare_digest ( token , CRASH_REPORT_TOKEN ):
roles . add ( "crash_report" )
if CRASH_SYMBOL_TOKEN and secrets . compare_digest ( token , CRASH_SYMBOL_TOKEN ):
roles . add ( "symbols" )
if CRASH_ADMIN_TOKEN :
if secrets . compare_digest ( token , CRASH_ADMIN_TOKEN ):
roles . add ( "admin" )
elif secrets . compare_digest ( token , ADMIN_TOKEN ):
roles . add ( "admin" )
if not roles :
crash_api_error ( 401 , "unauthorized" , "invalid bearer token" , False )
if roles . isdisjoint ( allowed_roles ):
crash_api_error ( 403 , "forbidden" , "token is not allowed to access this endpoint" , False )
return token
def ensure_multipart_request ( request : Request , max_bytes : int = CRASH_REQUEST_MAX_BYTES ):
content_type = request . headers . get ( "content-type" , "" )
if "multipart/form-data" not in content_type . lower ():
crash_api_error ( 415 , "unsupported_media_type" , "Content-Type must be multipart/form-data" , False )
raw_length = request . headers . get ( "content-length" )
if raw_length :
try :
content_length = int ( raw_length )
except ValueError :
crash_api_error ( 400 , "metadata_invalid" , "Content-Length is invalid" , False )
if content_length > max_bytes :
crash_api_error ( 413 , "payload_too_large" , "request body exceeds limit" , False )
def is_upload_file ( value ) -> bool :
return hasattr ( value , "filename" ) and hasattr ( value , "read" )
async def read_metadata_field ( value , max_bytes : int ) -> bytes :
if value is None :
crash_api_error ( 400 , "metadata_invalid" , "metadata is required" , False )
if is_upload_file ( value ):
data = bytearray ()
while True :
chunk = await value . read ( 64 * 1024 )
if not chunk :
break
data . extend ( chunk )
if len ( data ) > max_bytes :
crash_api_error ( 413 , "payload_too_large" , "metadata exceeds size limit" , False )
return bytes ( data )
data = str ( value ) . encode ( "utf-8" )
if len ( data ) > max_bytes :
crash_api_error ( 413 , "payload_too_large" , "metadata exceeds size limit" , False )
return data
async def save_upload_limited ( upload , target : Path , max_bytes : int , field_name : str ) -> dict :
if not is_upload_file ( upload ):
crash_api_error ( 400 , "metadata_invalid" , f " { field_name } file is required" , False )
target . parent . mkdir ( parents = True , exist_ok = True )
total = 0
digest = hashlib . sha256 ()
try :
with open ( target , "wb" ) as out_file :
while True :
chunk = await upload . read ( 1024 * 1024 )
if not chunk :
break
total += len ( chunk )
if total > max_bytes :
crash_api_error ( 413 , "payload_too_large" , f " { field_name } exceeds size limit" , False )
digest . update ( chunk )
out_file . write ( chunk )
except Exception :
if target . exists ():
target . unlink ()
raise
if total <= 0 :
crash_api_error ( 400 , "metadata_invalid" , f " { field_name } file is empty" , False )
return { "sha256" : digest . hexdigest (), "size" : total , "filename" : upload . filename or field_name }
def load_json_metadata ( raw : bytes , error_code : str = "metadata_invalid" ) -> dict :
try :
obj = json . loads ( raw . decode ( "utf-8" ))
except Exception :
crash_api_error ( 400 , error_code , "metadata must be valid UTF-8 JSON" , False )
if not isinstance ( obj , dict ):
crash_api_error ( 400 , error_code , "metadata must be a JSON object" , False )
return obj
def require_metadata_fields ( metadata : dict , fields : list [ str ], error_code : str = "metadata_invalid" ):
for field in fields :
if metadata . get ( field ) in ( None , "" ):
crash_api_error ( 400 , error_code , f "metadata. { field } is required" , False )
if metadata . get ( "schemaVersion" ) != 1 :
crash_api_error ( 400 , error_code , "metadata.schemaVersion must be 1" , False )
def normalize_declared_sha256 ( value , field_path : str ) -> str :
if value in ( None , "" ):
return ""
text = str ( value ) . strip () . lower ()
if len ( text ) != 64 or any ( ch not in "0123456789abcdef" for ch in text ):
crash_api_error ( 400 , "metadata_invalid" , f " { field_path } must be a SHA-256 hex string" , False )
return text
def declared_file_sha256 ( metadata : dict , kind : str , filename : str ) -> str :
files = metadata . get ( "files" )
if not isinstance ( files , list ):
crash_api_error ( 400 , "metadata_invalid" , "metadata.files must be an array" , False )
for index , item in enumerate ( files ):
if not isinstance ( item , dict ):
crash_api_error ( 400 , "metadata_invalid" , f "metadata.files[ { index } ] must be an object" , False )
if item . get ( "kind" ) == kind or item . get ( "name" ) == filename :
return normalize_declared_sha256 ( item . get ( "sha256" ), f "metadata.files[ { index } ].sha256" )
return ""
def validate_client_report_id ( value : str ):
try :
uuid . UUID ( value )
except Exception :
crash_api_error ( 400 , "metadata_invalid" , "metadata.clientReportId must be a UUID" , False )
def safe_storage_component ( value , fallback : str = "unknown" ) -> str :
text = str ( value or fallback ) . strip ()
cleaned = "" . join ( ch if ch . isalnum () or ch in "._-" else "_" for ch in text )
return ( cleaned [: 96 ] or fallback )
def crash_content_hash ( metadata_sha256 : str , minidump_sha256 : str , attachments_sha256 : str ) -> str :
payload = json . dumps (
{ "metadata" : metadata_sha256 , "minidump" : minidump_sha256 , "attachments" : attachments_sha256 or "" },
sort_keys = True ,
separators = ( "," , ":" ),
) . encode ( "utf-8" )
return hashlib . sha256 ( payload ) . hexdigest ()
def generate_prefixed_id ( conn , table : str , column : str , prefix : str ) -> str :
day = datetime . now ( timezone . utc ) . strftime ( "%Y%m %d " )
for _ in range ( 20 ):
value = f " { prefix } _ { day } _ { secrets . token_hex ( 4 ) } "
if not conn . execute ( f "SELECT 1 FROM { table } WHERE { column } =?" , ( value ,)) . fetchone ():
return value
crash_api_error ( 503 , "service_unavailable" , "could not allocate report id" , True )
def cleanup_path ( path : Path | None ):
if not path :
return
try :
if path . is_dir ():
shutil . rmtree ( path )
elif path . exists ():
path . unlink ()
except Exception as err :
print ( f "清理临时文件失败: { path } : { err } " )
def crash_report_response ( row , duplicate : bool , status_code : int ):
return JSONResponse (
status_code = status_code ,
content = {
"reportId" : row [ "report_id" ],
"duplicate" : duplicate ,
"status" : row [ "status" ],
"acceptedAtUtc" : row [ "received_at_utc" ],
},
)
def symbol_upload_response ( row , duplicate : bool , status_code : int ):
return JSONResponse (
status_code = status_code ,
content = {
"symbolUploadId" : row [ "symbol_upload_id" ],
"status" : row [ "status" ],
"duplicate" : duplicate ,
"acceptedAtUtc" : row [ "received_at_utc" ],
},
)
@app.get ( "/api/v1/health" )
def crash_health ():
return {
"status" : "ok" ,
"service" : "simcae-crash-server" ,
"version" : CRASH_SERVICE_VERSION ,
"timeUtc" : utc_now_text (),
}
@app.post ( "/api/v1/crash-reports" )
async def create_crash_report ( request : Request ):
tmp_dir : Path | None = None
final_dir : Path | None = None
stored = False
conn = None
try :
require_crash_bearer ( request , { "crash_report" })
ensure_multipart_request ( request )
try :
form = await request . form ()
except Exception :
crash_api_error ( 400 , "metadata_invalid" , "multipart form is invalid" , False )
metadata_raw = await read_metadata_field ( form . get ( "metadata" ), CRASH_METADATA_MAX_BYTES )
metadata_sha = hashlib . sha256 ( metadata_raw ) . hexdigest ()
metadata = load_json_metadata ( metadata_raw )
require_metadata_fields (
metadata ,
[ "schemaVersion" , "clientReportId" , "crashTimeUtc" , "product" , "appVersion" , "gitCommit" , "buildType" , "channel" , "platform" , "crash" , "userConsent" , "files" ],
)
if metadata . get ( "product" ) != "SIMCAE" :
crash_api_error ( 400 , "metadata_invalid" , "metadata.product must be SIMCAE" , False )
if not isinstance ( metadata . get ( "platform" ), dict ):
crash_api_error ( 400 , "metadata_invalid" , "metadata.platform must be an object" , False )
if not isinstance ( metadata . get ( "crash" ), dict ):
crash_api_error ( 400 , "metadata_invalid" , "metadata.crash must be an object" , False )
if not isinstance ( metadata . get ( "userConsent" ), dict ):
crash_api_error ( 400 , "metadata_invalid" , "metadata.userConsent must be an object" , False )
client_report_id = str ( metadata . get ( "clientReportId" ) or "" ) . strip ()
validate_client_report_id ( client_report_id )
idempotency_key = request . headers . get ( "idempotency-key" , "" ) . strip ()
if not idempotency_key :
crash_api_error ( 400 , "metadata_invalid" , "Idempotency-Key header is required" , False )
if idempotency_key != client_report_id :
crash_api_error ( 400 , "metadata_invalid" , "Idempotency-Key must equal metadata.clientReportId" , False )
tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f "crash_ { secrets . token_hex ( 12 ) } "
tmp_dir . mkdir ( parents = True , exist_ok = False )
( tmp_dir / "metadata.json" ) . write_bytes ( metadata_raw )
minidump_info = await save_upload_limited ( form . get ( "minidump" ), tmp_dir / "crash.dmp" , CRASH_MINIDUMP_MAX_BYTES , "minidump" )
declared_dump_sha = declared_file_sha256 ( metadata , "minidump" , "crash.dmp" )
if declared_dump_sha and declared_dump_sha != minidump_info [ "sha256" ]:
crash_api_error ( 400 , "file_hash_mismatch" , "minidump SHA-256 does not match metadata.files" , False )
attachments_info = None
if form . get ( "attachments" ) is not None :
attachments_info = await save_upload_limited ( form . get ( "attachments" ), tmp_dir / "attachments.zip" , CRASH_ATTACHMENTS_MAX_BYTES , "attachments" )
package_hash = crash_content_hash ( metadata_sha , minidump_info [ "sha256" ], attachments_info [ "sha256" ] if attachments_info else "" )
received_at = utc_now_text ()
content_length = int ( request . headers . get ( "content-length" ) or 0 )
remote_addr = request . client . host if request . client else ""
server_info = {
"storageVersion" : 1 ,
"clientReportId" : client_report_id ,
"receivedAtUtc" : received_at ,
"remoteAddress" : remote_addr ,
"contentLength" : content_length ,
}
conn = db . get_conn ()
existing = conn . execute ( "SELECT * FROM crash_reports WHERE client_report_id=?" , ( client_report_id ,)) . fetchone ()
if existing :
if existing [ "content_hash" ] != package_hash :
crash_api_error ( 409 , "idempotency_conflict" , "same clientReportId was uploaded with different content" , False )
return crash_report_response ( existing , True , 200 )
report_id = generate_prefixed_id ( conn , "crash_reports" , "report_id" , "srv" )
server_info [ "reportId" ] = report_id
( tmp_dir / "server.json" ) . write_text ( json . dumps ( server_info , ensure_ascii = False , indent = 2 ), encoding = "utf-8" )
final_dir = CRASH_STORAGE_ROOT / "reports" / report_id
final_dir . parent . mkdir ( parents = True , exist_ok = True )
tmp_dir . rename ( final_dir )
tmp_dir = None
crash_obj = metadata . get ( "crash" ) or {}
conn . execute (
"""
INSERT INTO crash_reports(
report_id,client_report_id,content_hash,status,symbolication_status,product,app_version,
git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,remote_address,
content_length,metadata_sha256,metadata_size,minidump_sha256,minidump_size,attachments_sha256,
attachments_size,storage_path,metadata_json,server_json
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""" ,
(
report_id ,
client_report_id ,
package_hash ,
"stored" ,
"not_started" ,
metadata . get ( "product" ),
metadata . get ( "appVersion" ),
metadata . get ( "gitCommit" ),
metadata . get ( "buildType" ),
metadata . get ( "channel" ),
str ( crash_obj . get ( "exceptionCode" ) or "" ),
metadata . get ( "crashTimeUtc" ),
received_at ,
remote_addr ,
content_length ,
metadata_sha ,
len ( metadata_raw ),
minidump_info [ "sha256" ],
minidump_info [ "size" ],
attachments_info [ "sha256" ] if attachments_info else "" ,
attachments_info [ "size" ] if attachments_info else 0 ,
str ( final_dir ),
metadata_raw . decode ( "utf-8" , errors = "replace" ),
json . dumps ( server_info , ensure_ascii = False , separators = ( "," , ":" )),
),
)
conn . commit ()
stored = True
row = conn . execute ( "SELECT * FROM crash_reports WHERE report_id=?" , ( report_id ,)) . fetchone ()
return crash_report_response ( row , False , 201 )
except sqlite3 . IntegrityError :
if conn :
conn . rollback ()
client_report_id = ""
try :
metadata = load_json_metadata ( metadata_raw )
client_report_id = str ( metadata . get ( "clientReportId" ) or "" ) . strip ()
except Exception :
pass
if client_report_id :
existing = conn . execute ( "SELECT * FROM crash_reports WHERE client_report_id=?" , ( client_report_id ,)) . fetchone ()
if existing and existing [ "content_hash" ] == package_hash :
return crash_report_response ( existing , True , 200 )
crash_api_error ( 409 , "idempotency_conflict" , "same clientReportId was uploaded with different content" , False )
except CrashApiException :
if conn :
conn . rollback ()
raise
except Exception as err :
if conn :
conn . rollback ()
print ( "崩溃报告上传失败:" , err )
crash_api_error ( 500 , "server_error" , "server failed to store crash report" , True )
finally :
if conn :
conn . close ()
cleanup_path ( tmp_dir )
if final_dir and not stored :
cleanup_path ( final_dir )
@app.get ( "/api/v1/crash-reports/ {report_id} " )
def get_crash_report ( report_id : str , request : Request ):
require_crash_bearer ( request , { "admin" })
conn = db . get_conn ()
row = conn . execute ( "SELECT * FROM crash_reports WHERE report_id=?" , ( report_id ,)) . fetchone ()
conn . close ()
if not row :
crash_api_error ( 404 , "metadata_invalid" , "crash report not found" , False )
return {
"reportId" : row [ "report_id" ],
"clientReportId" : row [ "client_report_id" ],
"status" : row [ "status" ],
"receivedAtUtc" : row [ "received_at_utc" ],
"symbolicationStatus" : row [ "symbolication_status" ],
"appVersion" : row [ "app_version" ],
"gitCommit" : row [ "git_commit" ],
"product" : row [ "product" ],
"buildType" : row [ "build_type" ],
"channel" : row [ "channel" ],
"exceptionCode" : row [ "exception_code" ],
"files" : {
"metadata" : { "name" : "metadata.json" , "sha256" : row [ "metadata_sha256" ], "size" : row [ "metadata_size" ]},
"minidump" : { "name" : "crash.dmp" , "sha256" : row [ "minidump_sha256" ], "size" : row [ "minidump_size" ]},
"attachments" : { "name" : "attachments.zip" , "sha256" : row [ "attachments_sha256" ], "size" : row [ "attachments_size" ]} if row [ "attachments_size" ] else None ,
"server" : { "name" : "server.json" },
},
}
@app.get ( "/api/v1/crash-reports/ {report_id} /files/ {file_name} " )
def download_crash_report_file ( report_id : str , file_name : str , request : Request ):
token = require_crash_bearer ( request , { "admin" })
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 :
crash_api_error ( 404 , "metadata_invalid" , "file not found" , False )
conn = db . get_conn ()
row = conn . execute ( "SELECT * FROM crash_reports WHERE report_id=?" , ( report_id ,)) . fetchone ()
if not row :
conn . close ()
crash_api_error ( 404 , "metadata_invalid" , "crash report not found" , False )
target = Path ( row [ "storage_path" ]) / stored_name
try :
conn . execute (
"INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)" ,
( report_id , stored_name , token_digest ( token )[: 16 ], request . client . host if request . client else "" , request . headers . get ( "user-agent" , "" )[: 300 ]),
)
conn . commit ()
finally :
conn . close ()
if not target . is_file ():
crash_api_error ( 404 , "metadata_invalid" , "file not found" , False )
return FileResponse ( target , media_type = "application/octet-stream" , filename = stored_name )
@app.post ( "/api/v1/symbols" )
async def upload_symbols ( request : Request ):
tmp_dir : Path | None = None
final_dir : Path | None = None
stored = False
conn = None
try :
require_crash_bearer ( request , { "symbols" })
ensure_multipart_request ( request , CRASH_SYMBOLS_MAX_BYTES + CRASH_METADATA_MAX_BYTES )
try :
form = await request . form ()
except Exception :
crash_api_error ( 400 , "metadata_invalid" , "multipart form is invalid" , False )
metadata_raw = await read_metadata_field ( form . get ( "metadata" ), CRASH_METADATA_MAX_BYTES )
metadata_sha = hashlib . sha256 ( metadata_raw ) . hexdigest ()
metadata = load_json_metadata ( metadata_raw )
require_metadata_fields (
metadata ,
[ "schemaVersion" , "product" , "appVersion" , "gitCommit" , "buildType" , "platform" , "toolchain" , "createdAtUtc" , "files" ],
)
if metadata . get ( "product" ) != "SIMCAE" :
crash_api_error ( 400 , "metadata_invalid" , "metadata.product must be SIMCAE" , False )
if not isinstance ( metadata . get ( "files" ), list ):
crash_api_error ( 400 , "metadata_invalid" , "metadata.files must be an array" , False )
tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f "symbols_ { secrets . token_hex ( 12 ) } "
tmp_dir . mkdir ( parents = True , exist_ok = False )
( tmp_dir / "metadata.json" ) . write_bytes ( metadata_raw )
symbols_info = await save_upload_limited ( form . get ( "symbols" ), tmp_dir / "symbols.zip" , CRASH_SYMBOLS_MAX_BYTES , "symbols" )
identity_payload = json . dumps (
{
"product" : metadata . get ( "product" ),
"appVersion" : metadata . get ( "appVersion" ),
"gitCommit" : metadata . get ( "gitCommit" ),
"buildType" : metadata . get ( "buildType" ),
"platform" : metadata . get ( "platform" ),
},
sort_keys = True ,
separators = ( "," , ":" ),
)
identity_hash = hashlib . sha256 ( identity_payload . encode ( "utf-8" )) . hexdigest ()
received_at = utc_now_text ()
conn = db . get_conn ()
existing = conn . execute ( "SELECT * FROM crash_symbol_uploads WHERE identity_hash=?" , ( identity_hash ,)) . fetchone ()
if existing :
if existing [ "symbols_sha256" ] != symbols_info [ "sha256" ]:
crash_api_error ( 409 , "idempotency_conflict" , "same build identity already has different symbols" , False )
return symbol_upload_response ( existing , True , 200 )
upload_id = generate_prefixed_id ( conn , "crash_symbol_uploads" , "symbol_upload_id" , "sym" )
final_dir = CRASH_STORAGE_ROOT / "symbols" / safe_storage_component ( metadata . get ( "product" )) / safe_storage_component ( metadata . get ( "appVersion" )) / safe_storage_component ( metadata . get ( "gitCommit" )) / safe_storage_component ( metadata . get ( "buildType" )) / safe_storage_component ( metadata . get ( "platform" ))
final_dir . parent . mkdir ( parents = True , exist_ok = True )
if final_dir . exists ():
shutil . rmtree ( final_dir )
server_info = {
"storageVersion" : 1 ,
"symbolUploadId" : upload_id ,
"receivedAtUtc" : received_at ,
"remoteAddress" : request . client . host if request . client else "" ,
"contentLength" : int ( request . headers . get ( "content-length" ) or 0 ),
}
( tmp_dir / "server.json" ) . write_text ( json . dumps ( server_info , ensure_ascii = False , indent = 2 ), encoding = "utf-8" )
tmp_dir . rename ( final_dir )
tmp_dir = None
conn . execute (
"""
INSERT INTO crash_symbol_uploads(
symbol_upload_id,identity_hash,status,product,app_version,git_commit,build_type,platform,toolchain,
created_at_utc,received_at_utc,metadata_sha256,metadata_size,symbols_sha256,symbols_size,storage_path,metadata_json
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""" ,
(
upload_id ,
identity_hash ,
"stored" ,
metadata . get ( "product" ),
metadata . get ( "appVersion" ),
metadata . get ( "gitCommit" ),
metadata . get ( "buildType" ),
metadata . get ( "platform" ),
metadata . get ( "toolchain" ),
metadata . get ( "createdAtUtc" ),
received_at ,
metadata_sha ,
len ( metadata_raw ),
symbols_info [ "sha256" ],
symbols_info [ "size" ],
str ( final_dir ),
metadata_raw . decode ( "utf-8" , errors = "replace" ),
),
)
conn . commit ()
stored = True
row = conn . execute ( "SELECT * FROM crash_symbol_uploads WHERE symbol_upload_id=?" , ( upload_id ,)) . fetchone ()
return symbol_upload_response ( row , False , 201 )
except sqlite3 . IntegrityError :
if conn :
conn . rollback ()
crash_api_error ( 409 , "idempotency_conflict" , "same build identity already exists" , False )
except CrashApiException :
if conn :
conn . rollback ()
raise
except Exception as err :
if conn :
conn . rollback ()
print ( "符号包上传失败:" , err )
crash_api_error ( 500 , "server_error" , "server failed to store symbols" , True )
finally :
if conn :
conn . close ()
cleanup_path ( tmp_dir )
if final_dir and not stored :
cleanup_path ( final_dir )
2026-07-01 03:32:41 +00:00
# ==================== 原有客户端接口 完整保留 ====================
2026-07-07 09:48:01 +00:00
# 0. 首次设备登记;仅此客户端接口允许没有设备凭证
@app.post ( "/api/v1/device/issue" )
async def issue_device ( request : Request , body : DeviceIssueReq = 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 = "设备登记参数无效" )
key_hash = token_digest ( body . license_key . strip ()); now = datetime . now ( timezone . utc )
conn = db . get_conn (); conn . execute ( "BEGIN IMMEDIATE" )
require_channel ( conn , app_id , channel )
lic = conn . execute ( "SELECT * FROM licenses WHERE license_key_hash=?" ,( key_hash ,)) . fetchone ()
try : expiry = datetime . fromisoformat (( lic [ "valid_until" ] if lic else "" ) . replace ( "Z" , "+00:00" ))
except ValueError : expiry = now - timedelta ( seconds = 1 )
if not lic or lic [ "status" ] != "active" or expiry <= now or lic [ "app_id" ] != app_id or lic [ "channel_code" ] != channel :
conn . rollback (); conn . close (); raise HTTPException ( status_code = 403 , detail = { "error" : "license_invalid" , "msg" : "授权密钥无效、已过期或不适用于当前应用/渠道" })
row = conn . execute ( "SELECT * FROM devices WHERE app_id=? AND installation_id=?" ,( app_id , installation_id )) . fetchone ()
if row and row [ "disabled" ]: reason = row [ "disabled_reason" ] or "设备已被禁用" ; conn . rollback (); conn . close (); raise HTTPException ( status_code = 403 , detail = { "error" : "device_disabled" , "msg" : reason })
if row and row [ "license_id" ] not in ( "" , lic [ "license_id" ]): conn . rollback (); conn . close (); raise HTTPException ( status_code = 409 , detail = "该安装实例已绑定其他授权" )
device_id = row [ "device_id" ] if row else "dev_" + secrets . token_hex ( 16 ); seq = int ( row [ "credential_seq" ]) if row else 1
bound = conn . execute ( "SELECT 1 FROM license_devices WHERE license_id=? AND device_id=?" ,( lic [ "license_id" ], device_id )) . fetchone ()
used = conn . execute ( "SELECT COUNT(*) FROM license_devices WHERE license_id=?" ,( lic [ "license_id" ],)) . fetchone ()[ 0 ]
if not bound and used >= int ( lic [ "max_devices" ]): conn . rollback (); conn . close (); raise HTTPException ( status_code = 403 , detail = { "error" : "license_device_limit" , "msg" : "授权设备数量已达到上限" })
ip = request . client . host if request . client else ""
if row : conn . execute ( "UPDATE devices SET machine_hash=?,license_id=?,channel=?,last_seen_at=datetime('now'),last_ip=? WHERE device_id=?" ,( body . machine_hash . strip () . lower (), lic [ "license_id" ], channel , ip , device_id ))
else : conn . execute ( "INSERT INTO devices(device_id,app_id,installation_id,machine_hash,credential_seq,last_ip,license_id,channel) VALUES(?,?,?,?,?,?,?,?)" ,( device_id , app_id , installation_id , body . machine_hash . strip () . lower (), seq , ip , lic [ "license_id" ], channel ))
conn . execute ( "INSERT OR IGNORE INTO license_devices(license_id,device_id) VALUES(?,?)" ,( lic [ "license_id" ], device_id )); conn . commit (); conn . close ()
identity = { "device_id" : device_id , "license_id" : lic [ "license_id" ], "app_id" : app_id , "channel" : channel , "installation_id" : installation_id , "credential_seq" : seq , "issued_at" : now . replace ( microsecond = 0 ) . isoformat () . replace ( "+00:00" , "Z" ), "valid_until" : lic [ "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 }
2026-07-01 03:32:41 +00:00
# 1. 查询更新接口
@app.post ( "/api/v1/update/check" )
2026-07-07 09:48:01 +00:00
async def check_update ( request : Request , body : CheckUpdateReq = 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 = "设备凭证与应用/渠道不匹配" )
2026-07-01 03:32:41 +00:00
print ( "收到客户端版本检测请求,参数:" , body . model_dump ())
app_id , cur_ver , channel = body . app_id , body . current_version , body . channel
conn = db . get_conn ()
2026-07-07 09:48:01 +00:00
require_channel ( conn , app_id , channel )
2026-07-01 03:32:41 +00:00
ver = conn . execute (
"SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1" , ( app_id , channel )
) . fetchone ()
policy_row = conn . execute (
"SELECT * FROM version_policies WHERE app_id=? AND channel=?" , ( app_id , channel )
) . fetchone ()
conn . close ()
settings = policy_row_to_dict ( policy_row )
latest_ver = ver [ "version" ] if ver else ""
version_id = int ( ver [ "id" ]) if ver else 0
2026-07-07 09:48:01 +00:00
target_protocol = int ( ver [ "client_protocol" ] or 1 ) if ver else 0
protocol_compatible = not ver or target_protocol >= body . client_protocol
2026-07-01 03:32:41 +00:00
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 )
2026-07-07 09:48:01 +00:00
rollback_allowed = rollback_candidate and bool ( settings [ "allow_rollback" ]) and protocol_compatible
2026-07-01 03:32:41 +00:00
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 } "
2026-07-07 09:48:01 +00:00
elif rollback_candidate and not protocol_compatible :
action , message = "rollback_denied" , f "目标版本 { latest_ver } 的客户端协议为 { target_protocol } ,低于当前协议 { body . client_protocol } ,禁止降级"
2026-07-01 03:32:41 +00:00
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 ,
2026-07-07 09:48:01 +00:00
"signature_alg" : "RSA-2048-SHA256" , "key_id" : SIGNING_KEY_ID
2026-07-01 03:32:41 +00:00
}
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" ],
2026-07-07 09:48:01 +00:00
"rollback" : rollback_allowed , "client_protocol" : body . client_protocol ,
"target_client_protocol" : target_protocol , "protocol_compatible" : protocol_compatible ,
2026-07-01 03:32:41 +00:00
"policy_seq" : settings [ "policy_seq" ], "policy_valid_until" : settings [ "valid_until" ],
"policy" : policy , "policy_text" : policy_text , "policy_signature" : policy_signature
}
# 2. 获取文件下载链接
@app.post ( "/api/v1/update/download-url" )
2026-07-07 09:48:01 +00:00
async def get_download_url ( request : Request , body : DownloadUrlReq = 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 = "设备凭证与应用/渠道不匹配" )
2026-07-01 03:32:41 +00:00
ver_id = body . version_id
conn = db . get_conn ()
2026-07-07 09:48:01 +00:00
version_row = conn . execute ( "SELECT app_id,channel,version FROM versions WHERE id=?" , ( ver_id ,)) . fetchone ()
if not version_row or version_row [ "app_id" ] != body . app_id or version_row [ "channel" ] != body . channel or version_row [ "version" ] != body . version :
conn . close (); raise HTTPException ( status_code = 404 , detail = "版本与应用/渠道不匹配" )
2026-07-01 03:32:41 +00:00
rows = conn . execute (
"SELECT path, sha256, size FROM version_files WHERE version_id=?" ,
( ver_id ,)
) . fetchall ()
2026-07-07 09:48:01 +00:00
identity = request . state . device_identity ; ip = request . client . host if request . client else "" ; ua = request . headers . get ( "user-agent" , "" )[: 300 ]
conn . executemany ( """INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent)
VALUES(?,?,?,?,?,?,?,?,?,?)""" , [( identity [ "device_id" ], identity [ "license_id" ], body . app_id , body . version , body . channel , r [ "path" ], r [ "size" ], "authorized" , ip , ua ) for r in rows ])
conn . commit (); conn . close ()
2026-07-01 03:32:41 +00:00
res = []
base_path = f " { body . app_id } / { body . channel } / { body . version } /files/"
for r in rows :
full_path = base_path + r [ "path" ]
url = minio_tool . get_url ( full_path )
res . append ({
"path" : r [ "path" ],
"url" : url ,
"sha256" : r [ "sha256" ],
"size" : r [ "size" ]
})
return { "files" : res }
2026-07-07 09:48:01 +00:00
@app.post ( "/api/v1/update/download-report" )
async def report_download ( request : Request , body : DownloadReportReq = 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 = "下载结果无效" )
conn = db . get_conn (); 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 ))
if values : conn . executemany ( "INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?,?)" , values )
conn . commit (); conn . close (); return { "code" : 0 , "logged" : len ( values )}
2026-07-01 03:32:41 +00:00
# 2b. 获取版本 manifest
@app.post ( "/api/v1/update/manifest" )
2026-07-07 09:48:01 +00:00
async def get_manifest ( request : Request , body : ManifestReq = 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 = "设备凭证与应用/渠道不匹配" )
2026-07-01 03:32:41 +00:00
ver_id = body . version_id
conn = db . get_conn ()
ver = conn . execute (
"SELECT app_id, channel, version, create_time FROM versions WHERE id=?" ,
( ver_id ,)
) . fetchone ()
if not ver :
conn . close ()
raise HTTPException ( status_code = 404 , detail = "Version not found" )
rows = conn . execute (
"SELECT path, sha256, size FROM version_files WHERE version_id=?" ,
( ver_id ,)
) . fetchall ()
conn . close ()
files = []
for r in rows :
files . append ({
"path" : r [ "path" ],
"sha256" : r [ "sha256" ],
"size" : r [ "size" ],
"executable" : is_executable_path ( r [ "path" ])
})
manifest = {
"app_id" : ver [ "app_id" ],
"version" : ver [ "version" ],
"channel" : ver [ "channel" ],
2026-07-07 09:48:01 +00:00
"platform" : TARGET_PLATFORM ,
"arch" : TARGET_ARCH ,
2026-07-01 03:32:41 +00:00
"manifest_seq" : int ( ver_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
}
# 3. 升级结果上报
@app.post ( "/api/v1/update/report" )
2026-07-07 09:48:01 +00:00
async def report ( request : Request , body : ReportReq = 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 = "上报身份与设备凭证不匹配" )
2026-07-01 03:32:41 +00:00
conn = db . get_conn ()
insert_sql = """
INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time)
VALUES (?,?,?,?,datetime('now'))
"""
conn . execute ( insert_sql , ( body . device_id , body . from_version , body . to_version , body . result ))
conn . commit ()
conn . close ()
return { "code" : 0 , "msg" : "上报成功" }
# ==================== 管理后台 Admin 全套接口(统一Header鉴权) ====================
@app.post ( "/admin/token/change" )
def admin_change_token ( body : ChangeAdminTokenReq , auth = Depends ( admin_auth )):
2026-07-07 09:48:01 +00:00
global ADMIN_TOKEN
2026-07-01 03:32:41 +00:00
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 个字符" )
2026-07-09 09:00:51 +00:00
try :
persist_env_value ( ENV_FILE_PATH , "ADMIN_TOKEN" , new_token )
except Exception as exc :
raise HTTPException ( status_code = 500 , detail = f "令牌已通过校验,但写入 .env 失败: { exc } " )
2026-07-07 09:48:01 +00:00
ADMIN_TOKEN = new_token
2026-07-09 09:00:51 +00:00
os . environ [ "ADMIN_TOKEN" ] = new_token
return { "code" : 0 , "msg" : f "管理员令牌已更新,并已写入 { ENV_FILE_PATH } " }
2026-07-01 03:32:41 +00:00
# 应用列表 GET
@app.get ( "/admin/app/list" )
def admin_get_app_list ( auth = Depends ( admin_auth )):
conn = db . get_conn ()
rows = conn . execute ( "SELECT app_id, app_name FROM apps" ) . fetchall ()
conn . close ()
return { "list" : [{ "app_id" : r [ "app_id" ], "app_name" : r [ "app_name" ]} for r in rows ]}
# 新增应用 POST json
@app.post ( "/admin/app/add" )
def admin_add_app ( body : dict , auth = Depends ( admin_auth )):
aid = body [ "app_id" ]
aname = body [ "app_name" ]
conn = db . get_conn ()
try :
conn . execute ( "INSERT INTO apps(app_id, app_name) VALUES (?, ?)" , ( aid , aname ))
2026-07-07 09:48:01 +00:00
conn . executemany ( "INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)" ,
[( aid , "stable" , "正式版" , 1 , 10 ),( aid , "preview" , "预览版" , 1 , 20 ),( aid , "dev" , "开发版" , 1 , 30 )])
2026-07-01 03:32:41 +00:00
conn . commit ()
except Exception as e :
return { "msg" : f "创建失败: { str ( e ) } " }
conn . close ()
return { "msg" : "应用创建成功" }
2026-07-07 09:48:01 +00:00
@app.get ( "/admin/channel/list" )
def admin_channel_list ( app_id : str , include_disabled : bool = True , auth = Depends ( admin_auth )):
conn = db . get_conn (); sql = "SELECT channel_code,display_name,enabled,sort_order FROM channels WHERE app_id=?" + ( "" if include_disabled else " AND enabled=1" ) + " ORDER BY sort_order,channel_code" ; rows = conn . execute ( sql ,( app_id ,)) . fetchall (); conn . close (); return { "list" :[{ "channel_code" : r [ "channel_code" ], "display_name" : r [ "display_name" ], "enabled" : bool ( r [ "enabled" ]), "sort_order" : r [ "sort_order" ]} for r in rows ]}
@app.post ( "/admin/channel/save" )
def admin_channel_save ( body : dict , auth = Depends ( admin_auth )):
app_id = str ( body . get ( "app_id" ) or "" ) . strip (); code = str ( body . get ( "channel_code" ) or "" ) . strip (); name = str ( body . get ( "display_name" ) or "" ) . strip (); enabled = bool ( body . get ( "enabled" , True )); order = int ( body . get ( "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 = "渠道参数无效;代码仅允许字母、数字、下划线和连字符" )
conn = db . get_conn ();
if not conn . execute ( "SELECT 1 FROM apps WHERE app_id=?" ,( app_id ,)) . fetchone (): conn . close (); raise HTTPException ( status_code = 404 , detail = "应用不存在" )
conn . execute ( "INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?) ON CONFLICT(app_id,channel_code) DO UPDATE SET display_name=excluded.display_name,enabled=excluded.enabled,sort_order=excluded.sort_order,updated_at=datetime('now')" ,( app_id , code , name , int ( enabled ), order )); conn . commit (); conn . close (); return { "msg" : "渠道已保存" }
2026-07-01 03:32:41 +00:00
# 查询和保存应用渠道策略
@app.get ( "/admin/policy" )
2026-07-07 09:48:01 +00:00
def admin_get_policy ( app_id : str , channel : str , auth = Depends ( admin_auth )):
2026-07-01 03:32:41 +00:00
conn = db . get_conn ()
row = conn . execute ( "SELECT * FROM version_policies WHERE app_id=? AND channel=?" , ( app_id , channel )) . fetchone ()
conn . close ()
result = policy_row_to_dict ( row )
result . update ({ "app_id" : app_id , "channel" : channel })
return result
@app.post ( "/admin/policy/save" )
def admin_save_policy ( body : dict , auth = Depends ( admin_auth )):
app_id = str ( body . get ( "app_id" ) or "" ) . strip ()
2026-07-07 09:48:01 +00:00
channel = str ( body . get ( "channel" ) or "" ) . strip ()
if not app_id or not validate_channel_code ( channel ):
2026-07-01 03:32:41 +00:00
raise HTTPException ( status_code = 400 , detail = "App ID 或渠道无效" )
disabled = body . get ( "disabled_versions" ) or []
if not isinstance ( disabled , list ) or any ( not isinstance ( v , str ) for v in disabled ):
raise HTTPException ( status_code = 400 , detail = "disabled_versions 必须是版本字符串数组" )
valid_until = str ( body . get ( "valid_until" ) or "" ) . strip ()
try :
datetime . fromisoformat ( valid_until . replace ( "Z" , "+00:00" ))
except ValueError :
raise HTTPException ( status_code = 400 , detail = "valid_until 必须是 ISO-8601 时间" )
conn = db . get_conn ()
2026-07-07 09:48:01 +00:00
require_channel ( conn , app_id , channel )
2026-07-01 03:32:41 +00:00
row = conn . execute ( "SELECT policy_seq FROM version_policies WHERE app_id=? AND channel=?" , ( app_id , channel )) . fetchone ()
next_seq = ( int ( row [ "policy_seq" ]) + 1 ) if row else 1
values = ( next_seq , int ( bool ( body . get ( "force_update" ))), int ( bool ( body . get ( "allow_rollback" ))),
int ( bool ( body . get ( "offline_allowed" ))), valid_until ,
str ( body . get ( "min_supported_version" ) or "" ) . strip (),
json . dumps ( disabled , ensure_ascii = False ), str ( body . get ( "message" ) or "" ) . strip (), app_id , channel )
conn . execute ( """
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
min_supported_version,disabled_versions,message,app_id,channel)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
message=excluded.message,updated_at=datetime('now')
""" , values )
conn . commit (); conn . close ()
return { "msg" : "策略已保存并递增 policy_seq" , "policy_seq" : next_seq }
# 发布新版本(文件上传)
2026-07-09 09:00:51 +00:00
async def admin_publish_version_locked ( request : Request ):
2026-07-01 03:32:41 +00:00
upload_items = []
2026-07-09 09:00:51 +00:00
archive_upload = None
archive_temp_dir = None
try :
# 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。
content_length_header = request . headers . get ( "content-length" )
try :
content_length = int ( content_length_header or 0 )
except ValueError :
raise HTTPException ( status_code = 400 , detail = "无效的 Content-Length" )
if content_length <= 0 :
raise HTTPException ( status_code = 411 , detail = "发布请求必须提供 Content-Length" )
if PUBLISH_MAX_REQUEST_BYTES > 0 and content_length > PUBLISH_MAX_REQUEST_BYTES :
raise HTTPException ( status_code = 413 , detail = publish_space_detail (
"publish_request_too_large" ,
f "发布请求过大,当前上限为 { PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024 } MB。请减小发布目录、清理无关文件,或调整 PUBLISH_MAX_REQUEST_MB" ,
content_length ,
PUBLISH_MAX_REQUEST_BYTES ,
))
spool_free = shutil . disk_usage ( UPLOAD_SPOOL_DIR ) . free
storage_free = shutil . disk_usage ( MINIO_DATA_DIR ) . free
same_storage_device = os . stat ( UPLOAD_SPOOL_DIR ) . st_dev == os . stat ( MINIO_DATA_DIR ) . st_dev
if same_storage_device :
required_bytes = content_length * 2 + UPLOAD_SPACE_RESERVE
if spool_free < required_bytes :
raise HTTPException ( status_code = 507 , detail = publish_space_detail (
"publish_space_insufficient" ,
"发布目录上传和版本存储位于同一磁盘,空间不足。请删除旧版本、扩容磁盘,或把 UPLOAD_SPOOL_DIR 放到其他磁盘" ,
required_bytes ,
spool_free ,
))
else :
if spool_free < content_length + 64 * 1024 * 1024 :
raise HTTPException ( status_code = 507 , detail = publish_space_detail (
"upload_temp_space_insufficient" ,
"上传临时空间不足,请减小发布包或扩容上传临时目录" ,
content_length + 64 * 1024 * 1024 ,
spool_free ,
))
if storage_free < content_length + UPLOAD_SPACE_RESERVE :
raise HTTPException ( status_code = 507 , detail = publish_space_detail (
"storage_space_insufficient" ,
"版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本" ,
content_length + UPLOAD_SPACE_RESERVE ,
storage_free ,
))
# 手动解析表单,增加兼容性并便于调试前端上传问题
try :
form = await request . form ( max_files = PUBLISH_MAX_FILES , max_fields = PUBLISH_MAX_FIELDS )
except OSError as err :
if getattr ( err , "errno" , None ) == 28 :
raise HTTPException ( status_code = 507 , detail = "上传临时空间已满,请扩容后重试" )
raise
# 尝试从 form 中获取字段
app_id = form . get ( "app_id" )
channel = form . get ( "channel" ) or ""
version = form . get ( "version" )
try :
client_protocol = int ( form . get ( "client_protocol" ) or 2 )
except ( TypeError , ValueError ):
raise HTTPException ( status_code = 400 , detail = "client_protocol 必须是正整数" )
if client_protocol < 1 :
raise HTTPException ( status_code = 400 , detail = "client_protocol 必须是正整数" )
# 收集 files(可能是多个同名字段)和 archive(单个压缩包)
files = []
archives = []
if hasattr ( form , "getlist" ):
files = form . getlist ( "files" )
archives = form . getlist ( "archive" )
else :
# 兼容性降级:遍历所有表单项
for k , v in form . multi_items ():
if k == "files" :
files . append ( v )
elif k == "archive" :
archives . append ( v )
files = [ item for item in files if hasattr ( item , "filename" ) and item . filename ]
archives = [ item for item in archives if hasattr ( item , "filename" ) and item . filename ]
# 如果关键信息缺失,返回详细调试信息,方便前端定位问题
if not app_id or not version :
headers = { k : v for k , v in request . headers . items ()}
detail = {
"error" : "missing form fields" ,
"have_app_id" : bool ( app_id ),
"have_version" : bool ( version ),
"content_type" : request . headers . get ( "content-type" ),
"headers" : headers ,
"form_keys" : list ( form . keys ()),
"app_id_value" : app_id ,
"version_value" : version ,
"app_id_len" : len ( app_id or "" ),
"version_len" : len ( version or "" )
}
raise HTTPException ( status_code = 422 , detail = detail )
if files and archives :
raise HTTPException ( status_code = 400 , detail = "请只选择一种发布方式:软件根目录或压缩发布包" )
if len ( archives ) > 1 :
raise HTTPException ( status_code = 400 , detail = "一次只能上传一个压缩发布包" )
if not files and not archives :
raise HTTPException ( status_code = 400 , detail = "未选择任何文件,请选择程序文件夹或压缩发布包" )
if archives :
archive_upload = archives [ 0 ]
archive_name = str ( archive_upload . filename or "" )
if not supported_archive_kind ( archive_name ):
raise HTTPException ( status_code = 400 , detail = "压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar" )
archive_temp_dir = Path ( tempfile . mkdtemp ( prefix = "publish_archive_" , dir = UPLOAD_SPOOL_DIR ))
archive_path = archive_temp_dir / ( "upload_" + secrets . token_hex ( 8 ))
try :
archive_upload . file . seek ( 0 )
with archive_path . open ( "wb" ) as target :
shutil . copyfileobj ( archive_upload . file , target , length = 1024 * 1024 )
except OSError as err :
raise HTTPException ( status_code = 500 , detail = { "error" : "archive_spool_failed" , "msg" : str ( err )})
extract_dir = archive_temp_dir / "extracted"
extract_dir . mkdir ( parents = True , exist_ok = True )
extract_release_archive ( archive_path , archive_name , extract_dir )
raw_items = release_items_from_extracted_dir ( extract_dir )
upload_items = validate_release_items ( raw_items )
extracted_total = sum ( item [ "local_path" ] . stat () . st_size for item in upload_items )
check_extracted_publish_limits ( extracted_total , len ( upload_items ))
else :
if len ( files ) > PUBLISH_MAX_FILES :
raise HTTPException ( status_code = 413 , detail = f "文件数量过多: { len ( files ) } ,当前上限为 { PUBLISH_MAX_FILES } " )
2026-07-01 03:32:41 +00:00
2026-07-09 09:00:51 +00:00
relative_paths = form . getlist ( "relative_paths" ) if hasattr ( form , "getlist" ) else []
if relative_paths and len ( relative_paths ) != len ( files ):
raise HTTPException ( status_code = 400 , detail = "文件数量与相对路径数量不一致" )
2026-07-01 03:32:41 +00:00
2026-07-09 09:00:51 +00:00
raw_items = []
for index , file in enumerate ( files ):
raw_path = relative_paths [ index ] if relative_paths else file . filename
raw_items . append ({ "upload" : file , "path" : str ( raw_path )})
upload_items = validate_release_items ( raw_items )
2026-07-01 03:32:41 +00:00
2026-07-09 09:00:51 +00:00
conn = db . get_conn ()
require_channel ( conn , str ( app_id ), str ( channel ))
publish_started = False
publish_prefix = f " { app_id } / { channel } / { version } /"
try :
cur = conn . cursor ()
exists = cur . execute (
"SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?" ,
( app_id , channel , version )
) . fetchone ()
if exists :
raise HTTPException ( status_code = 400 , detail = "该版本已存在,请更换版本号或先删除旧版本" )
cur . execute ( "UPDATE versions SET latest=0 WHERE app_id=? AND channel=?" , ( app_id , channel ))
2026-07-01 03:32:41 +00:00
cur . execute (
2026-07-09 09:00:51 +00:00
"INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)" ,
( app_id , channel , version , client_protocol )
2026-07-01 03:32:41 +00:00
)
2026-07-09 09:00:51 +00:00
new_vid = cur . lastrowid
publish_started = True
for item in upload_items :
rel_path = item [ "path" ]
if "upload" in item :
file = item [ "upload" ]
f_sha , f_size = calc_upload_file_sha256 ( file )
stream = file . file
else :
local_path = item [ "local_path" ]
f_sha , f_size = calc_local_file_sha256 ( local_path )
stream = local_path . open ( "rb" )
try :
ensure_publish_storage_space ( f_size )
obj_path = f " { app_id } / { channel } / { version } /files/ { rel_path } "
put_result = minio_tool . put_file_stream ( obj_path , stream , f_size )
if put_result . get ( 'storage' ) == 'local' :
print ( f "MinIO 不可用,已保存到本地回退: { put_result . get ( 'path' ) } " )
cur . execute (
"INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)" ,
( new_vid , rel_path , f_sha , f_size )
)
finally :
if "local_path" in item :
stream . close ()
conn . commit ()
except sqlite3 . IntegrityError as err :
conn . rollback ()
if publish_started :
minio_tool . remove_prefix ( publish_prefix )
if 'UNIQUE constraint failed: versions.app_id, versions.channel, versions.version' in str ( err ):
raise HTTPException ( status_code = 400 , detail = "该版本已存在,请更换版本号或先删除旧版本" )
raise HTTPException ( status_code = 500 , detail = str ( err ))
except sqlite3 . OperationalError as err :
conn . rollback ()
if publish_started :
minio_tool . remove_prefix ( publish_prefix )
raise HTTPException ( status_code = 500 , detail = { "error" : "database locked" , "msg" : str ( err )})
except HTTPException :
conn . rollback ()
if publish_started :
minio_tool . remove_prefix ( publish_prefix )
raise
except Exception as err :
conn . rollback ()
if publish_started :
minio_tool . remove_prefix ( publish_prefix )
if isinstance ( err , OSError ) and getattr ( err , "errno" , None ) == 28 :
raise HTTPException ( status_code = 507 , detail = "发布过程中存储空间耗尽,已清理未完成版本" )
raise HTTPException ( status_code = 500 , detail = { "error" : "publish_failed" , "msg" : str ( err )})
finally :
conn . close ()
2026-07-01 03:32:41 +00:00
2026-07-09 09:00:51 +00:00
return { "msg" : f "版本 { version } 发布完成,共上传 { len ( upload_items ) } 个文件" , "file_count" : len ( upload_items )}
2026-07-01 03:32:41 +00:00
finally :
2026-07-09 09:00:51 +00:00
for item in upload_items :
upload = item . get ( "upload" ) if isinstance ( item , dict ) else None
if upload is not None :
await close_upload_safely ( upload )
if archive_upload is not None :
await close_upload_safely ( archive_upload )
cleanup_path ( archive_temp_dir )
2026-07-01 03:32:41 +00:00
2026-07-09 09:00:51 +00:00
@app.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 admin_publish_version_locked ( request )
2026-07-01 03:32:41 +00:00
2026-07-07 09:48:01 +00:00
@app.post ( "/admin/version/offline-package" )
def admin_offline_package ( body : dict , auth = Depends ( admin_auth )):
version_id = int ( body . get ( "version_id" ) or 0 )
conn = db . get_conn (); ver = conn . execute ( "SELECT app_id,channel,version,create_time FROM versions WHERE id=?" ,( version_id ,)) . fetchone (); rows = conn . execute ( "SELECT path,sha256,size FROM version_files WHERE version_id=? ORDER BY id" ,( version_id ,)) . fetchall (); conn . close ()
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 )})
2026-07-01 03:32:41 +00:00
# 获取版本列表 GET
@app.get ( "/admin/version/list" )
def admin_get_version_list ( app_id : str , auth = Depends ( admin_auth )):
conn = db . get_conn ()
rows = conn . execute ( """
2026-07-07 09:48:01 +00:00
SELECT id, version, channel, latest, client_protocol, create_time
2026-07-01 03:32:41 +00:00
FROM versions WHERE app_id=? ORDER BY create_time DESC
""" , ( app_id ,)) . fetchall ()
conn . close ()
out = []
for r in rows :
out . append ({
"id" : r [ "id" ],
"version" : r [ "version" ],
"channel" : r [ "channel" ],
"latest" : bool ( r [ "latest" ]),
2026-07-07 09:48:01 +00:00
"client_protocol" : int ( r [ "client_protocol" ] or 1 ),
2026-07-01 03:32:41 +00:00
"create_time" : r [ "create_time" ]
})
return { "list" : out }
2026-07-07 09:48:01 +00:00
# 修正历史版本的客户端协议标记
@app.post ( "/admin/version/set-protocol" )
def admin_set_version_protocol ( body : dict , auth = Depends ( admin_auth )):
version_id = int ( body . get ( "version_id" ) or 0 )
client_protocol = int ( body . get ( "client_protocol" ) or 0 )
if version_id < 1 or client_protocol < 1 :
raise HTTPException ( status_code = 400 , detail = "版本 ID 和客户端协议必须是正整数" )
conn = db . get_conn ()
cur = conn . execute ( "UPDATE versions SET client_protocol=? WHERE id=?" , ( client_protocol , version_id ))
if cur . rowcount != 1 :
conn . close (); raise HTTPException ( status_code = 404 , detail = "版本不存在" )
conn . commit (); conn . close ()
return { "msg" : "客户端协议已更新" , "version_id" : version_id , "client_protocol" : client_protocol }
2026-07-01 03:32:41 +00:00
# 设置指定版本为渠道最新 POST json
@app.post ( "/admin/version/set-latest" )
def admin_set_latest ( body : dict , auth = Depends ( admin_auth )):
vid = body [ "version_id" ]
conn = db . get_conn ()
cur = conn . cursor ()
v_info = cur . execute ( "SELECT app_id, channel FROM versions WHERE id=?" , ( vid ,)) . fetchone ()
aid , ch = v_info [ "app_id" ], v_info [ "channel" ]
cur . execute ( "UPDATE versions SET latest=0 WHERE app_id=? AND channel=?" , ( aid , ch ))
cur . execute ( "UPDATE versions SET latest=1 WHERE id=?" , ( vid ,))
conn . commit ()
conn . close ()
return { "msg" : "已切换为渠道最新版本" }
# 删除版本 POST json
@app.post ( "/admin/version/delete" )
def admin_delete_version ( body : dict , auth = Depends ( admin_auth )):
vid = body [ "version_id" ]
conn = db . get_conn ()
cur = conn . cursor ()
v_info = cur . execute ( "SELECT app_id, channel, version, latest FROM versions WHERE id=?" , ( vid ,)) . fetchone ()
if not v_info :
conn . close ()
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" ])
# 删除minio下该版本全部文件
prefix = f " { aid } / { ch } / { ver } /files/"
minio_tool . remove_prefix ( prefix )
# 删除数据库记录
cur . execute ( "DELETE FROM version_files WHERE version_id=?" , ( vid ,))
cur . execute ( "DELETE FROM versions WHERE id=?" , ( vid ,))
promoted_version = None
if was_latest :
replacement = cur . execute (
"SELECT id, version FROM versions WHERE app_id=? AND channel=? ORDER BY create_time DESC, id DESC LIMIT 1" ,
( aid , ch )
) . fetchone ()
if replacement :
cur . execute ( "UPDATE versions SET latest=1 WHERE id=?" , ( replacement [ "id" ],))
promoted_version = replacement [ "version" ]
conn . commit ()
conn . close ()
return {
"msg" : "版本、云端文件、数据库记录全部删除完成" ,
"promoted_latest" : promoted_version
}
# 获取升级日志 GET
@app.get ( "/admin/report/list" )
def admin_get_report_log ( auth = Depends ( admin_auth )):
conn = db . get_conn ()
rows = conn . execute ( """
2026-07-09 09:00:51 +00:00
SELECT id,device_id,from_ver,to_ver,result,create_time
2026-07-01 03:32:41 +00:00
FROM upgrade_logs ORDER BY create_time DESC LIMIT 200
""" ) . fetchall ()
conn . close ()
res = []
for r in rows :
res . append ({
2026-07-09 09:00:51 +00:00
"id" : r [ "id" ],
2026-07-01 03:32:41 +00:00
"device_id" : r [ "device_id" ],
"app_id" : "" ,
"from_version" : r [ "from_ver" ],
"to_version" : r [ "to_ver" ],
"result" : r [ "result" ],
"create_time" : r [ "create_time" ]
})
return { "list" : res }
2026-07-07 09:48:01 +00:00
2026-07-09 09:00:51 +00:00
@app.post ( "/admin/report/delete" )
def admin_delete_report_log ( body : dict , auth = Depends ( admin_auth )):
log_id = int ( body . get ( "id" ) or 0 )
if log_id < 1 :
raise HTTPException ( status_code = 400 , detail = "日志 ID 无效" )
conn = db . get_conn ()
cur = conn . execute ( "DELETE FROM upgrade_logs WHERE id=?" , ( log_id ,))
conn . commit (); conn . close ()
if cur . rowcount != 1 :
raise HTTPException ( status_code = 404 , detail = "升级日志不存在" )
return { "msg" : "升级日志已删除" , "deleted" : cur . rowcount }
@app.post ( "/admin/report/clear" )
def admin_clear_report_logs ( auth = Depends ( admin_auth )):
conn = db . get_conn ()
cur = conn . execute ( "DELETE FROM upgrade_logs" )
conn . commit (); conn . close ()
return { "msg" : "升级日志已清空" , "deleted" : cur . rowcount }
2026-07-07 09:48:01 +00:00
@app.get ( "/admin/runtime-config" )
def admin_runtime_config ( auth = Depends ( admin_auth )):
return {
"release_main_executable" : RELEASE_MAIN_EXECUTABLE ,
"target_platform" : TARGET_PLATFORM ,
"target_arch" : TARGET_ARCH ,
2026-07-09 09:00:51 +00:00
"publish_max_request_bytes" : PUBLISH_MAX_REQUEST_BYTES ,
"publish_max_files" : PUBLISH_MAX_FILES ,
"publish_max_fields" : PUBLISH_MAX_FIELDS ,
2026-07-07 09:48:01 +00:00
}
@app.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 )); conn = db . get_conn (); rows = conn . execute ( "SELECT * FROM download_logs WHERE app_id=? ORDER BY id DESC LIMIT ?" ,( app_id , limit )) . fetchall () if app_id else conn . execute ( "SELECT * FROM download_logs ORDER BY id DESC LIMIT ?" ,( limit ,)) . fetchall (); conn . close (); return { "list" :[ dict ( r ) for r in rows ]}
2026-07-09 09:00:51 +00:00
@app.post ( "/admin/download-log/delete" )
def admin_download_log_delete ( body : dict , auth = Depends ( admin_auth )):
log_id = int ( body . get ( "id" ) or 0 )
if log_id < 1 :
raise HTTPException ( status_code = 400 , detail = "日志 ID 无效" )
conn = db . get_conn ()
cur = conn . execute ( "DELETE FROM download_logs WHERE id=?" , ( log_id ,))
conn . commit (); conn . close ()
if cur . rowcount != 1 :
raise HTTPException ( status_code = 404 , detail = "下载日志不存在" )
return { "msg" : "下载日志已删除" , "deleted" : cur . rowcount }
@app.post ( "/admin/download-log/clear" )
def admin_download_log_clear ( body : dict = Body ( default = {}), auth = Depends ( admin_auth )):
app_id = str (( body or {}) . get ( "app_id" ) or "" ) . strip ()
conn = db . get_conn ()
if app_id :
cur = conn . execute ( "DELETE FROM download_logs WHERE app_id=?" , ( app_id ,))
else :
cur = conn . execute ( "DELETE FROM download_logs" )
conn . commit (); conn . close ()
return { "msg" : "下载日志已清空" , "deleted" : cur . rowcount }
2026-07-07 09:48:01 +00:00
@app.get ( "/admin/audit-log/list" )
def admin_audit_log_list ( limit : int = 300 , auth = Depends ( admin_auth )):
conn = db . get_conn (); rows = conn . execute ( "SELECT * FROM admin_audit_logs ORDER BY id DESC LIMIT ?" ,( max ( 1 , min ( limit , 1000 )),)) . fetchall (); conn . close (); return { "list" :[ dict ( r ) for r in rows ]}
2026-07-09 09:00:51 +00:00
@app.post ( "/admin/audit-log/delete" )
def admin_audit_log_delete ( body : dict , auth = Depends ( admin_auth )):
log_id = int ( body . get ( "id" ) or 0 )
if log_id < 1 :
raise HTTPException ( status_code = 400 , detail = "日志 ID 无效" )
conn = db . get_conn ()
cur = conn . execute ( "DELETE FROM admin_audit_logs WHERE id=?" , ( log_id ,))
conn . commit (); conn . close ()
if cur . rowcount != 1 :
raise HTTPException ( status_code = 404 , detail = "审计日志不存在" )
return { "msg" : "审计日志已删除" , "deleted" : cur . rowcount }
@app.post ( "/admin/audit-log/clear" )
def admin_audit_log_clear ( auth = Depends ( admin_auth )):
conn = db . get_conn ()
cur = conn . execute ( "DELETE FROM admin_audit_logs" )
conn . commit (); conn . close ()
return { "msg" : "审计日志已清空" , "deleted" : cur . rowcount }
@app.get ( "/admin/crash-report/list" )
def admin_crash_report_list ( limit : int = 300 , auth = Depends ( admin_auth )):
limit = max ( 1 , min ( limit , 1000 ))
conn = db . get_conn ()
rows = conn . execute (
"""
SELECT
id,report_id,client_report_id,status,symbolication_status,product,app_version,
git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,
remote_address,content_length,metadata_size,minidump_size,attachments_size
FROM crash_reports
ORDER BY id DESC
LIMIT ?
""" ,
( limit ,),
) . fetchall ()
conn . close ()
return { "list" : [ dict ( row ) for row in rows ]}
@app.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 = "崩溃报告文件不存在" )
conn = db . get_conn ()
row = conn . execute ( "SELECT * FROM crash_reports WHERE report_id=?" , ( report_id ,)) . fetchone ()
if not row :
conn . close ()
raise HTTPException ( status_code = 404 , detail = "崩溃报告不存在" )
target = Path ( row [ "storage_path" ]) / stored_name
if not target . is_file ():
conn . close ()
raise HTTPException ( status_code = 404 , detail = "崩溃报告文件不存在" )
try :
conn . execute (
"INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)" ,
(
report_id ,
stored_name ,
token_digest ( X_Admin_Token )[: 16 ],
request . client . host if request . client else "" ,
request . headers . get ( "user-agent" , "" )[: 300 ],
),
)
conn . commit ()
finally :
conn . close ()
return FileResponse ( target , media_type = "application/octet-stream" , filename = stored_name )
2026-07-07 09:48:01 +00:00
@app.post ( "/admin/license/create" )
def admin_license_create ( body : dict , auth = Depends ( admin_auth )):
app_id = str ( body . get ( "app_id" ) or "" ) . strip (); channel = str ( body . get ( "channel" ) or "" ) . strip (); customer = str ( body . get ( "customer_name" ) or "" ) . strip (); max_devices = int ( body . get ( "max_devices" ) or 1 ); valid_until = str ( body . get ( "valid_until" ) or "" ) . 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 ); conn = db . get_conn (); require_channel ( conn , app_id , channel ); conn . execute ( "INSERT INTO licenses(license_id,license_key_hash,customer_name,app_id,channel_code,max_devices,valid_until,status) VALUES(?,?,?,?,?,?,?,'active')" ,( license_id , token_digest ( license_key ), customer , app_id , channel , max_devices , valid_until )); conn . commit (); conn . close (); return { "license_id" : license_id , "license_key" : license_key , "msg" : "授权已创建;密钥仅本次返回,请立即保存" }
@app.get ( "/admin/license/list" )
def admin_license_list ( app_id : str = "" , auth = Depends ( admin_auth )):
conn = db . get_conn (); rows = conn . execute ( "SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l WHERE app_id=? ORDER BY created_at DESC" ,( app_id ,)) . fetchall () if app_id else conn . execute ( "SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l ORDER BY created_at DESC" ) . fetchall (); conn . close (); return { "list" :[{ 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" ]} for r in rows ]}
@app.post ( "/admin/license/set-status" )
def admin_license_set_status ( body : dict , auth = Depends ( admin_auth )):
status = str ( body . get ( "status" ) or "" ); license_id = str ( body . get ( "license_id" ) or "" );
if status not in ( "active" , "disabled" ): raise HTTPException ( status_code = 400 , detail = "授权状态无效" )
conn = db . get_conn (); cur = conn . execute ( "UPDATE licenses SET status=?,updated_at=datetime('now') WHERE license_id=?" ,( status , license_id )); conn . commit (); conn . close ()
if cur . rowcount != 1 : raise HTTPException ( status_code = 404 , detail = "授权不存在" )
return { "msg" : "授权状态已更新" }
@app.get ( "/admin/device/list" )
def admin_device_list ( app_id : str = "" , auth = Depends ( admin_auth )):
conn = db . get_conn (); rows = conn . execute ( "SELECT * FROM devices WHERE app_id=? ORDER BY last_seen_at DESC" ,( app_id ,)) . fetchall () if app_id else conn . execute ( "SELECT * FROM devices ORDER BY last_seen_at DESC LIMIT 500" ) . fetchall (); conn . close ()
return { "list" :[{ "device_id" : r [ "device_id" ], "app_id" : r [ "app_id" ], "installation_id" : r [ "installation_id" ], "disabled" : bool ( r [ "disabled" ]), "disabled_reason" : r [ "disabled_reason" ], "credential_seq" : r [ "credential_seq" ], "first_seen_at" : r [ "first_seen_at" ], "last_seen_at" : r [ "last_seen_at" ], "last_ip" : r [ "last_ip" ]} for r in rows ]}
@app.post ( "/admin/device/set-disabled" )
def admin_device_set_disabled ( body : dict , auth = Depends ( admin_auth )):
device_id = str ( body . get ( "device_id" ) or "" ) . strip (); disabled = bool ( body . get ( "disabled" )); reason = str ( body . get ( "reason" ) or "" ) . strip (); conn = db . get_conn (); cur = conn . execute ( "UPDATE devices SET disabled=?,disabled_reason=? WHERE device_id=?" ,( int ( disabled ), reason if disabled else "" , device_id ))
if cur . rowcount != 1 : conn . close (); raise HTTPException ( status_code = 404 , detail = "设备不存在" )
conn . commit (); conn . close (); return { "msg" : "设备已禁用" if disabled else "设备已恢复" , "device_id" : device_id }
2026-07-01 03:32:41 +00:00
if __name__ == "__main__" :
import uvicorn
2026-07-07 09:48:01 +00:00
uvicorn . run ( app , host = SERVER_HOST , port = SERVER_PORT , workers = 1 )