453 lines
37 KiB
Markdown
453 lines
37 KiB
Markdown
# 2026-09-10
|
||
|
||
## 负面情绪提问拦截方案调研(评审,未改代码)
|
||
|
||
- 盘点提问链路现有 5 道防线,结论:**无任何情绪/意图维度判定**,全部是字面/合规/可用性维度。
|
||
- 已验证的代码事实(后续复用):
|
||
- 文本审核走七牛 `POST https://ai.qiniuapi.com/v3/text/censor`,scene=`antispam`,10s 超时(`internal/security/censor/qiniu.go:85`)。
|
||
- `IsPass()` 只硬拦 `politics/terrorism/porn`,且 `review` 一律放行 → `abuse`(辱骂)实际漏过(`qiniu.go:55-68`)。
|
||
- `censor.go:50-53` 命中 31 天缓存后**重新用 parser 计算 Pass**,缓存不锁死判定 → 收紧 `IsPass()` 无需清缓存。
|
||
- 拉黑 `user_block` 只作用于私聊(`route/question/page.go:368` 在 `StartChat` 分支内),**不拦提问**。
|
||
- 项目无 at-rest 加密(grep aes/Encrypt 无结果);无队列/Worker,异步只有 `shared.SafeGo` 裸 goroutine;Redis 仅 session/cache。
|
||
- 有内存滑动窗口限流器 `internal/route/ratelimit.go`,可复用做单 IP 限流。
|
||
- `conf.Limits.QuestionContentLen` 默认 1000;`AnswerContentLen`/`FollowUpContentLen` 默认 2000(= 七牛文本 2000 字上限的边界)。
|
||
|
||
## 重要背景更正(用户指出)
|
||
|
||
- **图片/文本审核服务是七牛云,不是阿里云**。阿里云通道虽仍在 `censor.go` 代码中,但 `conf/app.sample.ini` 的 `aliyun_access_key` 为空,实际不生效。后续所有审核相关评估以七牛为准。
|
||
|
||
## 七牛云能力(官方文档已验证)
|
||
|
||
- 文本审核 label 9 类:normal/spam/ad/politics/terrorism/**abuse**/porn/flood/contraband/meaningless;suggestion: block/review/pass + score;≤2000 字;不支持白名单(拉黑词库要提交工单)。
|
||
- 七牛自有 LLM(MaaS):`https://api.qnaigc.com/v1`,兼容 OpenAI `/v1/chat/completions` 与 Anthropic `/v1/messages`,50+ 模型,**用独立 API Key(非 AK/SK)**。
|
||
|
||
## 产出
|
||
|
||
- `outputs/llm-moderation-feasibility-2026-09-10.md`:LLM 内容审核方案可行性评审(数据流/失败降级/配置校验/Key 安全/邮件告警/成本限额)。
|
||
- 4 个 P0:SSRF(用户自填 API 地址,连通性测试即探测入口)、Key 明文落库、用户 Key 被刷造成损失、失败即发信导致邮件轰炸。
|
||
- 核心建议:分层 L1 收紧七牛 `IsPass()`(零成本)→ L2 本地情绪词库 → L3 LLM 只补七牛覆盖不到的语义部分。
|
||
- 待用户确认 8 点,最关键:命中后是"提示+仍提交"还是"硬拦"。
|
||
|
||
## 追加修订(用户实测反馈)
|
||
|
||
- **用户反馈:七牛对「辱骂」判定过严,正常对话经常误伤** → 撤回原 L1 建议(把 abuse 纳入硬拦),照做会放大误伤。
|
||
- 误伤两个真实成因(已代码复核):
|
||
1. 七牛把正常内容判 `abuse` 且 `suggestion=block` → `qiniu.go:55-68` 拒绝 → 提问/私聊直接发不出去;
|
||
2. **审核服务不可用时 fail-closed 拒绝**(`page.go:574`、`chat_handler.go:258`)→ 七牛抖动期间全站发不出内容。
|
||
- 关键约束:七牛文本审核**不支持白名单**、自定义词库需提交工单 → 无法调准也无法调松,**唯一可控的是"命中后怎么办"**。
|
||
- 修订原则:**显式规则硬拦,算法推断只标记**。箱主自定义屏蔽词+高危三类(politics/terrorism/porn)硬拦;abuse/spam/ad/flood/contraband/meaningless 只打标记照常送达;审核不可用改 fail-open+标记待审。
|
||
- 落地:questions/messages 加标记列(AutoMigrate 自动加列)+ 后台折叠/筛选 + 设置页「审核严格度」三档(严格/宽松(默认)/仅标记)+ 用 `detail.Score` 自建阈值(默认偏高)。
|
||
- 该路线零延迟零外部依赖,顺带消掉 SSRF/Key 明文/Key 被刷/邮件轰炸 4 个 P0。
|
||
|
||
---
|
||
|
||
## 「审核严格度」实现(已落地,构建/vet/测试全绿)
|
||
|
||
**备份(回滚锚点)**:`C:/OtherFiles/Github/TamaBox-v4-mainline-pre-moderation-strictness-20260910.tar.gz`
|
||
(git 仓库损坏,用户指示「不要动 git」,改用 tar.gz;304 条目,sha256 `c6cfce2b...`)。回滚 = 解压覆盖回工作目录。
|
||
|
||
**核心设计:把「是否拦截」从审核服务自身的 Pass 判定中剥离**,新增策略层 `internal/security/censor/policy.go`。
|
||
- 档位:`lax`(仅底线)/ `standard`(底线 + abuse≥0.9 + contraband)/ `strict`(底线 + 全部非 normal 维度不限分 + 采信服务 block 建议)。
|
||
- 底线 `baselineBlockLabels`:politics / terrorism / porn / **customized**;任何档位无条件拦截、不可关闭(`Decide` 中排第一、无条件 return)。
|
||
- 判定顺序固定:底线 → 档位维度(带分数门槛)→ 服务建议(仅 strict)。
|
||
- 缺省 `DefaultStrictness = strict`;非法值 `ParseStrictness` 一律回退 strict(保守)。
|
||
|
||
**⚠️ 重要事实修正(本次发现)**:strict 档 **并非**「与升级前等价」,而是 **⊇ 升级前**。
|
||
- 升级前拦截条件 = `!Pass` = (politics|terrorism|porn) 或 服务 suggestion==block(见备份 `route/question/page.go:443`)。
|
||
- strict 额外把 abuse/spam/ad/flood/contraband/meaningless/harmful 全部纳入(不限置信度)→ 比升级前更严。
|
||
- 已把散落各处「升级不改行为 / 行为等价」的错误注释全部改正(policy.go / moderation.go / conf/static.go / app.sample.ini / db/users.go / i18n 文案)。
|
||
- **待用户拍板的取舍**:DefaultStrictness 是否维持 strict。若用户首要诉求是「减少误伤」,应改为 standard(一行常量);维持 strict 则部署后默认拦截面略扩。
|
||
|
||
**阿里云兼容(用户本轮要求,已完成)**:
|
||
- **rate 量纲 bug(关键)**:阿里云 `rate` 是 0~100,七牛 `score` 是 0~1;不归一化会让 `abuseScoreFloor=0.9` 在阿里云下恒成立、standard 档分级失效。已加 `normalizeAliyunRate()`(>1 则 /100,clamp 0~1)。
|
||
- 补映射 `harmful→ForbiddenTypeHarmful`(覆盖用户最初想要的「负面情绪」,仅在 strict 档生效)、`customized→ForbiddenTypeCustomized`(进底线)。
|
||
- 未映射 label 不进 `Details`(无法按维度裁决);`results` 为空时 `ProviderSuggestion=block`(对齐 fail-closed)。
|
||
- 结论:**可直接复用,无需改调用方**;只需上述 parser 层归一化/映射,三家通道(七牛/阿里云)共用同一 `Decide`。
|
||
|
||
**统一入口**:`internal/security/moderation/moderation.go`
|
||
- `Check(ctx, strictness, lang, text)` = 调审核 → `censor.Decide` → 返回 err(本地化)。审核不可用仍 fail-closed。
|
||
- `StrictnessFor(raw)` = 账号设置优先,空则跟随 `conf.Security.DefaultModerationStrictness`,再回退 strict。
|
||
- 三场景入口已全部改走 `moderation.Check`(grep 可复核):
|
||
- 提问/回答:`route/question/page.go:441,574`、`question.go:170`、`follow_up.go:105,164`
|
||
- 投票:`route/vote/vote.go:72`、`route/user/vote.go:67,159,1012`
|
||
- 私聊:`internal/route/chat/chat_handler.go:259`(用 `pageUser`=箱主,非访客)
|
||
|
||
**改动文件清单**:
|
||
- 新增:`internal/security/censor/policy.go`、`policy_test.go`、`internal/security/moderation/moderation.go`、`route/user/moderation.go`、`templates/user/moderation-strictness.html`
|
||
- 修改:`censor/{text,qiniu,aliyun}.go`、`conf/static.go`、`db/users.go`、`form/user.go`、`internal/route/route.go`、`route/question/{page,follow_up,question}.go`、`internal/route/chat/chat_handler.go`、`route/vote/vote.go`、`route/user/vote.go`、`i18n/dict_{zh_cn,zh_tw,en,ja}.go`、`templates/user/profile.html`、`templates/render_test.go`、`conf/app.sample.ini`
|
||
- 设置页表单分别挂在 profile 的 提问/私聊/投票 三个面板。
|
||
|
||
**验证方式(Windows 沙箱无 libwebp/gcc,需 overlay)**:
|
||
```
|
||
go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
|
||
go test -overlay="..." ./internal/security/... ./internal/i18n/... ./templates/... ./route/... ./internal/route/...
|
||
```
|
||
结果:build 0、`go vet ./...` 0、上述测试全 ok。新增 6 个阿里云/策略用例(normalizeAliyunRate / AliyunParserFeedsPolicy / CustomizedHitsBaseline / NoResultsFailsClosed / UnmappedLabelSkipped)。
|
||
注:`internal/db` 测试需真实 DB,环境缺失,与本次改动无关;`censor.go`、`text_test.go` 存在**升级前就有**的空格缩进(gofmt -l 会列出),未动以避噪音。
|
||
|
||
---
|
||
|
||
## 第二轮:改动回归审阅(与备份全量 diff 逐项复核)
|
||
|
||
方法:把备份 tar.gz 解到 `%TEMP%/tb-review/base`,对工作区做 `diff -rq` 拿到精确变更集,再逐项查 bug。
|
||
**结论:无 P0(无安全/正确性回归)**。关键安全性质已核实:strict 档拦截条件 **⊇ 升级前**,故默认档下不可能出现「以前拦、现在放」。`censor.Text` 的 31 天缓存命中后**重新走 parser**(`censor.go:50/61`),`Details`/`ProviderSuggestion` 会被重建,不存在「缓存命中导致档位判定失效」。`EnableTextCensor=false` 时 parser 返回 `{Pass:true}` 且 Details 为空 → `Decide` 放行,开发环境不受影响。
|
||
|
||
### 已修(本轮)
|
||
|
||
- **🔴 P1 设置页档位回显错误(真实 bug)**:`moderation_strictness` 为空(存量账号,语义=跟随站点默认)时,模板三个 `option` 的 `eq` 全为假 → 浏览器默认选中第一个选项「宽松」,而实际生效是站点默认(通常 strict)。界面显示的档位与真实拦截行为**相反**,直接违背「让用户在切换前就知道会被拦什么」的需求。
|
||
- 修法:新增模板函数 `moderationStrictness`(`internal/template/template.go`,内部调 `moderation.StrictnessFor`,与 policy 同源),模板改用 `$current := moderationStrictness $root.LoggedUser.ModerationStrictness` 回显。
|
||
- 未用「在 Profile handler 注入 data key」的方案:全仓有 **16 处** `ctx.Success("user/profile")`(各 Update* 校验失败回显路径),漏一处就会让模板读到缺失 key。
|
||
- 补 `TestRenderProfileStrictnessReflectsEffectiveTier`(未设置/lax/standard/strict/非法值 5 例)。
|
||
- **🟠 P2 `censorVoteForPublish` 档位口径不一致**:同一函数内 `vote.Title` 用 `voteOwnerStrictness(ctx, vote.UserID)`,而 `Description`/选项/字段标签走 `censorCheck`(内部读 `ctx.User`)。当前因 `getOwnedVote` 强校验 `vote.UserID == ctx.User.ID` 而**不可达**(3 个调用路径 678/907/944 都归属校验过),属潜在缺陷。已改为函数开头解析一次 `strictness` 并全程复用。
|
||
- **🟠 P2 i18n 字典 diff 污染**:插入新 flash 键时切断了原有对齐组,导致 `admin.flash.deactivated`/`admin.export.errUser`/`admin.export.errQuestion` 三行在 4 个字典里被 gofmt 重新对齐(3 行×4 文件)。已把新键移到该组之后、独立成组,恢复原 3 行。**教训:往 gofmt 对齐组中间插键会把「拆分后的剩余组」重新对齐;应插在组末尾或独立成组。**
|
||
- **🟠 P2 死代码**:`TextCensorResponse.ErrorMessage()` 生产与测试**均无引用**(已被 `ErrorMessageFor` 取代),且其「取首个维度」的语义正是本轮要修的错误来源,留着易被误用 → 已删除。`BlockedLabels()` 仅测试使用,但注释声称「供设置页展示」→ 已改为如实描述(UI 目前用 i18n 静态文案)。
|
||
|
||
### 已核查为非问题(勿重复排查)
|
||
|
||
- `internal/cmd/censor.go:57,68` 仍直接调 `censor.Text`:这是 `tamabox censor` **回填 CLI**,只写 `content_censor_metadata` 审计字段、不做拦截放行判定,不属三场景提交闸门 → 无需改走 `moderation.Check`。
|
||
- 全仓已无「直接判 `resp.Pass` 决定放行」的残留(仅 `db/censor_log.go` 存字段)。
|
||
- `LoggedUser` 每个请求都由 `authenticatedUser()` 回源 `db.Users.GetByID`(`internal/context/auth.go:22`),保存后重定向读到的一定是新值,不存在「保存后下拉框还是旧值」。
|
||
- `route/user/moderation.go` 用 `ctx.User.ID` 且挂在 `reqUserSignIn` 组内(`internal/route/route.go:374`)→ 越权改他人档位不可行;与 `UpdateHarassment/UpdateBlockWords` 同构(同样无 rateLimit,保持一致)。
|
||
|
||
### 待用户处理 / 决策
|
||
|
||
1. **`templates/user/moderation-strictness.html` 被注入 `data-page-node-id` 属性**(全仓仅此文件有,备份/其它 40+ 模板均无 → 非项目约定,疑为页面编辑器标注)。它会**污染生产 HTML**,并让 `render_test.go` 的精确子串断言失败。我未擅自删除(可能属你侧编辑器状态);但已把断言改为正则 `selectedStrictnessOptions()`,不再耦合属性顺序 → 现在两种情况下测试都稳。**请确认是否要清掉这些属性。**
|
||
2. `DefaultStrictness` 维持 strict 还是改 standard(见上一节):待拍板,一行常量。
|
||
3. CHANGELOG 未记入:新功能属用户可见变更,按项目惯例应在 `dev/changelog-source.md` 顶部加条目并跑 `python dev/gen_changelog.py` 重新生成 `static/tamabox.changelogs.json`。**刻意留着等你定了档位口径再写文案**(严格/标准会改变措辞)。
|
||
|
||
---
|
||
|
||
## 第三轮:用户三项指示
|
||
|
||
1. **已删除** `templates/user/moderation-strictness.html` 的 `data-page-node-id` 属性(全仓无任何读取方,纯渲染噪声)。
|
||
- ⚠️ **重要机制发现**:**用 Write/Edit 工具写这个文件会被自动重新注入该属性**(且 id 每次是新的一批);用 **shell(sed)修改则不会被注入**。判断注入方是「文件工具触发的后处理钩子」,不是文件监听。→ **该文件今后一律用 shell 修改**,不要用 Write/Edit。
|
||
- 同时把 `render_test.go` 里原本精确匹配 `<option value="standard" selected>` 的断言改成不依赖属性顺序的 `selectedStrictnessOptions()`(正则),这样属性在不在都不会误报。
|
||
2. **`DefaultStrictness` 维持 strict**(用户已拍板)。仅更新了 `policy.go` 的注释记录该决定与理由,代码不变。
|
||
3. **新增 `[security] text_censor_source`** —— 此前通道链是硬编码的 `[qiniu, aliyun]`,无法选择。
|
||
- 取值:`qiniu` / `aliyun` / `auto`(**留空同 auto,即历史行为**:七牛优先、未通过则阿里云二次复核)。
|
||
- 常量定义在 `conf`(`CensorSourceAuto/Qiniu/Aliyun`),供 `conf.Init` 校验与 `censor.censorChain()` 共用,避免两处字符串漂移。
|
||
- `conf.Init` 启动校验(纯函数 `validateTextCensorSource`,便于单测):
|
||
- 审核关闭 → 不校验;留空/auto → 通过(两者都没凭据时仍不阻断启动,否则 sample 原样拷贝无法启动);
|
||
- 显式选 qiniu/aliyun 但缺凭据 → **启动即报错**(否则运行期审核必然失败,而失败是 fail-closed,会让整站发布/私信不可用且现象隐蔽);
|
||
- 其它取值 → 报错,**不静默回退**(拼错若被当成 auto,运维会误以为已只用七牛)。
|
||
- `censor.Text`:循环改为 `for _, censor := range censorChain()`;新增一次性告警 `warnIfNoCensorCredentials`(sync.Once,兜 auto 无凭据的漏网情况,避免按请求刷屏)。
|
||
- 顺带把原「HACK: Qiniu's API response is not accurate…」注释改写为如实描述链式复核语义,并说明 qiniu-only 时 `continue` 不会丢判定。
|
||
- 文档:`conf/app.sample.ini` 在 `enable_text_censor` 下新增带注释的 `text_censor_source`。
|
||
- 测试:新增 `internal/conf/text_censor_source_test.go`(12 例)、`internal/security/censor/censor_source_test.go`(6 例),全绿。
|
||
|
||
---
|
||
|
||
## 第四轮:阿里云通道「response data is empty」排查
|
||
|
||
用户报障日志:
|
||
```
|
||
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
|
||
```
|
||
(两条同时出现 = 两个通道都没给出结论 → fail-closed 拒签,即整站发布不可用。)
|
||
|
||
### 根因取证(读 SDK 源码 + 比对官方文档,未凭猜测)
|
||
|
||
1. 我们的 `aliyun.go` 与**上游 NekoBox `wuhan005/NekoBox` 的 aliyun.go 逐字节一致**,
|
||
也与**阿里云官方 Go 示例一致**(`green.CreateTextScanRequest()` + `SetContent(json)`,
|
||
`{"scenes":["antispam"],"tasks":[{"content":...}]}`)。→ 请求构造不是本项目引入的问题。
|
||
2. 走通了 SDK 调用链,确认「HTTP 2xx + 合法 JSON + `data` 为空」是唯一能产出该错误的组合:
|
||
- `sdk/responses/response.go:44` 非 2xx 会被 `Unmarshal` 直接转成 `ServerError` → 走
|
||
`errors.Wrap(err, "text scan")`,不会是这条消息;
|
||
- `response.go:58` 只有 `AcceptFormat == JSON` 才尝试解析进 `TextScanResponse`(空结构体,必然成功);
|
||
- 我们的 parser 用的是 `resp.GetHttpContentBytes()`,body 非 JSON 会报 `unmarshal response`。
|
||
3. 顺带确认真实端点解析正确:`green.EndpointMap` **不含 `cn-shanghai`**,但会一路回落到
|
||
`GetEndpointRules` → `green.cn-shanghai.aliyuncs.com`(正确)。
|
||
4. 找到一处**确定的代码缺陷**(不是根因,但让根因不可见):旧 parser 在 `len(Data)==0` 时
|
||
只报 `response data is empty`,把顶层 `code` / `msg` / `requestId` 全丢了 → 用户拿不到任何线索。
|
||
|
||
### 已落地改动(`internal/security/censor/aliyun.go` + `policy_test.go`)
|
||
|
||
- 抽出 `newTextScanRequest(text)`(便于测试断言请求形态);**显式 `req.SetContentType(requests.Json)`**。
|
||
SDK 的 ROA 默认值是 `application/octet-stream`(`roa_signature_composer.go:88`)。此改动属
|
||
**按接口契约对齐**(对已正常工作的环境无副作用),**是否为本例根因未证实**,以下一次日志为准。
|
||
- 顶层 `code != 0 && != 200` → 全新分支 `aliyun rejected the request: code=.. msg=.. request_id=.. raw=..`
|
||
(与旧「data 为空」同为错误返回,控制流/语义不变)。
|
||
- `data` 为空 / JSON 解析失败 / 请求体回显:错误里带上 `code/msg/requestId` 与原始响应摘要
|
||
(`aliyunRawDigest`,按字符截断 ≤512,避免切坏多字节)。
|
||
- `Censor()` 里 SDK 返回 err 时补 `status` + `body`;`!IsSuccess()` 分支同样带 status/body。
|
||
- `data[].code != 200` 仍 fail-closed 置 block(**语义未改**),但在真正决定 fail-closed 的位置补了
|
||
一条 Warn(code/msg/taskId/requestId)——否则「审核失败」会伪装成「命中违规」而无迹可循。
|
||
- 顺手修掉 `Censor()` 上错误的文档链接(原指向七牛文档 → 改为阿里云 70439)。
|
||
- 新增测试:`TestNewTextScanRequestShape`、`TestAliyunRejectedRequestSurfacesCodeAndMsg`、
|
||
`TestAliyunEmptyDataErrorCarriesRawResponse`、`TestAliyunInvalidJSONErrorCarriesRawResponse`、
|
||
`TestAliyunRawDigestTruncates`。`gofmt`/`go build`(overlay)/`go vet`/`go test ./internal/security/...` 全绿。
|
||
|
||
### 下一步(等用户)
|
||
|
||
重新部署后重试一次,新日志会直接显示阿里云返回的 `code/msg/requestId` 与原始响应。
|
||
账号侧首要排查项:①内容安全 1.0 是否已开通按量付费;②RAM 子账号是否有 Green 权限;③是否欠费停服;
|
||
④如需跨区可在 `getClient()` 写死 `cn-shanghai` 之外调整(该处目前硬编码)。
|
||
|
||
### 待决策
|
||
|
||
- 「显式选定通道坏掉 → 整站 fail-closed 不可用」是否要加**启动预检**(启动时发一次探活请求,
|
||
失败则告警/拒启)。成本:按条计费 + 启动依赖外部网络;收益:把「静默全站拒签」提前暴露。
|
||
|
||
### ✅ 根因已确认(同日 15:50,用户回传新日志)
|
||
|
||
诊断改动生效后一条日志即定案:
|
||
|
||
```
|
||
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.0」**(账号侧,非代码缺陷)。
|
||
- 关键实证:响应体里 `httpStatusCode: 200` + `success: false` + 无 `data` → **阿里云「请求被拒也返回 HTTP 200」**,
|
||
所以旧代码只会报「data 为空」这句废话。这一条坐实了第二节的推断。
|
||
- **`SetContentType(requests.Json)` 被证伪**,不是根因;保留但已把代码注释改为「接口契约对齐」而非「修 bug」。
|
||
- 处置:开通 https://www.aliyun.com/product/lvwang(勾选协议点「立即开通」);RAM 子账号需 Green 权限;
|
||
**开通完成前先切回 `auto`/`qiniu` 恢复线上可用性**。
|
||
- 防呆:`conf/app.sample.ini` 的 `text_censor_source` 注释补上「配好凭据 ≠ 可用」+ code=596 的完整说明。
|
||
- 报告已更新为确认版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`。
|
||
- 仍待决策:①启动预检(或折中=首次账号级拒绝时打一次性带处置指引的告警);②CHANGELOG 条目。
|
||
|
||
### ✅✅ 最终根因(同日 16:05,用户开通后仍报 596)
|
||
|
||
用户按我给的地址开了「AI 安全护栏」,仍报同样的 `code=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`) | ✅ 可开 |
|
||
|
||
- 1.0 真正开通入口:`https://common-buy.aliyun.com/?commodityCode=cdi#/open`;
|
||
`aliyun.com/product/lvwang` 只是产品介绍页(现为「AI 安全护栏」聚合页),点不出「一键开通」。
|
||
- 所以:**个人实名认证账号 + 1.0 API = 596 永久复现**,与 RAM 权限无关
|
||
(权限不足返回 403/Forbidden,不是 596;596 是「服务未开通」)。
|
||
- 已修正 `conf/app.sample.ini` 的注释(换成正确入口 + 写明企业认证前提 + 三条出路)。
|
||
- 报告更新为最终版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`(含 A/B/C 三方案对比表)。
|
||
- 三条出路:**A** 迁增强版 2.0(个人可开,但要换 `green-cip` + `TextModeration` + 新 SDK,**风险标签体系完全不同,`policy.go` 维度清单需重写**);
|
||
**B** 账号升级企业实名认证(代码零改动);**C** 放弃阿里云只用七牛(`auto`/`qiniu`,损失「七牛误报二次复核」能力)。
|
||
- 已就方向征询用户(AskUserQuestion)。
|
||
|
||
### ✅✅✅ 已落地:阿里云通道迁移到「内容安全增强版 2.0」
|
||
|
||
用户通过 AskUserQuestion 选定:**迁移增强版 2.0** + **加一次性告警**(即原「启动预检」的折中方案)。当日完成编码,构建/vet/测试全绿。
|
||
|
||
**⚠️ 关键事实修正(最容易踩的坑)**:
|
||
`green-20220302` SDK 中 `TextModerationResponseBodyData.Labels` 的内联注释列出的标签(10 个,且不含涉政)
|
||
**是旧的、不完整的**。以官方《文本审核增强版标签处理建议参考》(`practice_detail/465315`)为准,
|
||
2.0 通用服务**包含** `political_content`(涉政)、`violence`(暴恐)、`sexual_content`(色情)、
|
||
`C_customized`(自定义词库)→ **底线四件套可完整覆盖,`policy.go` 的 `baselineBlockLabels` 无需放宽**。
|
||
|
||
**2.0 标签 → ForbiddenType 映射**(`formatAliyunForbiddenType`):
|
||
`political_content`/`regional`→politics(底线);`violence`→terrorism(底线);`sexual_content`/`sexuality`→porn(底线);
|
||
`contraband`→contraband;`ad`/`PullinTraffic`/`ad_compliance`→ad;`profanity`→abuse;`religion`→harmful;
|
||
`negative_content`/`cyberbullying`→harmful;`nonsense`→meaningless;`spam`→spam;`C_customized`→customized(底线)。
|
||
⚠️ 2.0 无「灌水(flood)」维度(同类归入 spam),`ForbiddenTypeFlood` 不再由阿里云产生(七牛仍会返回)。
|
||
|
||
**其他关键事实**:
|
||
- 2.0 通用服务单次 `content` **硬上限 600 字符**;站点内容上限为 提问 1000 / 回答·追问·私聊 2000 /
|
||
投票描述 5000 字 → 超长必然超限,故**按 550 字分段、段间重叠 50 字**,标签取并集、风险级别取最高。
|
||
- 2.0 **不返回逐标签置信度**,只有整条内容的 `riskLevel` → 折算 high=1 / medium=0.6 / low=0.3,
|
||
该响应内所有标签共用同一分值。副作用:`abuseScoreFloor=0.9` 在阿里云下的实际语义变成
|
||
「标准档只拦 riskLevel=high 的辱骂」。
|
||
- 场景固定 `comment_detection`(公聊评论;官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流)。
|
||
- SDK:`alibabacloud-go/green-20220302/v3 v3.5.4`;`openapiutil.Config{RegionId:"cn-shanghai"}` 由 SDK 的
|
||
EndpointMap 解析出 `green-cip.cn-shanghai.aliyuncs.com`;`ReadTimeout` / `ConnectTimeout` **单位毫秒**,
|
||
直接设在 Config 上即可(不必用 RuntimeOptions)。
|
||
- SDK 错误经 `dara.TeaSDKError` 包成 `*dara.SDKError`(有 `GetCode()` 与 `StatusCode` 字段);
|
||
账号级故障用「结构化 code + 消息文本」双重识别(`aliyunIsAccountIssue`)。
|
||
- 2.0 开通前提:**仅需实名认证,个人账号可开通**(`document_detail/477720.html`);
|
||
1.0 才是「需企业实名认证」,个人账号 596 永久复现。
|
||
|
||
**两个设计取舍(容易被后人误删,务必保留)**:
|
||
1. 归一化响应带 `api:"TextModeration"` 格式标记,解析器拒绝一切非该格式的载荷;
|
||
`censor.go` 阿里云缓存回放解析失败时**退回实时调用**(原来是直接把错误返回上层)。
|
||
作用:迁移前 1.0 落库的缓存既不会被误判成「通过」(那会成为绕过审核的后门),
|
||
也不会因 fail-closed 让这些历史文本在 31 天缓存过期前一直发不出去。
|
||
2. 首次账号级失败(596 / Forbidden / InvalidAccessKeyId …)打**一条**带开通指引的 WARN
|
||
(`warnAliyunAccountIssueOnce` + `sync.Once`),之后静默。
|
||
|
||
**改动文件**:`internal/security/censor/aliyun.go`(整文件重写)、`aliyun_test.go`(新增测试套件)、
|
||
`censor.go`(缓存回退)、`text.go` / `policy.go`(注释同步)、`policy_test.go`(删除 1.0 格式旧测试)、
|
||
`conf/app.sample.ini` + `internal/conf/static.go`(改为 2.0 开通指引);
|
||
`go mod tidy` 已移除不再引用的 `github.com/aliyun/alibaba-cloud-sdk-go v1.62.26`。
|
||
|
||
**校验**:`go build -overlay=... ./...` 通过;`go vet ./internal/...` 通过;
|
||
`go test ./...` 仅 `internal/db` 失败(信息均为 `Unknown database type: ""`,需真实 PostgreSQL/MySQL,与本改动无关),
|
||
其余全绿(含 `internal/security/censor`)。
|
||
`gofmt -l` 仅列 `censor.go` / `text_test.go`(改动前既有的空格缩进,按约定未动)。
|
||
|
||
**报告已更新**:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md` 文首新增「最终落地」章节。
|
||
|
||
**仍需用户处理**:①到内容安全 2.0 控制台开通「内容审核增强版」按量付费,
|
||
RAM 子账号授予 `AliyunYundunGreenWebFullAccess`;②CHANGELOG 条目仍未记入
|
||
(`dev/changelog-source.md` + `python dev/gen_changelog.py`)。
|
||
|
||
---
|
||
|
||
## 第五轮:私聊审核拦截 UX 修复(用户实测反馈)
|
||
|
||
用户实测 2.0 功能正常,但发现**私聊页面被审核拦截后「无任何提示 + 输入框内容消失」**。
|
||
|
||
### 根因(两处)
|
||
|
||
1. **无提示**:`templates/chat/conversation.html` 没挂 `{{template "base/alert" .}}`,
|
||
导致 `SendMessage` 里所有 `SetErrorFlash`(过长/被拉黑/审核拦截…)在这页都渲染不出来。
|
||
2. **内容消失**:审核失败走 `SetErrorFlash` + `Redirect`,重渲染的 `ConversationPage`
|
||
从不回填 `content`,输入直接清空。
|
||
|
||
### 改动
|
||
|
||
- `internal/route/chat/chat_handler.go`:把 `ConversationPage` 拆出共享渲染函数
|
||
`renderConversation(ctx, domain, token, prefillContent, modErr)`;`ConversationPage` 改为其薄封装。
|
||
私聊审核失败分支由 `SetErrorFlash + Redirect` 改为 `renderConversation(..., content, err)`
|
||
——**就地重渲染**:`canSend` 为真时把 `prefillContent` 写入 `ctx.Data["Content"]`,
|
||
并把 `modErr` 写成 `ctx.Data["HasError"]/["Error"]`(与提问链路 `ctx.SetError` 同源)。
|
||
- `templates/chat/conversation.html`:①加 `{{template "base/alert" .}}`(容器开头);
|
||
②`<textarea>` 内填充 `{{ .Content }}` 回填已输入内容(html/template 自动转义,安全)。
|
||
|
||
### 验证(Windows 沙箱用 overlay 跑,仅 `internal/db` 因缺真实 DB 失败、与本次无关)
|
||
|
||
- `go build -overlay=... ./...` 0;`go vet ./internal/...` 0;
|
||
- `internal/route`、`internal/template`(模板解析)、`internal/conf` 全 ok;
|
||
- **审核严格度仍生效**:`TestDecideBaselineAlwaysBlocks` / `TestDecideByLevel` /
|
||
`TestDecideUnknownLevelFallsBackToStrict` / `TestBlockedLabelsIncludesBaselineAtEveryLevel` 全过
|
||
(底线四件套任何档位强制拦截、未知档回退 strict,未受 2.0 改动影响);
|
||
- **2.0 迁移未破坏**:`TestAliyunParser*` / `TestExtractAliyunChunkResult` / `TestMergeAliyunChunkResults`
|
||
/ `TestAliyunIsAccountIssue*` 全过。
|
||
|
||
### 备注
|
||
|
||
- 新增 `base/alert` 后,私聊页**其它** `SetErrorFlash` 提示(过长/被拉黑/每日上限…)现在也会显示;
|
||
但那些仍走 Redirect,输入内容仍会丢失。本次只修了用户反馈的审核拦截路径,其余校验失败保留内容属可后续优化项。
|
||
|
||
---
|
||
|
||
## 环境事实(用户更正,长期有效)
|
||
|
||
- **真实运行环境是 Debian**,不要为 Windows 相关问题花时间:本机沙箱才需要 `go build -overlay=...`
|
||
绕开 libwebp/gcc(cgo),Debian 生产/开发机 `go build ./...` 原生即可,不需要 overlay。
|
||
- 沙箱内若要用 overlay,注意 **overlay.json 的 Replace 键盘符必须小写**(`c:/...`),
|
||
大写 `C:/...` 会被 Go 规范化成 `c:\` 后匹配不上、报 `deleted in overlay`;stub 在
|
||
`dev/s3stub/s3_stub.go`。本次用 `C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-overlay2.json` 跑通。
|
||
|
||
---
|
||
|
||
## 第六轮:设置面板聚合(新增「防护设置」tab)+ 审核严格度排版修复
|
||
|
||
用户诉求:① 把防骚扰、屏蔽词、审核严格度、提问密码聚合到 `/user/profile` 下一个独立 tab;
|
||
② 审核严格度档位处「一个字就换行」很难看。
|
||
|
||
### 决策(AskUserQuestion 已确认)
|
||
|
||
- 新 tab 名称:**防护设置**(i18n 键 `admin.tabSecurity`,tab id `security`)。
|
||
- **「页面访问密码」一并迁入**(原先在「用户设置」面板)。
|
||
- 旧位置**完全移除,不留跳转提示**。
|
||
|
||
### 改动
|
||
|
||
**后端**
|
||
- `route/user/features.go`:新增 `ProfileTabSecurity = "security"`,并纳入 `ProfileTab()` 白名单
|
||
(不加入的话回跳会落到 account 面板)。
|
||
- `route/user/password.go`:`UpdateAskPassword` / `UpdatePagePassword` 的硬编码回跳
|
||
(`profileURLWithTab(ProfileTabQuestion)`、`"/user/profile"`)全部改为 `returnTab(ctx)`,
|
||
跟随表单携带的 `?tab=`。
|
||
|
||
**模板**
|
||
- `templates/user/profile.html`:tab 栏新增「防护设置」按钮;新增
|
||
`<div class="profile-panel" data-profile-panel="security">`,内含 防骚扰 / 屏蔽词 /
|
||
审核严格度 / 页面访问密码 / 提问密码 五个表单(action 均带 `?tab=security`)。
|
||
提问/私聊/投票面板中这些表单全部删除(**原先屏蔽词与审核严格度被重复渲染 3 份**)。
|
||
account 面板移除「页面访问密码」卡(原编号 7 让位,账号操作改为 7)。
|
||
JS 面板切换是全泛型(`[data-profile-tab]` / `[data-profile-panel]`),无需改动。
|
||
- `templates/user/moderation-strictness.html`(**只用 shell/脚本改,禁用 Write/Edit**):
|
||
1. 档位表格:档位列 `uk-table-shrink` + `white-space:nowrap`、范围列 `uk-table-expand`
|
||
—— 修掉右侧长文案把 4 字档位名(畅所欲言/正常水平/不听坏话)挤成逐字折行。
|
||
2. 选择区改三行:标题(显式 `display:block`)→ 下拉(独立 div)→
|
||
「未设置时跟随站点默认档位。」(独立 div)。
|
||
|
||
**i18n**:四语字典新增 `admin.tabSecurity`(防护设置 / 防護設置 / Protection / 防護設定),
|
||
插在「功能管理」组之后的**独立组**,未切开任何既有对齐组(避免 gofmt 重排污染 diff)。
|
||
|
||
### 验证
|
||
|
||
- `go test ./templates/`(含 profile 渲染、严格度回显断言 `selectedStrictnessOptions`)ok;
|
||
`./internal/i18n/` ok(四语键一致)。
|
||
- `go build` + `go vet` `./route/user/` 均 0。
|
||
- ⚠️ **gofmt 提醒(重要,易误判)**:`gofmt -l` 会列出**所有 CRLF 文件**(含本次完全没碰的
|
||
`route/user/moderation.go`、`vote.go`),因为 gofmt 把 CRLF 规范成 LF。
|
||
剥离 CR 后比对,本次改动**零格式差异** → 格式是对的。
|
||
**切勿跑 `gofmt -w`**,那会把文件转成 LF、造成全量 diff 污染。
|
||
|
||
---
|
||
|
||
## 第七轮:精简注释(删复述、留 WHY)
|
||
|
||
用户反馈「注释太多」,要求仅保留重要部分。原则(AskUserQuestion 已确认):
|
||
**保守尺度** —— 删除复述型/装饰型,保留全部安全约束、踩坑、外部服务怪癖,长注释压成 1-3 行。
|
||
|
||
### 摸底数据(脚本 `comment_survey.py`,排除 outputs/dev/.workbuddy)
|
||
|
||
- `.go` 139 文件 29,185 行,注释 3,102 行(10.6%);`.html` 41 文件 7,784 行,注释 152 行(2.0%)。
|
||
- 最重:`policy.go` 38.9%、`conf/limits.go` 37.8%、`shared/ip.go` 33.0%、`conf/static.go` 27.8%、
|
||
`harassment.go` 25.2%、`aliyun.go` 24.7%(152 行,绝对量最大)、`text.go` 22.2%、`censor.go` 21.3%、
|
||
`form.go` 19.8%、`gzip.go` 19.3%、`markdown.go` 18.1%、`route.go` 17.8%(80 行)。
|
||
|
||
### 第一批已完成(-106 行,-31%)
|
||
|
||
| 文件 | 原 | 现 | 减 |
|
||
|---|---|---|---|
|
||
| `policy.go` | 84 | 52 | -32(-38%)|
|
||
| `conf/limits.go` | 45 | 25 | -20(-44%)|
|
||
| `shared/ip.go` | 58 | 32 | -26(-45%)|
|
||
| `aliyun.go` | 152 | 124 | -28(-18%,**仅处理到前 180 行,余下待续**)|
|
||
|
||
保留的关键信息(勿再删):fail-closed 语义、严格档 ⊇ 改造前、底线维度不可关、
|
||
阿里云 596/600 字上限与分段重叠、格式标记防历史缓存误判「通过」、SDK 标签表不可信、
|
||
IPv6 /64 聚合仅限流不可用于黑名单、源站未锁可被伪造头、双 CDN 必须删对方权威头。
|
||
|
||
### ⚠️ 复用方法(重要)
|
||
|
||
1. **删注释会触发 gofmt 重排**:删掉字段/映射项之间的注释后,相邻行合并成一个对齐组,
|
||
列宽会变。手算列宽易错 → 用脚本 `gofmt_keep_crlf.py <files>`:
|
||
**跑 gofmt 后按原文件行尾还原 CRLF**(直接 `gofmt -w` 会把 CRLF 转成 LF,造成全量 diff 污染)。
|
||
2. **仓库行尾本就不统一**(`policy.go`/`aliyun.go`/`text.go`/`dict_*`/`profile.html` 是 CRLF;
|
||
`limits.go`/`features.go`/`password.go` 是 LF)。改动前先确认,改后保持一致。
|
||
3. `grep -c $'\r' file` 在 Git Bash 下**不可靠**(`$'\r'` 可能被当空模式,导致「全部行命中」)。
|
||
查行尾要用 Python 数 `b'\r\n'`。
|
||
4. `templates/user/moderation-strictness.html` 仍**只能用 shell/脚本改**(Write/Edit 会注入
|
||
`data-page-node-id`)。
|
||
|
||
### 验证
|
||
|
||
`go build ./internal/security/... ./internal/shared/... ./internal/conf/...` 通过;
|
||
`go test ./internal/security/censor/` ok(严格度与 2.0 用例全过,确认删注释未影响行为)。
|
||
|
||
---
|
||
|
||
## 续:注释清理第二批 + 编译卡死根因(晚间)
|
||
|
||
### 第二批已清理(本轮继续)
|
||
|
||
`conf/static.go`、`conf/conf.go`、`security/censor/{text,censor,aliyun}.go`、`route/{gzip,route}.go`、
|
||
`form/form.go`、`template/markdown.go`、`db/{db,feature_control}.go`、`captcha/math.go`、
|
||
`route/user/harassment.go`、`route/user/vote.go`(最大,~149→少,N+1→2SQL、sanitizeCSVCell、BOM 等 WHY 保留)、
|
||
`db/follow_ups.go`(部分,待构建释放后收尾)、`security/censor/policy.go`(最高比 28%→压缩,保 WHY)、
|
||
`i18n/i18n.go`(删内联复述、修 typo)。
|
||
|
||
### 编译卡死根因(已定位并绕过)
|
||
|
||
- 真因:`proxy.golang.org` 在本沙箱 `HTTP=000`(连接超时),`go build` 触网即无限等待 → 表象「卡死」。
|
||
`goproxy.cn` → 200。解法:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + **定向包构建**(勿 `./...`)。
|
||
- `go env -w` 失败(`%AppData% is not defined`),无法持久化,只能每条命令 export。
|
||
- 重活儿构建(`./route/user/ ./internal/route/ ./internal/db/`,overlay 换 s3 stub 避 cgo/webp)已用上述代理跑通、进行中(1C1G 首编 20+ 分钟)。
|
||
|
||
### 行尾结论
|
||
|
||
`core.autocrlf=true` → 工作区 CRLF、blob LF,Git 归一,**无 diff 污染**,不必手动转行尾。
|
||
`gofmt_keep_crlf.py` 重建到 `dev/gofmt_keep_crlf.py`(Temp 版被清),保留工作区 CRLF 以免 gofmt -l 误报。
|
||
|