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

236 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阿里云审核通道「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),可以写了。