Files
tamabox/.workbuddy/memory/2026-09-10.md
T

453 lines
37 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.
# 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 误报。