Files
tamabox/outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md
T
2026-09-10 18:04:19 +08:00

15 KiB
Raw Blame History

阿里云审核通道「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

两个刻意的设计取舍

  1. 格式标记 + 缓存回退:解析器拒绝一切非 api: "TextModeration" 的载荷,censor.go 在解析失败时退回一次实时调用。作用:迁移前 1.0 落库的缓存(若有)既不会被误判成 「通过」(那会变成绕过审核的后门),也不会因 fail-closed 让这些历史文本在 31 天缓存 过期前一直发不出去。
  2. 一次性账号级告警:首次出现 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(改动前既有的空格缩进,按约定未动)

仍需你处理

  1. 在阿里云内容安全 2.0 控制台开通「内容审核增强版」按量付费;RAM 子账号授予 AliyunYundunGreenWebFullAccess。开通前:auto 模式只走七牛;aliyun 模式仍会 fail-closed。
  2. CHANGELOG 尚未记入(见文末第五节)。

〇、结论速览(诊断阶段,2026-09-10 16:05)

✅ 根因(两层)

  1. 直接原因:阿里云账号未开通「内容安全 1.0」 → code=596。
  2. 深层原因:「内容安全 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}"

三点关键信息:

  1. httpStatusCode: 200 + success: false + 无 data —— 精确印证了第二节的推断: 阿里云在请求被拒时依然返回 HTTP 200,所以旧代码只会报出「data 为空」这一句废话。
  2. code=596 是**账号级「服务未开通」**拒绝,与请求体、签名、Content-Type、RAM 权限均无关 (RAM 权限不足会返回 403/Forbidden,不是 596)。
  3. 用户按提示点开后仍报 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. ⛔ (已作废,见文首「最终落地」) 原计划:到阿里云开通「内容安全 1.0」按量付费。 实际已确认个人实名认证账号无法开通 1.0(企业认证前提),故改为迁移到 2.0。
  2. ⚠️ 先恢复线上可用性:开通动作需要时间。在开通完成前,把 [security] text_censor_source 改回 auto(七牛优先)或 qiniu,否则整站发布/私信会持续被 fail-closed 拒绝。
  3. 开通后切回 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 便于定位。

五、待你决策

  1. 是否加「启动预检」(🟠 P2,现在证据更充分了):text_censor_source 显式选定某个通道时, 启动阶段发一次探活请求,失败则告警或直接拒启。
    • 收益:把「静默全站拒签」提前到启动时暴露——本次故障正是这个形态(用户是先发现发不出内容,才去看日志);
    • 成本:按条计费产生一次调用 + 启动强依赖外部网络;
    • 折中方案:不做网络预检,改为在首次出现账号级拒绝(如 596 / 403)时打一条一次性、带处置指引的显式告警 (复用 warnNoCensorCredentials 的 sync.Once 模式)——零成本、无外部依赖。
  2. CHANGELOG 仍未记入(此前遗留):按项目惯例应在 dev/changelog-source.md 顶部补条目并跑 python dev/gen_changelog.py 重新生成 static/tamabox.changelogs.json。档位口径已定(strict + auto),可以写了。