commit 9a6ac1e4edc3f0b84e1ef9ef5004a537bbca19d9 Author: nikelaluo <2135665716@qq.com> Date: Thu Jul 9 09:25:04 2026 +0000 chore: initialize server repository diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c52f22 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** +!Dockerfile +!requirements.txt +!main.py +!db.py +!minio_tool.py +!tables.sql +!admin.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ef82b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +Desktop.ini +.vscode/ +.idea/ + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*.log +*~ + +# Private requirement / handover documents +*.doc +*.docx +*交付说明*.md + +# Python environments and caches +venv/ +.venv/ +__pycache__/ +**/__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Local configuration and secrets +.env +.env.* +!.env.example +!.env.docker.example +admin_token.sha256 +keys/*private*.pem +keys/*private*.key + +# Runtime databases, uploads, object storage and generated packages +*.db +*.db-journal +*.db-wal +*.db-shm +local_uploads/ +upload_spool/ +crash_storage/ +minio_data/ +runtime/ +dist/ +minio + +# Runtime files +*.pid + +# Docker/local generated files +.dockerignore.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2bccc45 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp +RUN apt-get update \ + && apt-get install -y --no-install-recommends libarchive-tools \ + && rm -rf /var/lib/apt/lists/* + +COPY server/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./ +COPY --chown=updateapp:updateapp server/admin.html ./admin.html + +RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ + && chown -R updateapp:updateapp /app /data \ + && chmod 644 /app/admin.html + +USER updateapp + +EXPOSE 8000 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" + +CMD ["python", "main.py"] diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..eb85fa5 --- /dev/null +++ b/admin.html @@ -0,0 +1,2428 @@ + + + + + + Marsco 发布控制台 + + +
+

软件发布控制台

+
+
+

管理员登录

