15 KiB
阿里云审核通道「response data is empty」诊断报告
日期:2026-09-10
场景:@scene#15:"日常开发" — 内容审核 / [security] text_censor_source = aliyun
最终落地(2026-09-10 16:40)— 已迁移至内容安全 2.0
三条路径中已选定并落地「方案 A:迁移到内容安全增强版 2.0」,同时采纳第五节提出的折中防呆 (首次出现账号级拒绝时打一条一次性告警)。
为什么不是方案 B / C
- B(升级企业实名认证):需要企业资质,当前账号不具备;且 1.0 的标签体系本身也已过时。
- C(放弃阿里云只用七牛):会丢掉 auto 模式后半段「七牛误报时由阿里云二次复核」的能力。
关键事实(官方文档核实)
| 项目 | 内容安全 1.0(旧) | 文本审核增强版 2.0(新) |
|---|---|---|
| 接口 | green.cn-shanghai + /green/text/scan |
green-cip.cn-shanghai.aliyuncs.com + TextModeration |
| SDK | alibaba-cloud-sdk-go(已从依赖中移除) |
alibabacloud-go/green-20220302/v3 v3.5.4 |
| 开通前提 | 企业实名认证(个人账号永远 596) | 实名认证即可(个人可开通) |
| 计费 | — | 文本审核标准 7.5 元/万次;开通免费、按量出账 |
| 单次上限 | — | content ≤ 600 字符(超长必须分段) |
⚠️ 一处必须记录的事实修正:green-20220302 SDK 中
TextModerationResponseBodyData.Labels 的内联注释列出的标签(10 个,且不含涉政)
是旧的、不完整的。以官方《文本审核增强版标签处理建议参考》为准,2.0 通用服务
包含 political_content(涉政)、violence(暴恐)、sexual_content(色情)、
C_customized(自定义词库)—— 即 policy.go 的底线四件套可完整覆盖,
不存在此前担心的「迁移后失去涉政拦截」缺口。
落地改动
| 文件 | 改动 |
|---|---|
internal/security/censor/aliyun.go |
整文件重写为 2.0:TextModeration + 归一化响应 + 标签映射 + 分段审核 + 一次性账号告警 |
| 同上 | 审核场景固定 comment_detection(公聊评论:官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流) |
| 同上 | 超长文本按 550 字分段、段间重叠 50 字;合并口径为标签取并集、风险级别取最高 |
| 同上 | riskLevel → 置信度折算 high=1 / medium=0.6 / low=0.3,供 policy.go 的阈值复用 |
| 同上 | 归一化响应带 api: "TextModeration" 格式标记 |
internal/security/censor/censor.go |
阿里云缓存回放解析失败时退回实时调用,不再直接抛错 |
internal/security/censor/{text,policy}.go |
同步注释(置信度来源由 rate 改为 riskLevel 折算) |
internal/security/censor/aliyun_test.go |
新增 2.0 测试套件(分段 / 标签映射 / 多片合并 / 兼容性 / 告警判定) |
internal/security/censor/policy_test.go |
移除 1.0 格式的旧测试 |
conf/app.sample.ini、internal/conf/static.go |
注释改为 2.0 开通指引 |
go.mod / go.sum |
移除已无引用的 alibaba-cloud-sdk-go v1.62.26 |
两个刻意的设计取舍
- 格式标记 + 缓存回退:解析器拒绝一切非
api: "TextModeration"的载荷,censor.go在解析失败时退回一次实时调用。作用:迁移前 1.0 落库的缓存(若有)既不会被误判成 「通过」(那会变成绕过审核的后门),也不会因 fail-closed 让这些历史文本在 31 天缓存 过期前一直发不出去。 - 一次性账号级告警:首次出现 596 / Forbidden / InvalidAccessKeyId 等「账号侧未就绪」 失败时,打印一条带开通入口与授权说明的 WARN,之后不再重复 —— 零成本、无外部依赖, 把「静默全站拒签」变成日志里一眼可见的可操作信号。
校验结果
go build -overlay=... ./...:通过go vet ./internal/security/...:通过go test ./...:internal/security/censor全绿;仅internal/db失败, 且失败信息均为Unknown database type: ""(需真实 PostgreSQL/MySQL),与本改动无关gofmt -l:仅censor.go(改动前既有的空格缩进,按约定未动)
仍需你处理
- 在阿里云内容安全 2.0 控制台开通「内容审核增强版」按量付费;RAM 子账号授予
AliyunYundunGreenWebFullAccess。开通前:auto模式只走七牛;aliyun模式仍会 fail-closed。 - CHANGELOG 尚未记入(见文末第五节)。
〇、结论速览(诊断阶段,2026-09-10 16:05)
✅ 根因(两层)
- 直接原因:阿里云账号未开通「内容安全 1.0」 →
code=596。- 深层原因:「内容安全 1.0」的开通前提是账号已完成企业实名认证(官方文档明文)。 若该账号是个人实名认证,则 1.0 永远无法开通,无论在产品页怎么点。
诊断改动落地后,下一条日志立即给出了答案:
error="text scan (request body={\"scenes\":[\"antispam\"],\"tasks\":[{\"content\":\"测试!\"}]}):
aliyun rejected the request: code=596
msg=\"You have not opened Yundun Content Moderation Service, please go to the page
(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console\"
request_id=01A08A4B-CCE2-5AAF-9329-A0C51C0F5060
raw={\"code\":596,\"httpStatusCode\":200,\"msg\":\"...\",\"success\":false}"
三点关键信息:
httpStatusCode: 200+success: false+ 无data—— 精确印证了第二节的推断: 阿里云在请求被拒时依然返回 HTTP 200,所以旧代码只会报出「data为空」这一句废话。code=596是**账号级「服务未开通」**拒绝,与请求体、签名、Content-Type、RAM 权限均无关 (RAM 权限不足会返回 403/Forbidden,不是 596)。- 用户按提示点开后仍报 596,原因见下:开通入口与认证门槛。
开通门槛对照(官方文档原文)
| 服务 | 开通前提条件 | 个人实名认证可否 |
|---|---|---|
内容安全 1.0(green, 2018-05-09 — 本项目在用) |
需要账号完成企业认证(document_detail/69806.html,2026-07-23 更新) |
❌ 不可 |
内容安全增强版 2.0(green-cip, 2022-03-02) |
需要账号完成实名认证(document_detail/477720.html) |
✅ 可 |
另附两点排查结论:
- 用户点开的
https://www.aliyun.com/product/lvwang只是产品介绍页(现为「AI 安全护栏」聚合页), 点不出「一键开通 1.0」。1.0 的正确开通入口是:https://common-buy.aliyun.com/?commodityCode=cdi#/open。 - 但即使换到正确入口,个人认证账号依然会被「企业认证」前提卡住 → 596 不会消失。
三条可选路径
| 方案 | 代价 | 说明 |
|---|---|---|
| A. 改用「内容安全增强版 2.0」 | 🟠 需改代码(中等) | 个人认证即可开通。需换成 green-cip.<region>.aliyuncs.com + TextModeration API + 新 SDK(green-20220302),并重做风险标签映射(2.0 标签体系与 1.0 完全不同:pornographic_adult / inappropriate_profanity / pt_to_contact …),策略层 policy.go 的维度清单需同步重写。 |
| B. 把该账号升级为企业实名认证 | 需企业资质 | 代码零改动,升级后到正确入口开通 1.0 即可。 |
| C. 放弃阿里云通道,只用七牛 | 功能损失 | 置 text_censor_source = auto / qiniu。阿里云通道的原始用途是「七牛误报时的二次复核」,放弃后该能力消失。 |
📌 副产品结论:第三节第 2 项(SetContentType(requests.Json))已被证伪——它不是本次根因。
改动保留,但代码注释已改成「按接口契约对齐」而非「修 bug」,避免后人误解。
📌 副产品结论:第三节第 2 项(SetContentType(requests.Json))已被证伪——它不是本次根因。
改动保留,但代码注释已改成「按接口契约对齐」而非「修 bug」,避免后人误解。
一、报障现象
ERRO Failed to censor text censor_source=aliyun error="response data is empty"
ERRO Failed to censor text error="no censor source available" strictness=lax
两条同时出现 = 链上所有通道都没给出结论 → censor.Text 返回错误 → moderation.Check fail-closed 拒签。
🔴 严重性说明:fail-closed 是本项目既有设计,但推论是 —— 只要显式选定的审核通道坏掉(账号未开通 / 欠费停服 / RAM 权限不足),整站的「发布提问/回答 + 私信」会全部被拒,且现象只在日志可见,前端只显示「审核服务不可用」。这次报障正是这种形态。
二、根因取证
2.1 请求构造:不是本项目引入的问题(已验证)
| 比对对象 | 结论 |
|---|---|
上游 NekoWheel/NekoBox 的 internal/security/censor/aliyun.go |
与本仓 逐字节一致 |
阿里云官方 Go 示例(help.aliyun.com/document_detail/330839.htm) |
green.CreateTextScanRequest() + SetContent(json),body 形状 {"scenes":["antispam"],"tasks":[{"content":...}]} 一致 |
→ 请求的 URL、body 形状、签名流程都与官方参考实现相同,不是本次改造引入的偏差。
2.2 能产出该错误消息的唯一组合(已验证,走通 SDK 源码)
逐段读过 [email protected] 后,可以确定报出 response data is empty 必须同时满足:
| 条件 | 依据 |
|---|---|
| HTTP 状态是 2xx | sdk/responses/response.go:44 非 2xx 会被 Unmarshal 直接转成 ServerError → 走 errors.Wrap(err,"text scan"),不会是该消息 |
| 响应体是合法 JSON | AliyunTextCensorParser 的 json.Unmarshal 失败会报 unmarshal response |
顶层 data 为空数组 / 缺省 / null |
len(responseJSON.Data) == 0 才命中该分支 |
→ 即:阿里云回了「HTTP 200 + 合法 JSON + 无 data」。
顺带排除一个可疑点:green.EndpointMap 不含 cn-shanghai,但端点解析会一路回落到
GetEndpointRules → green.cn-shanghai.aliyuncs.com,地址是正确的。
2.3 顺带发现的确定缺陷(🟠 P1,非本次根因,但让根因不可见)
旧 parser 在 len(Data)==0 时只报 response data is empty,把顶层 code / msg / requestId 全部丢掉——
而这正是唯一能区分「签名错 / 权限不足 / 欠费 / 服务未开通 / 请求体不合契约」的线索。
这不是「阿里云用不了」的原因,但它是「为什么查不下去」的原因。
三、已落地改动
internal/security/censor/aliyun.go、internal/security/censor/policy_test.go
| # | 改动 | 性质 |
|---|---|---|
| 1 | 抽出 newTextScanRequest(text),便于测试断言请求形态 |
重构 |
| 2 | 显式 req.SetContentType(requests.Json) |
🟡 假设性修复,见下方说明 |
| 3 | 顶层 code != 0 && != 200 → 新分支 aliyun rejected the request: code=.. msg=.. request_id=.. raw=.. |
✅ 确定改进(与旧「data 为空」同为错误返回,上层控制流不变) |
| 4 | data 为空 / JSON 解析失败时,错误里带上 code/msg/requestId + 原始响应摘要(≤512 字符、按字符截断) |
✅ 确定改进 |
| 5 | Censor() 中 SDK 返回 err 时补 status + body;!IsSuccess() 分支同样带 status/body |
✅ 确定改进 |
| 6 | data[].code != 200(任务级失败)语义不变(仍 fail-closed 置 block),但在真正决定 fail-closed 处补一条 Warn(code/msg/taskId/requestId) |
✅ 确定改进 |
| 7 | 修掉 Censor() 上错误的文档链接(原指向七牛 7260 → 阿里云 70439) |
文档 |
| 8 | 新增 5 个测试:请求形态+Content-Type、顶层 code 拒绝、空 data、非法 JSON、摘要截断 | 测试 |
关于第 2 项(Content-Type):SDK 对 ROA 请求的默认回退值是 application/octet-stream
(sdk/auth/roa_signature_composer.go:88),而 /green/text/scan 的接口契约是 JSON 请求体。
该改动属按接口契约对齐(对已正常工作的环境无副作用)。
⚠️ 曾假设它是本例根因,已于 2026-09-10 15:50 证伪(真实根因是 code=596 未开通服务)。 保留理由仅为「显式声明正确类型优于依赖 SDK 兜底值」,代码注释已同步更正。
注意它必须在 client.TextScan 之前设置(Content-Type 参与 ROA 签名串)。
校验结果
gofmt -l internal/security/censor/:仅列出censor.go、text_test.go(升级前既有的空格缩进,按约定未动)go build -overlay=... ./...:通过go vet ./internal/security/...:通过go test ./internal/security/... ./internal/template/...:全绿
四、处置(已确认后)
- ⛔ (已作废,见文首「最终落地」) 原计划:到阿里云开通「内容安全 1.0」按量付费。 实际已确认个人实名认证账号无法开通 1.0(企业认证前提),故改为迁移到 2.0。
- ⚠️ 先恢复线上可用性:开通动作需要时间。在开通完成前,把
[security] text_censor_source改回auto(七牛优先)或qiniu,否则整站发布/私信会持续被 fail-closed 拒绝。 - 开通后切回
aliyun再验证一次;届时日志若仍报错,code/msg/requestId会直接给出下一步。
为同类问题补的防呆(已落地)
conf/app.sample.ini 的 text_censor_source 注释里补上了这段经验:
; ⚠️ 配好凭据 ≠ 可用:阿里云通道还必须在阿里云控制台开通
; 「内容安全 1.0」按量付费(https://www.aliyun.com/product/lvwang,需勾选协议点「立即开通」),
; 否则接口返回 code=596 "You have not opened Yundun Content Moderation Service",
; 审核必然失败 → fail-closed 拒绝全部提交(整站发不出提问/回答/私信)。
; 用 RAM 子账号调用时还需授予内容安全(Green)权限;欠费停服同理。
; 开通完成前建议先用 auto / qiniu,避免线上不可用。
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位。
五、待你决策
- 是否加「启动预检」(🟠 P2,现在证据更充分了):
text_censor_source显式选定某个通道时, 启动阶段发一次探活请求,失败则告警或直接拒启。- 收益:把「静默全站拒签」提前到启动时暴露——本次故障正是这个形态(用户是先发现发不出内容,才去看日志);
- 成本:按条计费产生一次调用 + 启动强依赖外部网络;
- 折中方案:不做网络预检,改为在首次出现账号级拒绝(如 596 / 403)时打一条一次性、带处置指引的显式告警
(复用
warnNoCensorCredentials的sync.Once模式)——零成本、无外部依赖。
- CHANGELOG 仍未记入(此前遗留):按项目惯例应在
dev/changelog-source.md顶部补条目并跑python dev/gen_changelog.py重新生成static/tamabox.changelogs.json。档位口径已定(strict + auto),可以写了。