25 KiB
外部系统风险评估报告接口
1. 文档说明
本文档定义外部业务系统调用蝶雅风险预警平台生成新品风险评估 Markdown 报告的接口协议。
外部接口代码已实现。启用前需执行数据库迁移、配置系统调用密钥并开通本地钉钉用户;测试和正式地址由部署方提供。本接口与内部
POST /api/v1/risk-predictions/preview分离,不依赖内部用户 JWT 或聊天会话。
1.1 接口用途
外部系统提交产品、包装、供应商和开发阶段等风险评估参数,蝶雅平台完成以下处理:
- 校验调用方系统身份和请求签名。
- 根据钉钉用户标识,或管理员预先批准的外部账号映射,匹配蝶雅平台用户。
- 使用蝶雅平台风险数据库、标准库和评分模型完成风险评估。
- 生成 Markdown 格式的风险评估报告并直接返回。
1.2 当前数据边界
外部系统与蝶雅平台使用不同数据库时,蝶雅平台默认只使用自己的历史异常案例、采购订单、供应商主数据和标准规则库。外部系统数据库中的历史数据不会自动参与评分;如需使用,双方另行约定数据同步或 externalEvidence 扩展协议。
2. 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /api/v1/integrations/risk-assessments/report |
| 请求类型 | application/json |
| 成功响应类型 | text/markdown; charset=utf-8 |
| 字符编码 | UTF-8 |
| 接口超时时间建议 | 120 秒 |
| 幂等依据 | sourceSystem + requestId;Idempotency-Key 必须与 requestId 一致 |
生产环境完整地址由蝶雅平台提供,例如:
https://risk.example.com/api/v1/integrations/risk-assessments/report
3. 调用鉴权
接口使用“调用方系统身份 + HMAC-SHA256 签名”鉴权。外部系统不使用蝶雅平台用户 JWT,也不直接传入蝶雅平台本地 userId。
3.1 请求头
| 请求头 | 必填 | 说明 |
|---|---|---|
Content-Type |
是 | 固定为 application/json |
X-Client-Id |
是 | 蝶雅平台分配的调用方系统编号,例如 dieya-plm |
X-Timestamp |
是 | Unix 时间戳,单位为秒 |
X-Nonce |
是 | 8~64 位随机字符串,仅允许字母、数字、下划线、短横线,单次请求唯一 |
X-Signature |
是 | HMAC-SHA256 签名,固定使用标准 Base64 编码(带填充 =) |
Idempotency-Key |
是 | 与请求体中的 requestId 保持一致 |
3.2 签名计算
蝶雅平台为每个 X-Client-Id 分配独立密钥 clientSecret。POST 调用使用以下原文计算签名:
X-Timestamp + "\n" + X-Nonce + "\n" + 原始请求Body
伪代码:
message = f"{timestamp}\n{nonce}\n{raw_body}"
signature = base64.b64encode(
hmac.new(client_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
注意事项:
- 签名必须基于发送时的原始 JSON 字节内容计算,不能先格式化后再计算。
- 服务端默认只接受时间误差不超过 5 分钟的请求。
- 已认证请求的
X-Nonce在数据库中保留 10 分钟,跨进程和重启后仍拒绝重复使用;正常 JSON 的字段校验失败也会消耗 Nonce。 clientSecret只通过双方约定的安全渠道保存,不放在前端代码、日志或请求体中。- 生产环境建议叠加 IP 白名单;高安全场景可使用 mTLS。
3.3 用户身份映射
请求中的用户身份使用钉钉标识:
dingtalkUnionId:首选跨系统关联标识。dingtalkUserId:钉钉用户 ID,作为辅助校验和追踪字段。employeeNo、name:展示和审计信息,不能单独作为身份依据。
蝶雅平台按以下顺序处理:
- 按
dingtalkUnionId查找本地用户。 - 必要时结合
dingtalkUserId校验用户身份。 - 用户不存在、停用或 UnionId 匹配到多个本地用户时返回
403,不自动创建账号。 - 评分、审计和报告记录使用蝶雅平台本地用户 ID;该 ID 不返回给外部系统作为跨库主键。
用户需要拥有本地 risk.assess 操作权限(管理员直接允许)。如传入 dingtalkUserId,必须与本地用户一致。报告中的姓名使用本地用户资料,不使用调用方提交的姓名作为认证或显示依据。查询报告时再次校验原操作人的账号状态及权限。
3.4 非钉钉操作人
没有钉钉身份的用户可使用 operator.externalUserId,前提是风险平台管理员已为当前调用系统批准映射。两种身份模式必须且只能选一种;外部账号模式不传 dingtalkUnionId、dingtalkUserId,可以省略或传 null。不允许按姓名、员工编号自动匹配用户。
例如 PLM 账号 superadmin(周子墨)的操作人参数为:
{
"externalUserId": "superadmin",
"name": "周子墨"
}
这个对象替换请求中的整个 operator,其他字段及系统签名要求不变。PLM 后端必须从当前已登录用户取出该账号标识,不能让前端任意指定别人的 externalUserId。平台通过 X-Client-Id + externalUserId 查映射并检查本地账号及权限,未批准映射返回 403。外部系统的管理员角色不授予风险平台管理员权限。
GET 查询时也会重新校验记录中的外部账号映射;撤销映射或停用本地账号后禁止查询。
如果两个钉钉应用返回的 UnionId 不一致,双方需要先确定统一的钉钉身份源或增加统一身份映射表。
4. 请求参数
4.1 请求体
{
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {
"dingtalkUnionId": "ding_union_xxx",
"dingtalkUserId": "ding_user_xxx",
"employeeNo": "A10001",
"name": "张三"
},
"assessment": {
"subjectId": "SKU-20261007-001",
"productName": "新品洗发水",
"productCategory": "洗发水/洗护类",
"specification": "500ml",
"materialType": "PET瓶/泵头",
"supplier": "某某供应商",
"factory": "某某加工厂",
"stageNew": "产品开发",
"defectLevel": null,
"description": "新品上市前风险评估",
"asOfDate": "2026-10-07"
}
}
4.2 顶层字段
| 字段 | 类型 | 必填 | 说明 | 校验建议 |
|---|---|---|---|---|
requestId |
string | 是 | 调用方请求唯一编号,用于幂等 | 1~128 个字符,仅允许字母、数字、_、.、:、- |
sourceSystem |
string | 是 | 来源系统编号 | 必须是已登记的 X-Client-Id |
operator |
object | 是 | 发起评估的钉钉用户 | 见下表 |
assessment |
object | 是 | 风险评估数据 | 见下表 |
4.3 operator 字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
dingtalkUnionId |
string/null | 条件必填 | 钉钉用户模式必填,与 externalUserId 二选一 |
externalUserId |
string/null | 条件必填 | 非钉钉用户的调用方稳定账号标识,1~128 字符,必须在当前调用系统下预先批准映射 |
dingtalkUserId |
string | 建议 | 钉钉用户 ID |
employeeNo |
string | 否 | 员工编号,用于审计展示 |
name |
string | 否 | 用户姓名,用于报告展示 |
4.4 assessment 字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
subjectId |
string | 是 | 产品、SKU 或评估对象编号 |
productName |
string | 否 | 产品名称 |
productCategory |
string | 是 | 按当前风险评分的八类传值,完整清单及兼容规则见 4.6 |
specification |
string | 否 | 规格或容量,例如 500ml、100g |
materialType |
string | 是 | 包装材质或物料组合,例如 PET瓶/泵头 |
supplier |
string | 否 | 供应商名称 |
factory |
string | 否 | 加工厂名称 |
stageNew |
string | 否 | 开发阶段 |
defectLevel |
string/null | 否 | 缺陷等级,可取 A、B、C |
description |
string | 否 | 评估背景或补充说明 |
asOfDate |
string/null | 否 | 评估数据截止日期,格式 YYYY-MM-DD,不早于 1900-01-01、不允许未来日期;省略时使用首次受理的上海日期 |
stageNew 建议使用以下枚举值:
需求发现
产品立项
产品开发
量产上市
产品生命管理
4.5 字段约束
productCategory、materialType、subjectId缺失时,接口返回422。description仅作为评估背景,不直接作为已确认的检测结果或历史事实。- 调用方不要提交最终风险分数、风险等级或模型版本;这些字段由蝶雅平台统一计算。
- 当前版本不接收外部数据库连接信息、SQL、文件路径或可执行脚本。
- 可选字段允许省略或传
null。stageNew、defectLevel非空时必须符合枚举;未知字段(包括调用方提交的分数、角色)返回422。 - 参数长度:
subjectId128、productName200、productCategory100、specification100、materialType200、supplier/factory300、description2000;dingtalkUnionId64、dingtalkUserId50、employeeNo64、name50。请求体最大 64KB。
4.6 产品品类字典(当前评分口径)
本次 PLM 对接采用现有七维风险评分服务 normalize_category() 的八类口径。以下为当前评分分类清单,并非数据库字典接口的返回结果。
PLM 优先将产品品类映射成下表的标准值后提交 assessment.productCategory,并保存映射关系。无法确定具体类别时,不应仅凭 PLM 的宽泛业务大类强行映射。
| 标准值(建议提交值) | 当前兼容识别关键词 |
|---|---|
洗发水/洗护类 |
洗发、洗护、洗发水、洗发露、洗发乳 |
沐浴露类 |
沐浴、沐浴露 |
面霜/护肤类 |
面霜、乳液、精华、面膜、护肤 |
牙膏/口腔护理类 |
牙膏、口腔 |
清洁剂类 |
清洁剂、清洁、洗洁 |
脱毛类 |
脱毛 |
彩妆/唇部类 |
彩妆、口红、粉底、眼影 |
香氛/止汗类 |
香氛、香水、止汗 |
匹配规则及评分边界:
- 当前按上表顺序进行关键词子串匹配,多个类别同时命中时取第一类。
- 空值仍按本接口必填规则返回
422。非空值无法识别时,按现有评分逻辑将“产品品类”维度设为默认 50 分,并在报告中注明无法归类;这不代表产品风险已经由历史数据验证。 - 已识别品类的历史异常案例少于 10 条时,该维度按 45 分处理;其余情况按现有异常数量归一化规则计算。
- 当前
防晒、洁面、护发素等名称本身没有专门的评分匹配规则;包含其他兼容关键词的完整名称仍按现有规则归类,例如“防晒乳液”命中“乳液”。 个护清洁会命中“清洁”并归为“清洁剂类”,不能将该 PLM 大类整体作为防晒等产品的评分分类。- 报告应同时展示调用方提交的品类和实际评分归类;无法识别时注明默认分及原因。
历史案例的品类维度统计优先归一化案例的 product_category,无法识别时再尝试案例的 product_name。本对接口径采用评分规则;看板中的“身体护理类”“洁面/卸妆类”“头发护理类”“包材/物料专项”“待补标品类”不属于当前八类评分字典。
5. 成功响应
5.1 响应头
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
X-Request-Id: PLM-D-FT-003-0001
X-Assessment-Id: 10086
X-Model-Version: database-seven-dimension-v1
X-Risk-Score: 72.50
X-Risk-Level: moderately_low
5.2 响应体
响应体直接为 Markdown 文本,不再包裹 data、code 等 JSON 字段:
# 新品风险评估报告
## 一、评估信息
| 项目 | 内容 |
|---|---|
| 评估对象 | 新品洗发水 |
| 对象编号 | SKU-20261007-001 |
| 产品品类 | 洗发水/洗护类 |
| 规格 | 500ml |
| 包装材质 | PET瓶/泵头 |
| 供应商 | 某某供应商 |
| 加工厂 | 某某加工厂 |
| 开发阶段 | 产品开发 |
| 评估人 | 张三 |
| 评估时间 | 2026-10-07 |
## 二、综合风险结论
- 综合得分:72.50
- 风险等级:较低风险
- 模型版本:database-seven-dimension-v1
## 三、七维风险评分
| 风险维度 | 得分 | 权重 | 数据状态 | 说明 |
|---|---:|---:|---|---|
| 供应商表现 | 80.00 | 25% | database | 近三年采购订单和异常案例统计 |
| 物料品类 | 75.00 | 20% | database | 包装材质历史异常统计 |
| 品类历史 | 70.00 | 15% | database | 产品品类历史案例统计 |
## 四、相似异常案例
暂无直接匹配案例,建议结合相似品类进行验证。
## 五、标准规则
| 风险点 | 判定规则 | 来源 | 状态 |
|---|---|---|---|
| 密封性 | 按标准要求完成密封性验证 | 标准文件.xlsx | 待检测 |
## 六、风险预防建议
1. 打样阶段完成内容物与包装材料相容性测试。
2. 增加密封性和加速老化测试。
3. 首单加强来料检验和过程巡检。
## 七、数据口径说明
本报告基于蝶雅风险平台本地数据库中的历史案例、采购订单和标准库生成。
部分维度无实际数据时,按系统配置的中性值计算。
报告中的分数是安全得分,分数越高代表风险越低。风险等级阈值以蝶雅平台当前模型配置为准,调用方不要自行重新计算等级。
X-Risk-Score 为 0~100 的安全分,保留两位小数。X-Risk-Level 为 low(低风险)、moderately_low(较低风险)、medium(中等风险)、high(高风险)、very_high(极高风险)。X-Assessment-Id 按字符串存储。GET 和幂等重试返回原报告及相同结论响应头。
6. 幂等规则
requestId 是调用方生成的业务请求唯一编号,同一个 sourceSystem + requestId 只能对应一次评估请求。
首次请求
返回 200 和 Markdown 报告。
相同请求重复提交
如果请求体和原请求一致,服务端返回同一份已生成报告,仍返回 200,并在响应头中带回原始 X-Assessment-Id。
同一编号提交不同内容
如果 requestId 已经使用,但请求内容摘要不同,返回 409,避免覆盖历史报告。
处理中、失败及重试
- 幂等比较基于校验后的 JSON 对象摘要,忽略 JSON 键顺序和排版,省略的可选字段与
null等价;变更任意有效字段视为不同请求。 - 重试保留原请求体(包括操作人和
asOfDate),重新生成时间戳、Nonce 和签名。主动重新评估必须使用新requestId。 - 同一请求仍在生成时,POST 或 GET 返回
202JSON 和Retry-After: 5,不会重复计算。 - 每次生成最多 110 秒(可配置为 10~110 秒),超时或失败记录为 failed,返回
503;相同请求内容可以重新 POST 重试。生成权租约为 150 秒,进程异常退出后租约到期可重新 POST 接管。 - 调用方网络超时并不代表服务端已经停止。应先 GET 查询,再决定是否使用原请求重试。
- 省略
asOfDate时首次受理日期单独持久化,跨天重试仍沿用首次日期。
处理中响应示例:
{"code":202,"message":"报告正在生成,请稍后查询","data":{"requestId":"PLM-D-FT-003-0001"}}
报告查询
GET /api/v1/integrations/risk-assessments/report?requestId=PLM-D-FT-003-0001
查询参数必须且只能包含一个 requestId;GET 不携带 Body,也不要求 Idempotency-Key。仅能查询与 X-Client-Id 相同来源的记录。成功返回 200 Markdown,处理中返回 202,失败返回 503,不存在返回 404。
GET 签名原文为:
"GET" + "\n" + 请求路径 + "\n" + 原始查询字符串 + "\n" + X-Timestamp + "\n" + X-Nonce + "\n"
请求路径包含 /api/v1,不含域名、查询字符串;原始查询字符串不含 ?,包括实际发送的 URL 编码,签名后不得重新编码。网关不能重写对外接口路径。以 UTF-8 字节计算 HMAC-SHA256,再以标准 Base64 编码。报告持久化保存,当前未启用自动清理策略。
7. 错误响应
错误响应统一使用 JSON:
{
"code": 422,
"message": "产品品类不能为空",
"data": null
}
| HTTP 状态码 | code |
场景 | 调用方处理建议 |
|---|---|---|---|
400 |
400 |
请求格式错误或签名原文不合法 | 修正请求格式后重试 |
401 |
401 |
缺少签名、签名错误、时间戳过期 | 检查系统密钥和签名算法 |
403 |
403 |
调用方未开通接口或用户无风险评估权限 | 联系平台管理员开通权限 |
404 |
404 |
当前调用系统未找到该请求 | 核对 requestId 和调用系统 |
409 |
409 |
requestId 重复但内容不一致 |
更换请求编号或查询原请求 |
422 |
422 |
参数缺失或字段值不符合约束 | 修正参数后重试 |
429 |
429 |
超过调用频率限制 | 按 Retry-After 延迟重试 |
500 |
500 |
平台内部处理异常 | 使用同一 requestId 重试并联系平台方 |
503 |
503 |
风险数据库、模型或依赖服务暂不可用 | 稍后重试,不要生成新的业务编号 |
8. 调用示例
8.1 cURL
curl -X POST 'https://risk.example.com/api/v1/integrations/risk-assessments/report' \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: dieya-plm' \
-H 'X-Timestamp: 1791331200' \
-H 'X-Nonce: 8f3d4c1a9e' \
-H 'X-Signature: Base64HmacSha256Value' \
-H 'Idempotency-Key: PLM-D-FT-003-0001' \
--data-raw '{
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {
"dingtalkUnionId": "ding_union_xxx",
"dingtalkUserId": "ding_user_xxx",
"employeeNo": "A10001",
"name": "张三"
},
"assessment": {
"subjectId": "SKU-20261007-001",
"productName": "新品洗发水",
"productCategory": "洗发水/洗护类",
"specification": "500ml",
"materialType": "PET瓶/泵头",
"supplier": "某某供应商",
"factory": "某某加工厂",
"stageNew": "产品开发",
"description": "新品上市前风险评估",
"asOfDate": "2026-10-07"
}
}' \
--output risk-report.md
8.2 Python 签名示例
import base64
import hashlib
import hmac
import json
import secrets
import time
body = {
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {"dingtalkUnionId": "ding_union_xxx"},
"assessment": {
"subjectId": "SKU-20261007-001",
"productCategory": "洗发水/洗护类",
"materialType": "PET瓶/泵头",
},
}
raw_body = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
nonce = secrets.token_urlsafe(16)
message = timestamp.encode() + b"\n" + nonce.encode() + b"\n" + raw_body
signature = base64.b64encode(
hmac.new(client_secret.encode(), message, hashlib.sha256).digest()
).decode()
调用方必须使用计算签名时的同一份 raw_body 发送请求。
9. 评分和报告处理说明
平台内部处理逻辑建议统一复用现有七维风险评分服务:
- 供应商表现
- 物料品类
- 品类历史
- 加工厂质量
- 标准符合性
- 节点阶段
- 缺陷严重度
平台会在报告中标注数据状态。没有真实数据库数据的维度不得伪造历史结论,按模型配置的中性值处理并在报告中说明。
外部接口使用结构化参数直接评分,不再通过聊天语义提取参数。沿用现有评分区间起点(截止年份往前 3 年的 1 月 1 日),区间终点为 asOfDate,不包含截止日期之后的案例及采购订单。相似案例使用现有检索服务的文本和业务字段排序,输入同一统计区间内的案例;本接口不调用向量嵌入服务。
包装组合沿用现有单标签评分口径,例如 PET 瓶身 + PP 泵头 命中“泵头”,不代表分别评估了所有组件。报告展示原文及评分标签。缺陷维度来自历史 A/B/C 记录,不预测新品实际缺陷等级。
AI 只用于生成预防建议,等待最多 20 秒。AI 不可用时仍返回数据库评分及固定验证建议,并在报告中标注;数据库评分失败则返回 503,不生成模拟分数。
报告建议包含:
- 评估信息。
- 综合风险得分和风险等级。
- 七维评分明细。
- 品类、供应商和物料历史统计。
- 相似异常案例。
- 标准规则匹配结果。
- 风险预防建议。
- 数据口径和模型版本。
10. 联调前需要双方确认的事项
- 正式接口域名、环境地址和接口版本。
sourceSystem、X-Client-Id和密钥分配方式。- 钉钉
unionId是否可以作为双方统一用户标识;如果不能,需要提供统一身份映射方案。 - 操作人提前开通、UnionId 唯一映射及
risk.assess权限;本接口已采用不存在返回403的策略。 - 是否启用 IP 白名单、mTLS 和调用频率限制。
stageNew、defectLevel枚举,以及 PLM 到第 4.6 节八类评分品类的映射关系;确认无法归类时的默认分提示。- 外部系统历史数据是否需要同步到蝶雅平台。
- Markdown 报告的保存、下载和展示方式。
- 超时、重试和
requestId查询机制。
11. 服务端启用配置
先执行迁移:
python -m alembic upgrade head
新增表为 integration_nonces 和 integration_assessments,分别保存已认证随机串及请求快照、处理状态、评分结果和 Markdown 报告;成功评估同时写入操作审计。现有用户表不自动添加外部用户。报告记录通过本地用户编号逻辑关联,不对已有用户表创建外键,迁移账号不需要该表的 REFERENCES 权限;用户状态和权限仍由接口校验。
在服务端环境配置中设置:
RISK_INTEGRATION_CLIENTS={"dieya-plm":"replace-with-at-least-32-random-characters"}
RISK_INTEGRATION_REQUESTS_PER_MINUTE=60
RISK_INTEGRATION_REPORT_TIMEOUT_SECONDS=110
上面的密钥仅为占位,须替换为至少 32 个字符的随机密钥;测试、正式各使用一套。调用系统编号仅允许字母、数字、下划线和短横线,最长 64 位。默认 {} 不允许任何系统调用。配置后重启服务。
也可以在后端项目根目录使用启用工具生成密钥,工具会保留 .env 中的其他设置及已有系统密钥,不打印密钥、不自动轮换已有密钥:
.\.venv\Scripts\python.exe scripts/setup_external_risk.py configure --client-id dieya-plm
让操作人在钉钉工作台中登录风险平台,正常免登会同步用户资料和 UnionId。然后使用 PLM 保存的真实 UnionId 检查映射与权限:
.\.venv\Scripts\python.exe scripts/setup_external_risk.py check-user --union-id "PLM操作人的真实UnionId" --dingtalk-user-id "同一人的真实UserId"
工具只检查、不修改账号或权限。不存在时先通过钉钉免登开通;已停用或无权限时由账号管理员确认处理;映射不唯一或身份不一致时核对两边钉钉身份源,不能按姓名猜测绑定。当前启用的 admin、product_manager、reviewer、user 角色均有风险评估操作权限,以本地 role_permissions 表实际记录为准。
限流按调用系统统计已通过签名的请求,POST、GET、幂等重试共用滚动 60 秒窗口;达到阈值返回 429 和 Retry-After: 60。IP 白名单和 mTLS 可在网关配置,当前应用层不提供这两项配置。
12. 联调调用工具
非钉钉用户需由风险平台管理员在本地显式开通,例如:
.\.venv\Scripts\python.exe scripts/setup_external_risk.py allow-user --client-id dieya-plm --external-user-id superadmin --employee-no plm_superadmin --name "周子墨"
工具创建无密码、无钉钉绑定的普通本地账号,并写入 .env:
RISK_INTEGRATION_OPERATOR_MAPPINGS='{"dieya-plm":{"superadmin":"plm_superadmin"}}'
映射目标是本地 emp_no。默认 {} 禁止所有非钉钉账号;已有映射不会自动指向其他账号,已有账号不会自动更改角色、姓名或启用状态。配置后重启后端,无需数据库迁移。
项目提供 scripts/call_external_risk_report.py,自动生成 POST/GET 签名并保存 Markdown。调用方将本系统分配的密钥写入环境变量 RISK_INTEGRATION_CLIENT_SECRET(客户端单个密钥,与服务端 JSON 密钥表是不同配置)。assessment.json 使用本文第 4.1 节请求结构,填入已开通的真实 UnionId。
python scripts/call_external_risk_report.py --url https://risk.example.com/api/v1/integrations/risk-assessments/report --client-id dieya-plm --body-file assessment.json --output risk-report.md
查询原请求:
python scripts/call_external_risk_report.py --url https://risk.example.com/api/v1/integrations/risk-assessments/report --client-id dieya-plm --request-id PLM-D-FT-003-0001 --output risk-report.md
只有 200 会保存报告;202/错误响应会展示 JSON 和重试等待时间,不会覆盖已有 Markdown。