aliyun sensor server

This commit is contained in:
tamakyi
2026-09-10 18:04:19 +08:00
parent e3e59e7c69
commit 8e2325eda5
38 changed files with 3147 additions and 344 deletions
@@ -0,0 +1,235 @@
# 阿里云审核通道「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),可以写了。