diff --git a/Docs/ReadMe.txt b/Docs/ReadMe.txt index 949d27c..93d04b6 100644 --- a/Docs/ReadMe.txt +++ b/Docs/ReadMe.txt @@ -11,6 +11,9 @@ 2. 后端模板化改造说明.md 面向后端维护,说明当前 FastAPI 后端如何按 routes/services/repositories/schemas 分层,以及后续工程化方向。 +3. 崩溃报告接口联调说明.md + 面向测试和客户端接入,说明 CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN、CRASH_ADMIN_TOKEN 分别怎么用,并给出可直接复制的 curl 示例。 + 常用源码入口: - main.py:服务启动、数据库初始化、MinIO 初始化、全局客户端鉴权。 diff --git a/Docs/崩溃报告接口联调说明.md b/Docs/崩溃报告接口联调说明.md new file mode 100644 index 0000000..170dd24 --- /dev/null +++ b/Docs/崩溃报告接口联调说明.md @@ -0,0 +1,303 @@ +# SimCAE 崩溃报告接口联调说明 + +本文说明崩溃报告后端接口怎么测试,以及 curl 示例里的 `dev-token` 应该替换成什么。 + +## 一、先搞清楚三个 Token + +curl 示例里的: + +```text +Authorization: Bearer dev-token +``` + +这里的 `dev-token` 只是占位符,不是固定值。实际要按接口用途替换成服务端 `.env` / `.env.docker.example` 里的 token。 + +| 使用场景 | 请求头里填什么 | 服务端配置字段 | +| --- | --- | --- | +| 客户端上传崩溃报告 | `Authorization: Bearer ` | `CRASH_REPORT_TOKEN` | +| 上传符号包 | `Authorization: Bearer ` | `CRASH_SYMBOL_TOKEN` | +| 查询/下载崩溃报告原始文件 | `Authorization: Bearer ` | `CRASH_ADMIN_TOKEN` | +| `CRASH_ADMIN_TOKEN` 为空时查询/下载 | `Authorization: Bearer ` | `ADMIN_TOKEN` | + +当前默认配置通常是: + +```env +CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 +CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 +CRASH_ADMIN_TOKEN= +ADMIN_TOKEN=SimCAEAdminToken2026 +``` + +所以,如果使用默认配置: + +- 上传崩溃报告用 `SimCAECrashReportToken2026`。 +- 上传符号包用 `SimCAESymbolToken2026`。 +- 查询/下载崩溃报告时,因为 `CRASH_ADMIN_TOKEN` 为空,所以用 `SimCAEAdminToken2026`。 + +管理后台“客户端配置生成”区域下方也会显示“崩溃报告接口联调信息”。这部分只给管理员或测试人员查看,不会复制进客户端 `app_config.json`,也不要手动放进客户端配置文件。 + +其中会以类似 `.env` 的形式把 `CRASH_REPORT_TOKEN`、`CRASH_SYMBOL_TOKEN`、`CRASH_ADMIN_TOKEN`、`ADMIN_TOKEN` 单独列出来,并在每个字段前加中文说明。`CRASH_ADMIN_TOKEN` 如果为空,查询和下载崩溃报告时就使用 `ADMIN_TOKEN`。 + +## 二、接口地址 + +假设服务端地址是: + +```text +http://192.168.1.158:8000 +``` + +下面示例里用变量表示: + +```bash +SERVER=http://192.168.1.158:8000 +REPORT_TOKEN=SimCAECrashReportToken2026 +SYMBOL_TOKEN=SimCAESymbolToken2026 +ADMIN_TOKEN=SimCAEAdminToken2026 +``` + +如果部署了 HTTPS 或域名,把 `SERVER` 改成实际地址即可。 + +## 三、健康检查 + +健康检查不需要 token: + +```bash +curl "$SERVER/api/v1/health" +``` + +正常返回类似: + +```json +{ + "status": "ok", + "service": "simcae-crash-server", + "version": "1.0.0", + "timeUtc": "2026-07-14T00:00:00Z" +} +``` + +## 四、准备测试文件 + +创建一个假的 minidump 文件: + +```bash +printf "dummy minidump for api test\n" > crash.dmp +``` + +附件是可选的。需要测试附件时可以准备一个 zip: + +```bash +mkdir -p attachments +printf "extra log\n" > attachments/runtime.log +zip -r attachments.zip attachments +``` + +如果服务器上没有 `zip`,可以先不传 `attachments` 字段。 + +## 五、准备 metadata.json + +`clientReportId` 必须是 UUID,并且请求头 `Idempotency-Key` 必须和它完全一致。 + +最小可用示例: + +```json +{ + "schemaVersion": 1, + "clientReportId": "8f6c5ed9-9a2b-4ef1-89a2-8843f2f7d9d1", + "crashTimeUtc": "2026-07-14T08:30:00Z", + "product": "SIMCAE", + "appVersion": "0.10.3", + "gitCommit": "unknown", + "buildType": "Release", + "channel": "stable", + "platform": { + "os": "Windows", + "arch": "x64" + }, + "crash": { + "exceptionCode": "0xC0000005", + "threadId": 1 + }, + "userConsent": { + "uploadAllowed": true + }, + "files": [] +} +``` + +保存成 `metadata.json`。 + +说明: + +- `product` 必须是 `SIMCAE`。 +- `schemaVersion` 必须是数字 `1`。 +- `files` 必须是数组,可以先填空数组。 +- 如果 `files` 里声明了 `minidump` 的 `sha256`,服务端会校验它必须和实际上传的 `crash.dmp` 一致。 + +## 六、上传崩溃报告 + +不带附件的最小上传: + +```bash +curl -X POST "$SERVER/api/v1/crash-reports" \ + -H "Authorization: Bearer $REPORT_TOKEN" \ + -H "Idempotency-Key: 8f6c5ed9-9a2b-4ef1-89a2-8843f2f7d9d1" \ + -H "X-SimCAE-Client: SIMCAE" \ + -H "X-SimCAE-Version: 0.10.3" \ + -F "metadata=@metadata.json;type=application/json" \ + -F "minidump=@crash.dmp;type=application/octet-stream" +``` + +带附件上传: + +```bash +curl -X POST "$SERVER/api/v1/crash-reports" \ + -H "Authorization: Bearer $REPORT_TOKEN" \ + -H "Idempotency-Key: 8f6c5ed9-9a2b-4ef1-89a2-8843f2f7d9d1" \ + -H "X-SimCAE-Client: SIMCAE" \ + -H "X-SimCAE-Version: 0.10.3" \ + -F "metadata=@metadata.json;type=application/json" \ + -F "minidump=@crash.dmp;type=application/octet-stream" \ + -F "attachments=@attachments.zip;type=application/zip" +``` + +正常首次上传返回 HTTP 201,类似: + +```json +{ + "reportId": "srv_xxxxxxxxxxxxxxxxxxxxxxxx", + "duplicate": false, + "status": "stored", + "acceptedAtUtc": "2026-07-14T08:31:00Z" +} +``` + +如果用同一个 `clientReportId` 和相同内容重复上传,会返回 HTTP 200,并且: + +```json +{ + "duplicate": true +} +``` + +如果同一个 `clientReportId` 但内容变了,会返回 HTTP 409,表示幂等冲突。 + +## 七、查询崩溃报告详情 + +把上传返回的 `reportId` 填到下面命令里: + +```bash +REPORT_ID=srv_xxxxxxxxxxxxxxxxxxxxxxxx + +curl "$SERVER/api/v1/crash-reports/$REPORT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +注意: + +- 这里用的是管理查询 token。 +- 如果 `.env` 里配置了 `CRASH_ADMIN_TOKEN`,就用 `CRASH_ADMIN_TOKEN`。 +- 如果 `CRASH_ADMIN_TOKEN` 为空,就用 `ADMIN_TOKEN`。 + +## 八、下载崩溃报告文件 + +可下载文件名: + +```text +metadata.json +crash.dmp +attachments.zip +server.json +``` + +下载 minidump: + +```bash +curl "$SERVER/api/v1/crash-reports/$REPORT_ID/files/crash.dmp" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -o crash_downloaded.dmp +``` + +下载 metadata: + +```bash +curl "$SERVER/api/v1/crash-reports/$REPORT_ID/files/metadata.json" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -o metadata_downloaded.json +``` + +管理后台也可以查看崩溃报告列表和下载文件。 + +## 九、上传符号包 + +符号包接口用于上传某个版本/构建对应的 symbols.zip。 + +准备 `symbols_metadata.json`: + +```json +{ + "schemaVersion": 1, + "product": "SIMCAE", + "appVersion": "0.10.3", + "gitCommit": "unknown", + "buildType": "Release", + "platform": "windows-x64", + "toolchain": "msvc2019", + "createdAtUtc": "2026-07-14T08:00:00Z", + "files": [] +} +``` + +准备测试符号包: + +```bash +mkdir -p symbols +printf "dummy symbols\n" > symbols/readme.txt +zip -r symbols.zip symbols +``` + +上传: + +```bash +curl -X POST "$SERVER/api/v1/symbols" \ + -H "Authorization: Bearer $SYMBOL_TOKEN" \ + -F "metadata=@symbols_metadata.json;type=application/json" \ + -F "symbols=@symbols.zip;type=application/zip" +``` + +正常返回类似: + +```json +{ + "symbolUploadId": "sym_xxxxxxxxxxxxxxxxxxxxxxxx", + "status": "stored", + "duplicate": false, + "acceptedAtUtc": "2026-07-14T08:35:00Z" +} +``` + +## 十、常见错误 + +1. HTTP 401 `missing bearer token` + 没有传 `Authorization: Bearer ...`,或者格式写错。 + +2. HTTP 401 `invalid bearer token` + token 值不对。检查 `.env` 里的 `CRASH_REPORT_TOKEN`、`CRASH_SYMBOL_TOKEN`、`CRASH_ADMIN_TOKEN`、`ADMIN_TOKEN`。 + +3. HTTP 403 `token is not allowed to access this endpoint` + token 是对的,但用错接口了。例如拿 `CRASH_REPORT_TOKEN` 去查询/下载报告。 + +4. HTTP 400 `Idempotency-Key must equal metadata.clientReportId` + 请求头 `Idempotency-Key` 必须等于 metadata 里的 `clientReportId`。 + +5. HTTP 400 `metadata.product must be SIMCAE` + metadata 里的 `product` 必须填 `SIMCAE`。 + +6. HTTP 400 `file_hash_mismatch` + metadata.files 里声明的 SHA-256 和实际上传文件不一致。联调初期可以先把 `files` 填成空数组。 + +7. HTTP 413 `payload_too_large` + 文件超过 `.env` 里的大小限制,例如 `CRASH_MINIDUMP_MAX_MB`、`CRASH_ATTACHMENTS_MAX_MB`、`CRASH_REQUEST_MAX_MB`。 + +8. HTTP 409 `idempotency_conflict` + 同一个 `clientReportId` 已经上传过,但这次内容不一样。换一个新的 UUID 再试。 diff --git a/Docs/服务端部署说明.md b/Docs/服务端部署说明.md index df2c26c..fde728a 100644 --- a/Docs/服务端部署说明.md +++ b/Docs/服务端部署说明.md @@ -240,6 +240,8 @@ MinIO 控制台: http://你的服务器IP:9001/ | `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 | | `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 | +崩溃报告上传、查询、下载、符号包上传的 curl 联调步骤见 `Docs/崩溃报告接口联调说明.md`。示例里的 `dev-token` 不是固定值,要按接口用途替换为 `CRASH_REPORT_TOKEN`、`CRASH_SYMBOL_TOKEN` 或 `CRASH_ADMIN_TOKEN` / `ADMIN_TOKEN`。 + ## 五、客户端配置要同步哪些值 推荐直接使用后台页面生成: diff --git a/admin-ui/src/views/simcae/index.vue b/admin-ui/src/views/simcae/index.vue index 0a35a39..a43cbc7 100644 --- a/admin-ui/src/views/simcae/index.vue +++ b/admin-ui/src/views/simcae/index.vue @@ -27,6 +27,7 @@ const auditLogs = ref([]); const currentAppId = ref(""); const createdLicenseKey = ref(""); const generatedClientConfig = ref(""); +const generatedCrashTestInfo = ref(""); const runtime = reactive({}); const appForm = reactive({ app_id: "", app_name: "" }); @@ -526,6 +527,9 @@ async function generateClientConfig() { }); generatedClientConfig.value = result.json_text || JSON.stringify(result.config || {}, null, 2); + generatedCrashTestInfo.value = + result.crash_test_text || + JSON.stringify(result.crash_test_config || {}, null, 2); ElMessage.success("客户端配置已生成"); } catch (error) { handleError("生成客户端配置失败", error); @@ -537,6 +541,11 @@ async function copyGeneratedConfig() { ElMessage.success("客户端配置已复制"); } +async function copyGeneratedCrashTestInfo() { + await copyText(generatedCrashTestInfo.value); + ElMessage.success("崩溃报告联调信息已复制"); +} + async function loadDevices() { if (!currentAppId.value) return; const data = await adminRequest( @@ -917,7 +926,11 @@ onMounted(loadAll);
生成配套配置复制配置
+
客户端 app_config.json
{{ generatedClientConfig || "尚未生成客户端配置" }}
+ +
崩溃报告接口联调信息复制联调信息
+
{{ generatedCrashTestInfo || "生成配套配置后显示崩溃报告接口、Token 和大小限制" }}
@@ -1144,6 +1157,19 @@ onMounted(loadAll); font-size: 12px; } +.config-section-title { + display: inline-flex; + align-items: center; + min-height: 32px; + margin: 12px 0 6px; + color: var(--el-text-color-primary); + font-weight: 600; +} + +.action-row .config-section-title { + margin: 0; +} + .code-block { min-height: 220px; max-height: 440px; diff --git a/app/api/routes/admin_config.py b/app/api/routes/admin_config.py index bb3162a..44e0fe5 100644 --- a/app/api/routes/admin_config.py +++ b/app/api/routes/admin_config.py @@ -15,6 +15,8 @@ from app.services.admin_config_service import ( TARGET_ARCH, TARGET_PLATFORM, default_client_config_values, + crash_report_test_values, + crash_report_test_text, normalize_executable_name, public_api_base_url, ) @@ -104,7 +106,10 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req "platform": defaults["platform"], "arch": defaults["arch"], } + crash_test_config = crash_report_test_values(api_base_url) return { "config": config, "json_text": json.dumps(config, ensure_ascii=False, indent=2), + "crash_test_config": crash_test_config, + "crash_test_text": crash_report_test_text(api_base_url), } diff --git a/app/services/admin_config_service.py b/app/services/admin_config_service.py index df40526..ad4841e 100644 --- a/app/services/admin_config_service.py +++ b/app/services/admin_config_service.py @@ -52,6 +52,116 @@ def default_client_config_values(request: Request) -> dict: } +def crash_report_test_values(api_base_url: str) -> dict: + base_url = api_base_url.rstrip("/") + crash_admin_token = os.getenv("CRASH_ADMIN_TOKEN", "").strip() + admin_token = os.getenv("ADMIN_TOKEN", "").strip() + crash_report_token = ( + os.getenv("CRASH_REPORT_TOKEN") + or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN") + or os.getenv("CLIENT_API_TOKEN") + or "" + ) + crash_symbol_token = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN") or "" + effective_admin_token = crash_admin_token or admin_token + effective_admin_token_source = "CRASH_ADMIN_TOKEN" if crash_admin_token else "ADMIN_TOKEN" + return { + "说明": "仅用于后台管理员/测试人员联调崩溃报告接口,不要复制进客户端 app_config.json。", + "server_base_url": base_url, + "tokens": { + "CRASH_REPORT_TOKEN": { + "用途": "上传崩溃报告 /api/v1/crash-reports", + "token": crash_report_token, + }, + "CRASH_SYMBOL_TOKEN": { + "用途": "上传符号包 /api/v1/symbols", + "token": crash_symbol_token, + }, + "CRASH_ADMIN_TOKEN": { + "用途": "查询和下载崩溃报告", + "token": crash_admin_token, + "说明": "如果为空,查询/下载接口会使用 ADMIN_TOKEN。", + }, + "ADMIN_TOKEN": { + "用途": "管理后台令牌;当 CRASH_ADMIN_TOKEN 为空时也用于查询/下载崩溃报告", + "token": admin_token, + }, + }, + "upload_crash_report": { + "url": f"{base_url}/api/v1/crash-reports", + "authorization_header": "Authorization: Bearer ", + "token_env": "CRASH_REPORT_TOKEN", + "token": crash_report_token, + "idempotency_key_rule": "Idempotency-Key 必须等于 metadata.clientReportId", + }, + "upload_symbols": { + "url": f"{base_url}/api/v1/symbols", + "authorization_header": "Authorization: Bearer ", + "token_env": "CRASH_SYMBOL_TOKEN", + "token": crash_symbol_token, + }, + "admin_query_and_download": { + "detail_url_template": f"{base_url}/api/v1/crash-reports/{{report_id}}", + "file_url_template": f"{base_url}/api/v1/crash-reports/{{report_id}}/files/{{file_name}}", + "authorization_header": f"Authorization: Bearer <{effective_admin_token_source}>", + "token_env": effective_admin_token_source, + "token": effective_admin_token, + "file_name_examples": ["metadata.json", "crash.dmp", "attachments.zip", "server.json"], + }, + "limits": { + "CRASH_SERVICE_VERSION": os.getenv("CRASH_SERVICE_VERSION", "1.0.0"), + "CRASH_METADATA_MAX_KB": os.getenv("CRASH_METADATA_MAX_KB", "256"), + "CRASH_MINIDUMP_MAX_MB": os.getenv("CRASH_MINIDUMP_MAX_MB", "128"), + "CRASH_ATTACHMENTS_MAX_MB": os.getenv("CRASH_ATTACHMENTS_MAX_MB", "64"), + "CRASH_REQUEST_MAX_MB": os.getenv("CRASH_REQUEST_MAX_MB", "200"), + "CRASH_SYMBOLS_MAX_MB": os.getenv("CRASH_SYMBOLS_MAX_MB", "512"), + }, + } + + +def crash_report_test_text(api_base_url: str) -> str: + values = crash_report_test_values(api_base_url) + tokens = values["tokens"] + lines = [ + "# 说明:下面内容仅用于后台管理员/测试人员联调崩溃报告接口。", + "# 注意:不要复制进客户端 app_config.json,也不要放进客户端配置文件。", + "", + "# 服务端基础地址。curl 示例里的 SERVER 就填这个值。", + f"SERVER_BASE_URL={values['server_base_url']}", + "", + "# 上传崩溃报告使用的 Token。", + "# 用途:POST /api/v1/crash-reports", + "# 请求头写法:Authorization: Bearer ", + f"CRASH_REPORT_TOKEN={tokens['CRASH_REPORT_TOKEN']['token']}", + f"CRASH_REPORT_URL={values['upload_crash_report']['url']}", + "", + "# 上传符号包使用的 Token。", + "# 用途:POST /api/v1/symbols", + "# 请求头写法:Authorization: Bearer ", + f"CRASH_SYMBOL_TOKEN={tokens['CRASH_SYMBOL_TOKEN']['token']}", + f"CRASH_SYMBOL_URL={values['upload_symbols']['url']}", + "", + "# 查询和下载崩溃报告使用的 Token。", + "# 如果 CRASH_ADMIN_TOKEN 为空,就使用 ADMIN_TOKEN。", + "# 请求头写法:Authorization: Bearer ", + f"CRASH_ADMIN_TOKEN={tokens['CRASH_ADMIN_TOKEN']['token']}", + f"ADMIN_TOKEN={tokens['ADMIN_TOKEN']['token']}", + f"CRASH_REPORT_DETAIL_URL_TEMPLATE={values['admin_query_and_download']['detail_url_template']}", + f"CRASH_REPORT_FILE_URL_TEMPLATE={values['admin_query_and_download']['file_url_template']}", + "", + "# 可下载文件名。", + "CRASH_REPORT_FILE_NAMES=metadata.json,crash.dmp,attachments.zip,server.json", + "", + "# 幂等规则:Idempotency-Key 必须等于 metadata.json 里的 clientReportId。", + "CRASH_IDEMPOTENCY_KEY_RULE=Idempotency-Key must equal metadata.clientReportId", + "", + "# 崩溃报告服务版本和大小限制。", + ] + for key, value in values["limits"].items(): + lines.append(f"{key}={value}") + return "\n".join(lines) + + def normalize_executable_name(value: str) -> str: normalized = str(value or "").strip().replace(chr(92), "/").strip("/") if not normalized: