# 阿里云审核通道「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..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 源码) 逐段读过 `alibaba-cloud-sdk-go@v1.62.26` 后,可以确定报出 `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),可以写了。