+ +
令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验。
+
当前连接服务端:
+
+
+ + + + + + + + diff --git a/db.py b/db.py new file mode 100644 index 0000000..b408345 --- /dev/null +++ b/db.py @@ -0,0 +1,38 @@ +import sqlite3 +from pathlib import Path +import os +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent + + +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 + + +DB_FILE = configured_path("DB_FILE", "mini.db") +SQL_FILE = configured_path("SQL_FILE", "tables.sql") + +# 初始化数据库 +def init_db(): + conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + cur = conn.cursor() + sql = Path(SQL_FILE).read_text("utf-8") + cur.executescript(sql) + conn.commit() + conn.close() + print(f"数据库 {DB_FILE} 创建完成") + +# 获取连接 +def get_conn(): + conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + +if __name__ == "__main__": + init_db() diff --git a/docker-compose.image.yml b/docker-compose.image.yml new file mode 100644 index 0000000..5304d09 --- /dev/null +++ b/docker-compose.image.yml @@ -0,0 +1,75 @@ +services: + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - ./minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + minio: + condition: service_healthy + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" && + mc mb --ignore-existing "update-store/$${MINIO_BUCKET}" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} + + api: + image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0} + restart: unless-stopped + user: "${APP_UID:-1000}:${APP_GID:-1000}" + env_file: + - .env + environment: + SERVER_HOST: 0.0.0.0 + SERVER_PORT: 8000 + ENV_FILE_PATH: /app/.env + DB_FILE: /data/mini.db + SQL_FILE: /app/tables.sql + ADMIN_HTML_PATH: /app/admin.html + LOCAL_UPLOAD_ROOT: /data/uploads + UPLOAD_SPOOL_DIR: /data/upload_spool + MINIO_DATA_DIR: /minio_data + CRASH_STORAGE_ROOT: /data/crash_storage + MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required} + MINIO_AUTO_START: "false" + MINIO_SECURE: "false" + ports: + - "${SERVER_PORT:-8000}:8000" + volumes: + - ./.env:/app/.env + - ./runtime:/data + - ./minio_data:/minio_data:ro + - ./keys:/run/secrets/update-keys:ro + depends_on: + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] + interval: 15s + timeout: 3s + start_period: 10s + retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73f677a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +services: + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - ./minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + minio: + condition: service_healthy + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" && + mc mb --ignore-existing "update-store/$${MINIO_BUCKET}" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} + + api: + build: + context: .. + dockerfile: server/Dockerfile + restart: unless-stopped + user: "${APP_UID:-1000}:${APP_GID:-1000}" + env_file: + - .env + environment: + SERVER_HOST: 0.0.0.0 + SERVER_PORT: 8000 + ENV_FILE_PATH: /app/.env + DB_FILE: /data/mini.db + SQL_FILE: /app/tables.sql + ADMIN_HTML_PATH: /app/admin.html + LOCAL_UPLOAD_ROOT: /data/uploads + UPLOAD_SPOOL_DIR: /data/upload_spool + MINIO_DATA_DIR: /minio_data + CRASH_STORAGE_ROOT: /data/crash_storage + MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required} + MINIO_AUTO_START: "false" + MINIO_SECURE: "false" + ports: + - "${SERVER_PORT:-8000}:8000" + volumes: + - ./.env:/app/.env + - ./runtime:/data + - ./minio_data:/minio_data:ro + - ./keys:/run/secrets/update-keys:ro + depends_on: + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] + interval: 15s + timeout: 3s + start_period: 10s + retries: 3 diff --git a/keys/manifest_public_key.pem b/keys/manifest_public_key.pem new file mode 100644 index 0000000..781682f --- /dev/null +++ b/keys/manifest_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU +WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l +Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ +Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv +Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs +F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ +TwIDAQAB +-----END PUBLIC KEY----- diff --git a/main.py b/main.py new file mode 100644 index 0000000..d92882d --- /dev/null +++ b/main.py @@ -0,0 +1,2244 @@ +from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from pydantic import BaseModel +import os +import sqlite3 +from pathlib import Path, PurePosixPath +from dotenv import load_dotenv +from contextlib import asynccontextmanager +import asyncio +import db +import minio_tool +import hashlib +import secrets +import base64 +import json +import uuid +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 +import zipfile +import tarfile +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes + +# 加载环境变量 +BASE_DIR = Path(__file__).resolve().parent +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) + + +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")) +VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN") +TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows") +TARGET_ARCH = os.getenv("TARGET_ARCH", "x64") +RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/") +SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1") + +# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。 +UPLOAD_SPOOL_DIR = configured_path("UPLOAD_SPOOL_DIR", "upload_spool") +UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True) +tempfile.tempdir = str(UPLOAD_SPOOL_DIR) +MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data") +UPLOAD_SPACE_RESERVE = int(os.getenv("UPLOAD_SPACE_RESERVE_MB", "256")) * 1024 * 1024 +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() + +# 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") + +def token_digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +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 + +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") + +# ========== 生命周期初始化 ========== +@asynccontextmanager +async def lifespan(app: FastAPI): + print("===== 进程启动,开始初始化数据库 =====") + db_file = db.DB_FILE + sql_file_path = db.SQL_FILE + 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) + 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)]) + conn.commit() + print(f"✅ 成功执行tables.sql,创建数据表与测试数据") + else: + print(f"❌ 错误:当前目录找不到 tables.sql 文件!") + conn.close() + print("===== 数据库初始化完成 =====") + + 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) + + 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: + scheme = "https" if env_bool("MINIO_SECURE") else "http" + base_url = f"{scheme}://{endpoint.rstrip('/')}" + health_url = base_url + "/minio/health/live" + with urllib.request.urlopen(health_url, timeout=float(os.getenv("MINIO_HEALTH_TIMEOUT_SEC", "2"))) as resp: + return resp.status == 200 + except Exception: + return False + + +def download_minio_binary(target_path: Path) -> str | None: + url = os.getenv("MINIO_DOWNLOAD_URL", "https://dl.min.io/server/minio/release/linux-amd64/minio") + 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(): + if not env_bool("MINIO_AUTO_START", True): + return + 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')) + 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) + if not minio_cmd: + print("MinIO 二进制未找到,且未启用或未完成自动下载") + return + + data_dir = MINIO_DATA_DIR + data_dir.mkdir(parents=True, exist_ok=True) + minio_address = os.getenv("MINIO_LISTEN_ADDRESS", ":9000") + print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address {minio_address}") + try: + 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"))) + if is_minio_running(endpoint): + print("MinIO 已启动成功") + else: + print("MinIO 启动失败,仍然无法连接") + except Exception as err: + print(f"启动 MinIO 失败:{err}") + + +# Manifest signing key path +MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "keys/manifest_private_key.pem")) + +# 创建APP绑定生命周期 +app = FastAPI(title=os.getenv("SERVICE_TITLE", "软件自动升级服务"), lifespan=lifespan) + +ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "admin.html") + + +@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"} + +# 本地上传备份文件目录(MinIO 不通时会保存到这里) +app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static") + +# 跨域配置(给admin前端页面用) +cors_origins = [item.strip() for item in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if item.strip()] +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 管理后台写操作统一审计;不保存令牌明文和请求正文。 +@app.middleware("http") +async def admin_audit_middleware(request: Request, call_next): + should_audit = (request.url.path.startswith("/admin/") and request.method not in ("GET", "HEAD", "OPTIONS") + and not request.url.path.startswith("/admin/audit-log/")) + 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) + +# ===================== 全局客户端鉴权中间件 ===================== +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + # 后台接口、文档接口、静态回退链接全部跳过客户端token校验 + 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): + response = await call_next(request) + return response + + client_token = request.headers.get("X-Client-Token") + if client_token != VALID_CLIENT_TOKEN: + 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}) + response = await call_next(request) + return response + +# ========== 请求体模型 ========== +class CheckUpdateReq(BaseModel): + app_id: str + current_version: str + channel: str + client_protocol: int = 1 + +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): + app_id: str + device_id: str + from_version: str + to_version: str + result: str + +class ChangeAdminTokenReq(BaseModel): + new_token: str + +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 = "" + +# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头 +def admin_auth(X_Admin_Token: str = Header("")): + if not secrets.compare_digest(X_Admin_Token, ADMIN_TOKEN): + raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问") + return True + +@app.get("/admin/auth/check") +def admin_auth_check(auth=Depends(admin_auth)): + return {"code": 0, "msg": "管理员令牌有效"} + +# 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") + + +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 + +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 "" + } + + +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 + +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() + + +def validate_release_path(relative_path: str): + folded = relative_path.casefold() + parts = PurePosixPath(folded).parts + for part in parts[:-1]: + if should_skip_release_directory(part): + raise HTTPException( + status_code=400, + detail=f"发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: {relative_path}" + ) + + +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 + +# 文件sha256工具 +def calc_sha256(data: bytes): + sha = hashlib.sha256() + sha.update(data) + return sha.hexdigest() + + +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, + )) + + +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) + +# ==================== 原有客户端接口 完整保留 ==================== +# 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} + +# 1. 查询更新接口 +@app.post("/api/v1/update/check") +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="设备凭证与应用/渠道不匹配") + print("收到客户端版本检测请求,参数:", body.model_dump()) + app_id, cur_ver, channel = body.app_id, body.current_version, body.channel + conn = db.get_conn() + require_channel(conn, app_id, channel) + 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 + target_protocol = int(ver["client_protocol"] or 1) if ver else 0 + protocol_compatible = not ver or target_protocol >= body.client_protocol + upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver) + rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver) + rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible + need_update = upgrade_available or rollback_allowed + disabled = cur_ver in settings["disabled_versions"] + below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"]) + allow_run = not disabled and not below_min + force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min + if disabled: + action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用" + elif below_min: + action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}" + elif force_update: + action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用" + elif rollback_allowed: + action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}" + elif rollback_candidate and not protocol_compatible: + action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级" + elif rollback_candidate: + action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级" + elif upgrade_available: + action, message = "optional_update", settings["message"] or "发现可用新版本" + else: + action, message = "allow", settings["message"] or "当前版本允许使用" + + issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + policy = { + "app_id": app_id, "channel": channel, "current_version": cur_ver, + "policy_seq": settings["policy_seq"], "allow_run": allow_run, + "force_update": force_update, "allow_rollback": settings["allow_rollback"], + "rollback_targets": [], "offline_allowed": settings["offline_allowed"], + "issued_at": issued_at, "valid_until": settings["valid_until"], + "latest_version": latest_ver, "min_supported_version": settings["min_supported_version"], + "disabled_versions": settings["disabled_versions"], "message": message, + "signature_alg": "RSA-2048-SHA256", "key_id": SIGNING_KEY_ID + } + policy_text, policy_signature = sign_policy(policy) + policy["signature"] = policy_signature + return { + "allow_run": allow_run, "action": action, "message": message, + "need_update": need_update, "release_available": bool(ver), + "current_version": cur_ver, "latest_version": latest_ver, "version_id": version_id, + "force_update": force_update, "allow_rollback": settings["allow_rollback"], + "rollback": rollback_allowed, "client_protocol": body.client_protocol, + "target_client_protocol": target_protocol, "protocol_compatible": protocol_compatible, + "policy_seq": settings["policy_seq"], "policy_valid_until": settings["valid_until"], + "policy": policy, "policy_text": policy_text, "policy_signature": policy_signature + } + +# 2. 获取文件下载链接 +@app.post("/api/v1/update/download-url") +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="设备凭证与应用/渠道不匹配") + ver_id = body.version_id + conn = db.get_conn() + 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="版本与应用/渠道不匹配") + rows = conn.execute( + "SELECT path, sha256, size FROM version_files WHERE version_id=?", + (ver_id,) + ).fetchall() + 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() + 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} + +@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)} + +# 2b. 获取版本 manifest +@app.post("/api/v1/update/manifest") +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="设备凭证与应用/渠道不匹配") + 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"], + "platform": TARGET_PLATFORM, + "arch": TARGET_ARCH, + "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") +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="上报身份与设备凭证不匹配") + 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)): + global ADMIN_TOKEN + new_token = body.new_token.strip() + if len(new_token) < 8: + raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符") + if len(new_token) > 128: + raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符") + try: + persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}") + ADMIN_TOKEN = new_token + os.environ["ADMIN_TOKEN"] = new_token + return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"} + +# 应用列表 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)) + 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)]) + conn.commit() + except Exception as e: + return {"msg": f"创建失败:{str(e)}"} + conn.close() + return {"msg": "应用创建成功"} + +@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":"渠道已保存"} + +# 查询和保存应用渠道策略 +@app.get("/admin/policy") +def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)): + 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() + channel = str(body.get("channel") or "").strip() + if not app_id or not validate_channel_code(channel): + 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() + require_channel(conn, app_id, channel) + 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} + + +# 发布新版本(文件上传) +async def admin_publish_version_locked(request: Request): + upload_items = [] + 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}") + + 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="文件数量与相对路径数量不一致") + + 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) + + + 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)) + cur.execute( + "INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)", + (app_id, channel, version, client_protocol) + ) + 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() + + return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件", "file_count": len(upload_items)} + finally: + 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) + + +@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) + +@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)}) + +# 获取版本列表 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(""" + SELECT id, version, channel, latest, client_protocol, create_time + 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"]), + "client_protocol": int(r["client_protocol"] or 1), + "create_time": r["create_time"] + }) + return {"list": out} + +# 修正历史版本的客户端协议标记 +@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} + + +# 设置指定版本为渠道最新 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(""" + SELECT id,device_id,from_ver,to_ver,result,create_time + FROM upgrade_logs ORDER BY create_time DESC LIMIT 200 + """).fetchall() + conn.close() + res = [] + for r in rows: + res.append({ + "id": r["id"], + "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} + + +@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} + + +@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, + "publish_max_request_bytes": PUBLISH_MAX_REQUEST_BYTES, + "publish_max_files": PUBLISH_MAX_FILES, + "publish_max_fields": PUBLISH_MAX_FIELDS, + } + + +@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]} + + +@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} + + +@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]} + + +@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) + + +@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} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1) diff --git a/minio_tool.py b/minio_tool.py new file mode 100644 index 0000000..6201586 --- /dev/null +++ b/minio_tool.py @@ -0,0 +1,168 @@ +from minio import Minio +from datetime import timedelta +import os +from dotenv import load_dotenv +import io +import socket +import shutil +from pathlib import Path +import urllib3 +from urllib.parse import urlparse +from urllib3.util import Retry, Timeout + +load_dotenv() +BASE_DIR = Path(__file__).resolve().parent + + +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"} + +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY") +MINIO_BUCKET = os.getenv("MINIO_BUCKET") +SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60) +LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")).expanduser() +if not LOCAL_UPLOAD_ROOT.is_absolute(): + LOCAL_UPLOAD_ROOT = BASE_DIR / LOCAL_UPLOAD_ROOT +LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE") +MINIO_SECURE = env_bool("MINIO_SECURE") +MINIO_CONNECT_TIMEOUT_SEC = float(os.getenv("MINIO_CONNECT_TIMEOUT_SEC", "2")) +MINIO_READ_TIMEOUT_SEC = float(os.getenv("MINIO_READ_TIMEOUT_SEC", "5")) +MINIO_RETRY_TOTAL = int(os.getenv("MINIO_RETRY_TOTAL", "1")) +LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True) + +http_client = urllib3.PoolManager( + timeout=Timeout(connect=MINIO_CONNECT_TIMEOUT_SEC, read=MINIO_READ_TIMEOUT_SEC), + retries=Retry(total=MINIO_RETRY_TOTAL, connect=MINIO_RETRY_TOTAL, + read=MINIO_RETRY_TOTAL, status=MINIO_RETRY_TOTAL, backoff_factor=0.2), +) + +mc = Minio( + MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=MINIO_SECURE, + http_client=http_client, +) + +public_endpoint_raw = os.getenv("MINIO_PUBLIC_ENDPOINT", "").strip() +if public_endpoint_raw: + parsed_public_endpoint = urlparse( + public_endpoint_raw if "://" in public_endpoint_raw else f"http://{public_endpoint_raw}" + ) + public_mc = Minio( + parsed_public_endpoint.netloc, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=parsed_public_endpoint.scheme == "https", + ) +else: + public_mc = mc + + +def get_url(file_path: str) -> str: + bucket = MINIO_BUCKET + full_object_path = file_path + try: + mc.stat_object(bucket, full_object_path) + url = public_mc.presigned_get_object( + bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN) + ) + return url + except Exception as err: + print(f"generate url error: {err}") + local_path = LOCAL_UPLOAD_ROOT / file_path + if local_path.exists(): + if LOCAL_FILE_URL_BASE: + return LOCAL_FILE_URL_BASE.rstrip('/') + '/' + file_path + host = os.getenv('SERVER_HOST') or '127.0.0.1' + if host == '0.0.0.0': + try: + host = socket.gethostbyname(socket.gethostname()) + except Exception: + host = '127.0.0.1' + port = os.getenv('SERVER_PORT') or '8000' + return f"http://{host}:{port}/static/{file_path}" + scheme = "https" if MINIO_SECURE else "http" + return f"{scheme}://{MINIO_ENDPOINT}/{bucket}/{full_object_path}" + + +def put_file(object_path: str, data: bytes, size: int): + bucket = MINIO_BUCKET + try: + bio = io.BytesIO(data) + mc.put_object(bucket, object_path, bio, length=size) + return {'storage': 'minio', 'path': object_path} + except Exception as e: + print(f"minio put_file error: {e}") + local_path = LOCAL_UPLOAD_ROOT / object_path + local_path.parent.mkdir(parents=True, exist_ok=True) + with open(local_path, 'wb') as f: + f.write(data) + print(f"local fallback stored {local_path}") + return {'storage': 'local', 'path': str(local_path)} + + + +def put_file_stream(object_path: str, source, size: int): + bucket = MINIO_BUCKET + try: + source.seek(0) + mc.put_object(bucket, object_path, source, length=size) + return {'storage': 'minio', 'path': object_path} + except Exception as e: + print(f"minio put_file_stream error: {e}") + local_path = LOCAL_UPLOAD_ROOT / object_path + local_path.parent.mkdir(parents=True, exist_ok=True) + source.seek(0) + with open(local_path, 'wb') as target: + shutil.copyfileobj(source, target, length=1024 * 1024) + print(f"local fallback stored {local_path}") + return {'storage': 'local', 'path': str(local_path)} + + +def remove_prefix(prefix: str): + bucket = MINIO_BUCKET + try: + objects = mc.list_objects(bucket, prefix=prefix, recursive=True) + for obj in objects: + try: + mc.remove_object(bucket, obj.object_name) + except Exception as e: + print(f"failed remove object {obj.object_name}: {e}") + except Exception as e: + print(f"minio remove_prefix error: {e}") + local_prefix = LOCAL_UPLOAD_ROOT / prefix + if local_prefix.exists(): + try: + shutil.rmtree(local_prefix) + except Exception as e: + print(f"local remove_prefix error: {e}") + +def iter_file(object_path: str, chunk_size: int = 1024 * 1024): + """Stream an object without creating a second full local copy.""" + response = None + emitted = 0 + try: + response = mc.get_object(MINIO_BUCKET, object_path) + while True: + chunk = response.read(chunk_size) + if not chunk: break + emitted += len(chunk) + yield chunk + return + except Exception as err: + if emitted: + raise RuntimeError(f"MinIO stream interrupted after {emitted} bytes: {object_path}") from err + print(f"MinIO stream fallback for {object_path}: {err}") + finally: + if response is not None: + response.close(); response.release_conn() + local_path = LOCAL_UPLOAD_ROOT / object_path + with open(local_path, "rb") as source: + while True: + chunk = source.read(chunk_size) + if not chunk: break + yield chunk diff --git a/package-offline-server.sh b/package-offline-server.sh new file mode 100755 index 0000000..73e7ee9 --- /dev/null +++ b/package-offline-server.sh @@ -0,0 +1,511 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="0.1.0" +OUTPUT_DIR="" +SKIP_BUILD=0 +SKIP_PULL=0 + +usage() { + cat <<'EOF' +Usage: ./package-offline-server.sh [options] + +Options: + --version VERSION Image/package version, default: 0.1.0 + --output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage + --skip-build Do not build simcae-update-server image + --skip-pull Do not pull MinIO images + -h, --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:?--version requires a value}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:?--output-dir requires a value}" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --skip-pull) + SKIP_PULL=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_file() { + local path="$1" + local message="$2" + if [[ ! -e "$path" ]]; then + echo "$message" >&2 + exit 1 + fi +} + +require_cmd() { + local name="$1" + if ! command -v "$name" >/dev/null 2>&1; then + echo "Required command is missing: $name" >&2 + exit 1 + fi +} + +require_cmd docker +require_cmd tar + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage" +fi + +IMAGE_NAME="simcae-update-server:$VERSION" +MINIO_IMAGE="minio/minio:RELEASE.2025-04-22T22-12-26Z" +MC_IMAGE="minio/mc:RELEASE.2025-04-16T18-13-26Z" +PACKAGE_DIR="$OUTPUT_DIR" +IMAGES_DIR="$PACKAGE_DIR/images" +KEYS_DIR="$PACKAGE_DIR/keys" +TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar" +ARCHIVE_FILE="$PACKAGE_DIR.tar.gz" + +require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing." +require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing." +require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing." +require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing." +require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing." + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT" +fi + +if [[ "$SKIP_PULL" -eq 0 ]]; then + docker pull "$MINIO_IMAGE" + docker pull "$MC_IMAGE" +fi + +rm -rf "$PACKAGE_DIR" +mkdir -p "$IMAGES_DIR" "$KEYS_DIR" + +cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml" +cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example" +cp "$SCRIPT_DIR/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem" +cp "$SCRIPT_DIR/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem" + +cat > "$PACKAGE_DIR/README.md" <<'EOF_README' +# SimCAE 服务端 Docker 部署包说明 + +这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。 + +如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。 + +## 一、这个包里面有什么 + +解压后目录结构如下: + +```text +SimCAEServerDockerPackage/ + README.md 当前说明文档 + docker-compose.yml 容器编排文件 + .env.example 环境变量模板 + load-images.sh 导入 Docker 镜像并创建 .env 的脚本 + images/ + simcae-server-all-images___VERSION__.tar Docker 镜像包,包含 api、minio、minio-init + keys/ + manifest_private_key.pem 服务端 Manifest 签名私钥 + manifest_public_key.pem 与客户端配套的验签公钥 +``` + +这些文件的关系可以这样理解: + +```text +images/*.tar 程序镜像包,相当于安装材料 +docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载 +.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成 +keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签 +runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等 +minio_data/ 启动后自动生成,保存升级包文件 +``` + +## 二、最小部署流程 + +先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行: + +```bash +tar -xzf SimCAEServerDockerPackage.tar.gz +cd SimCAEServerDockerPackage +pwd +ls +``` + +你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到: + +```text +docker-compose.yml +.env.example +load-images.sh +images/ +keys/ +``` + +后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。 + +继续在 `SimCAEServerDockerPackage` 目录执行: + +```bash +bash ./load-images.sh +``` + +这个脚本会做两件事: + +```text +1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像 +2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env +3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限 +``` + +然后修改 `.env`: + +```bash +nano .env +``` + +最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +例如服务器 IP 是 `192.168.229.128`: + +```text +MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000 +``` + +启动服务: + +```bash +docker compose up -d +``` + +查看状态和后端日志: + +```bash +docker compose ps +docker compose logs -f api +``` + +浏览器访问: + +```text +后台/API: http://你的服务器IP:8000/ +MinIO 控制台: http://你的服务器IP:9001/ +``` + +如果服务器开启防火墙,至少放行: + +```text +8000 后台/API +9000 客户端下载升级文件 +9001 MinIO 控制台,可选 +``` + +## 三、.env 配置怎么填 + +`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。 + +### 必须确认或修改 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | +| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | +| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 | +| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | +| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | +| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | +| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | + +### 通常不用改 + +| 字段 | 作用 | 默认值说明 | +| --- | --- | --- | +| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:__VERSION__`。 | +| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | +| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | +| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | +| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 | +| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 | +| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | +| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | +| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 | +| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 | +| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 | + +### 发布保护参数 + +这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。 + +| 字段 | 作用 | 默认值 | +| --- | --- | --- | +| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` | +| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` | +| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` | +| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` | + +如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。 + +### 崩溃报告接口参数 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 | +| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 | +| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 | +| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 | +| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 | +| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 | +| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 | +| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 | +| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 | + +## 四、客户端配置要同步哪些值 + +客户端 `config/app_config.json` 至少要和服务端保持这两个值一致: + +```json +{ + "api_base_url": "http://你的服务器IP:8000", + "client_token": "和服务端 CLIENT_API_TOKEN 一样" +} +``` + +如果服务端 `.env` 里保持默认: + +```text +CLIENT_API_TOKEN=SimCAEClientToken2026 +``` + +客户端就填: + +```json +"client_token": "SimCAEClientToken2026" +``` + +`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。 + +## 五、发布新版本 + +进入后台后,“发布新版本”支持两种方式: + +```text +1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe +2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar +``` + +压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: + +```text +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe +``` + +`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。 + +## 六、常用命令 + +确认你仍然在 `SimCAEServerDockerPackage` 目录: + +```bash +pwd +``` + +启动: + +```bash +docker compose up -d +``` + +查看状态: + +```bash +docker compose ps +``` + +查看后端日志: + +```bash +docker compose logs -f api +``` + +查看所有容器日志: + +```bash +docker compose logs -f +``` + +重启后端: + +```bash +docker compose restart api +``` + +停止服务: + +```bash +docker compose down +``` + +如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。 + +## 七、数据、备份和迁移 + +运行后会生成这些目录或文件: + +```text +.env 当前服务器实际配置 +runtime/ 服务端数据库、上传临时目录、崩溃报告等 +minio_data/ MinIO 对象数据,也就是上传后的升级文件 +keys/ Manifest 签名密钥 +``` + +备份或迁移正式环境时,重点备份: + +```text +.env +runtime/ +minio_data/ +keys/ +docker-compose.yml +``` + +不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。 + +## 八、常见问题 + +### 启动后看不到后端日志 + +`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志: + +```bash +docker compose logs -f api +``` + +### API 容器反复重启,提示 Permission denied: '/data/uploads' + +这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行: + +```bash +docker compose down +mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage +sudo chown -R $(id -u):$(id -g) runtime +docker compose up -d +``` + +### 网页能打开,但客户端提示令牌校验失败 + +检查客户端 `config/app_config.json`: + +```json +"client_token": "..." +``` + +必须和服务端 `.env`: + +```text +CLIENT_API_TOKEN=... +``` + +完全一致。 + +### 客户端能连 API,但下载升级文件失败 + +检查 `.env`: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。 + +## 九、安全提醒 + +`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 + +默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 +EOF_README + +python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME' +from pathlib import Path +import sys +path = Path(sys.argv[1]) +version = sys.argv[2] +text = path.read_text(encoding='utf-8').replace('__VERSION__', version) +path.write_text(text, encoding='utf-8') +PYREADME + +cat > "$PACKAGE_DIR/load-images.sh" </dev/null || true +else + echo "Warning: ./runtime is not writable by the current user." + echo "Run: sudo chown -R \$APP_UID_VALUE:\$APP_GID_VALUE ./runtime" +fi + +echo "Next: edit .env, then run: docker compose up -d" +EOF +chmod +x "$PACKAGE_DIR/load-images.sh" + +python3 - "$PACKAGE_DIR/.env.example" "$IMAGE_NAME" <<'PYENV' +from pathlib import Path +import sys +path = Path(sys.argv[1]) +image = sys.argv[2] +text = path.read_text(encoding='utf-8') +text = text.replace('SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0', f'SIMCAE_UPDATE_SERVER_IMAGE={image}') +path.write_text(text, encoding='utf-8') +PYENV + +docker save -o "$TAR_FILE" "$IMAGE_NAME" "$MINIO_IMAGE" "$MC_IMAGE" + +rm -f "$ARCHIVE_FILE" +tar -czf "$ARCHIVE_FILE" -C "$(dirname "$PACKAGE_DIR")" "$(basename "$PACKAGE_DIR")" + +echo "Offline package directory: $PACKAGE_DIR" +echo "Offline package archive: $ARCHIVE_FILE" +echo "Images tar: $TAR_FILE" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1025912 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +cryptography==49.0.0 +fastapi==0.138.0 +minio==7.2.20 +python-dotenv==1.2.2 +python-multipart==0.0.32 +uvicorn==0.49.0 diff --git a/tables.sql b/tables.sql new file mode 100644 index 0000000..3d2d80c --- /dev/null +++ b/tables.sql @@ -0,0 +1,187 @@ +-- 1.软件应用表:管理所有接入自动更新系统的软件 +CREATE TABLE IF NOT EXISTS apps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键ID + app_id TEXT NOT NULL UNIQUE, -- 软件唯一标识(客户端用来区分不同软件) + app_name TEXT NOT NULL -- 软件展示名称 +); + +-- 2.软件版本表:存储每个软件各个渠道下的所有版本号 +CREATE TABLE IF NOT EXISTS versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 版本自增主键version_id,关联文件表 + app_id TEXT NOT NULL, -- 关联apps表的软件唯一标识 + channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版 + version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2 + latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版 + client_protocol INTEGER NOT NULL DEFAULT 1, -- 该版本客户端支持的更新/策略协议版本 + create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间 + UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本 +); + +-- 3.版本关联文件表:记录每个版本需要更新的全部文件信息(exe、dll、资源文件) +CREATE TABLE IF NOT EXISTS version_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 文件记录自增ID + version_id INTEGER NOT NULL, -- 关联versions表的版本主键id + path TEXT NOT NULL, -- 文件在MinIO存储桶内的完整路径 + sha256 TEXT NOT NULL, -- 文件哈希值,客户端下载后做完整性/防篡改校验 + size INTEGER -- 文件字节大小,用于计算下载进度、校验磁盘空间 +); + +-- 4.升级日志记录表:存储所有客户端上报的升级结果,用于后台统计排查问题 +CREATE TABLE IF NOT EXISTS upgrade_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 日志自增主键 + device_id TEXT, -- 用户设备唯一标识,区分不同电脑客户端 + from_ver TEXT, -- 升级前本地旧版本号 + to_ver TEXT, -- 升级目标新版本号 + result TEXT, -- 升级结果:success成功 / fail失败 + create_time TEXT -- 升级上报时间 +); + +--5.日志数据表 +CREATE TABLE IF NOT EXISTS update_report ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + app_id TEXT NOT NULL, + from_version TEXT NOT NULL, + to_version TEXT NOT NULL, + result TEXT NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 6. 每个应用/渠道的运行与升级策略;每次保存必须递增 policy_seq +CREATE TABLE IF NOT EXISTS version_policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_id TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'stable', + policy_seq INTEGER NOT NULL DEFAULT 1, + force_update INTEGER NOT NULL DEFAULT 0, + allow_rollback INTEGER NOT NULL DEFAULT 0, + offline_allowed INTEGER NOT NULL DEFAULT 1, + valid_until TEXT NOT NULL, + min_supported_version TEXT NOT NULL DEFAULT '', + disabled_versions TEXT NOT NULL DEFAULT '[]', + message TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(app_id, channel) +); + + +-- 7. RSA 签发的客户端设备身份 +CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL UNIQUE, app_id TEXT NOT NULL, + installation_id TEXT NOT NULL, machine_hash TEXT NOT NULL DEFAULT '', credential_seq INTEGER NOT NULL DEFAULT 1, + disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '', + first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), last_ip TEXT NOT NULL DEFAULT '', + UNIQUE(app_id, installation_id) +); + + +-- 8. 软件授权及其设备占用关系 +CREATE TABLE IF NOT EXISTS licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE, + customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable', + max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS license_devices ( + license_id TEXT NOT NULL, device_id TEXT NOT NULL UNIQUE, bound_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY(license_id,device_id) +); + + +-- 9. 每个应用独立维护的动态发布渠道 +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, channel_code TEXT NOT NULL, + display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100, + created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(app_id, channel_code) +); + + +-- 10. 文件下载授权与客户端完成结果 +CREATE TABLE IF NOT EXISTS download_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, license_id TEXT NOT NULL, + app_id TEXT NOT NULL, version TEXT NOT NULL, channel_code TEXT NOT NULL, file_path TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, result TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_download_logs_created ON download_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,created_at); + +-- 11. 管理后台写操作审计 +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL, + method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL, + status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at); + + +-- 12. SimCAE 崩溃报告原始数据索引 +CREATE TABLE IF NOT EXISTS crash_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_id TEXT NOT NULL UNIQUE, + client_report_id TEXT NOT NULL UNIQUE, + content_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'stored', + symbolication_status TEXT NOT NULL DEFAULT 'not_started', + product TEXT NOT NULL, + app_version TEXT NOT NULL, + git_commit TEXT NOT NULL, + build_type TEXT NOT NULL, + channel TEXT NOT NULL, + exception_code TEXT NOT NULL DEFAULT '', + crash_time_utc TEXT NOT NULL, + received_at_utc TEXT NOT NULL, + remote_address TEXT NOT NULL DEFAULT '', + content_length INTEGER NOT NULL DEFAULT 0, + metadata_sha256 TEXT NOT NULL, + metadata_size INTEGER NOT NULL DEFAULT 0, + minidump_sha256 TEXT NOT NULL, + minidump_size INTEGER NOT NULL DEFAULT 0, + attachments_sha256 TEXT NOT NULL DEFAULT '', + attachments_size INTEGER NOT NULL DEFAULT 0, + storage_path TEXT NOT NULL, + metadata_json TEXT NOT NULL, + server_json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_crash_reports_build ON crash_reports(product,app_version,git_commit); +CREATE INDEX IF NOT EXISTS idx_crash_reports_exception ON crash_reports(exception_code); +CREATE INDEX IF NOT EXISTS idx_crash_reports_received ON crash_reports(received_at_utc); + +-- 13. SimCAE 符号包上传索引 +CREATE TABLE IF NOT EXISTS crash_symbol_uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol_upload_id TEXT NOT NULL UNIQUE, + identity_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'stored', + product TEXT NOT NULL, + app_version TEXT NOT NULL, + git_commit TEXT NOT NULL, + build_type TEXT NOT NULL, + platform TEXT NOT NULL, + toolchain TEXT NOT NULL, + created_at_utc TEXT NOT NULL, + received_at_utc TEXT NOT NULL, + metadata_sha256 TEXT NOT NULL, + metadata_size INTEGER NOT NULL DEFAULT 0, + symbols_sha256 TEXT NOT NULL, + symbols_size INTEGER NOT NULL DEFAULT 0, + storage_path TEXT NOT NULL, + metadata_json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_crash_symbols_build ON crash_symbol_uploads(product,app_version,git_commit,build_type,platform); +CREATE INDEX IF NOT EXISTS idx_crash_symbols_received ON crash_symbol_uploads(received_at_utc); + +-- 14. 崩溃原始文件访问审计 +CREATE TABLE IF NOT EXISTS crash_file_access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_id TEXT NOT NULL, + file_name TEXT NOT NULL, + actor_hash TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_crash_file_access_report ON crash_file_access_logs(report_id,created_at);