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
+295
View File
@@ -38,3 +38,298 @@
- 修订原则:**显式规则硬拦,算法推断只标记**。箱主自定义屏蔽词+高危三类(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` 跑通。
+70
View File
@@ -0,0 +1,70 @@
# TamaBox-v4 项目长期笔记
## 审计/失败分级惯例(沿用用户偏好)
- 结论分级:P0/P1/P2,配 🔴(阻断级)/🟠(高)。给修复建议前先做根因取证与并行诊断。
- 改代码前先出「方案/变更清单」获批准,再分批落地;用户倾向方案 A 或明确指定方向。
- 工作场景用 `@scene#N:"名称"` 标记(如 `@scene#15:"日常开发"`)。
- 用户常说「你先继续,后面我发现问题再和你说」→ 可自主推进,但重大问题仍要显式指出。
## 构建与测试(Windows 沙箱)
- **本机无 libwebp / GCC**,而 `chai2010/webp`(cgo)会挡住 `go build ./...`。
必须用 overlay 把 `internal/storage/s3.go` 换成 `dev/s3stub/s3_stub.go`:
```
go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
go test -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
```
(`dev/overlay.json` 里的路径指向 TAMAKY~1 短名,可能失效;用上面 Temp 里的那份。)
- `internal/db` 的测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`),
与业务改动无关,可跳过;`go vet` 已能覆盖其编译。
- **`proxy.golang.org` 在本沙箱返回 Bad Gateway**:拉新依赖 / `go mod tidy` 前先
`export GOPROXY=https://goproxy.cn,direct`。
- `internal/security/censor/censor.go`、`internal/security/censor/text_test.go` 存在**升级前就有**的
空格缩进(`gofmt -l` 会列出)。除非专门整理,否则不要顺手 gofmt,避免污染 diff。
## 内容审核架构(简洁版)
- 线上生效的审核通道是**七牛云**(`ai.qiniuapi.com/v3/text/censor`,scene=antispam);阿里云通道代码存在但 key 留空、不生效。
- **通道可选**:`[security] text_censor_source = qiniu | aliyun | auto`(留空同 auto=七牛优先、阿里云兜底,即历史行为)。
取值常量在 `conf`(`CensorSource*`),装配在 `censor.censorChain()`,启动校验在 `conf.validateTextCensorSource`。
显式选通道却缺凭据会**启动报错**(避免运行期 fail-closed 导致整站不可用)。
- 关键事实:七牛 `IsPass()` 只硬拦 politics/terrorism/porn,`review` 一律放行;
七牛文本审核**不支持白名单**、自定义词库需提工单 → 服务端「拦得准不准」不可控。
- 因此本项目把「是否拦截」从服务商 Pass 判定剥离,收敛到策略层:
`internal/security/censor/policy.go`(档位裁决)+ `internal/security/moderation/moderation.go`(唯一入口 `Check`)。
- **任何新增的文本审核调用点都必须走 `moderation.Check`**,不要再直接调 `censor.Text`,
否则会绕过「审核严格度」档位。三场景:提问/回答、投票、私聊。
- 语义约定:`LabelScore.Score` 统一归一化 0~1(七牛本身即 0~1;阿里云 2.0 无逐标签置信度,
按 `riskLevel` 折算 high=1 / medium=0.6 / low=0.3,该响应内所有标签共用同一分值)。
- 审核服务不可用时 **fail-closed**(拒绝提交)—— 这是既有行为,未经批准不要改成 fail-open。
⚠️ 推论:**显式选定的通道一旦坏掉(账号未开通/欠费/权限不足),整站发布+私信会全部被拒**,现象隐蔽(只在日志)。
### 阿里云 green 通道(`/green/text/scan`, 2018-05-09)
- 实现与上游 NekoBox 及阿里云官方 Go 示例**逐字节一致**;`TextScanRequest` 无独立 `Content` 字段,
body 走内嵌 `RoaRequest.Content`。**SDK 的 ROA 默认 Content-Type 是 `application/octet-stream`**
(`sdk/auth/roa_signature_composer.go:88`),现已显式 `SetContentType(requests.Json)` 对齐接口契约。
- 已知故障:HTTP 200 + `data` 为空 → `"response data is empty"`。**该错误此前会吞掉顶层 code/msg/requestId**
(唯一的根因线索),现已把 code/msg/requestId + 原始响应摘要(≤512 字符)写进错误。
✅ 2026-09-10 实证根因:`code=596 "You have not opened Yundun Content Moderation Service"` ——
阿里云回的是 **HTTP 200 + `success:false` + 无 data**,即「请求被拒也返回 200」。
⚠️ **硬门槛:「内容安全 1.0」开通前提是账号完成企业实名认证**(官方 69806),个人认证账号**永远开不了**。
开通入口是 `common-buy.aliyun.com/?commodityCode=cdi#/open`,不是产品介绍页 `aliyun.com/product/lvwang`。
替代方案:迁「内容安全增强版 2.0」(`green-cip`,个人认证可开,但标签体系完全不同、需改代码),
或退回 `auto`/`qiniu`。RAM 用户需 `AliyunYundunGreenWebFullAccess`;欠费停服同理。
- `data[].code != 200`(任务级失败)仍 fail-closed 置 block,但现在会打 Warn ——
否则「审核失败」与「真的违规」在日志里无法区分。
- ⚠️ `SetContentType(requests.Json)` 已加,但**已证伪与上述故障无关**,保留原因仅是接口契约对齐。
## 文件工具陷阱(重要)
- `templates/user/moderation-strictness.html`:**用 Write/Edit 写它会被自动注入 `data-page-node-id` 属性**
(每次一批新 id);**用 shell(sed)改则不会**。注入方是「文件工具触发的后处理钩子」,不是文件监听。
→ **该文件今后只用 shell 修改**。相关断言(`templates/render_test.go`)已改成不依赖属性顺序的正则。
- 往 gofmt 对齐组**中间**插 map 键,会把「拆分后剩余组」整组重新对齐 → 污染 diff。
新键应插在组末尾或独立成组(前后留空行)。i18n 字典已因此返工过一次。
## 仓库状态
- **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。
备份/回滚一律用 tar.gz(排除 `.git`、`.workbuddy`),放在项目同级目录:
`C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。