From 8e2325eda559d06ea818b5ac77a03c2b6e7edb93 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 10 Sep 2026 18:04:19 +0800 Subject: [PATCH] aliyun sensor server --- .workbuddy/memory/2026-09-10.md | 295 ++++++++ .workbuddy/memory/MEMORY.md | 70 ++ conf/app.sample.ini | 26 + go.mod | 17 +- go.sum | 140 +++- internal/conf/conf.go | 42 ++ internal/conf/static.go | 41 +- internal/conf/text_censor_source_test.go | 55 ++ internal/db/users.go | 32 +- internal/form/user.go | 7 + internal/i18n/dict_en.go | 21 +- internal/i18n/dict_ja.go | 21 +- internal/i18n/dict_zh_cn.go | 21 +- internal/i18n/dict_zh_tw.go | 21 +- internal/route/chat/chat_handler.go | 43 +- internal/route/route.go | 3 + internal/security/censor/aliyun.go | 645 +++++++++++++++--- internal/security/censor/aliyun_test.go | 459 +++++++++++++ internal/security/censor/censor.go | 70 +- .../security/censor/censor_source_test.go | 51 ++ internal/security/censor/policy.go | 216 ++++++ internal/security/censor/policy_test.go | 287 ++++++++ internal/security/censor/qiniu.go | 53 +- internal/security/censor/text.go | 38 +- internal/security/moderation/moderation.go | 71 ++ internal/template/template.go | 13 + ...-censor-empty-data-diagnosis-2026-09-10.md | 235 +++++++ route/question/follow_up.go | 31 +- route/question/page.go | 37 +- route/question/question.go | 16 +- route/user/moderation.go | 44 ++ route/user/vote.go | 64 +- route/vote/vote.go | 30 +- templates/chat/conversation.html | 101 +-- templates/home.html | 6 +- templates/render_test.go | 95 ++- templates/user/moderation-strictness.html | 66 ++ templates/user/profile.html | 8 + 38 files changed, 3147 insertions(+), 344 deletions(-) create mode 100644 .workbuddy/memory/MEMORY.md create mode 100644 internal/conf/text_censor_source_test.go create mode 100644 internal/security/censor/aliyun_test.go create mode 100644 internal/security/censor/censor_source_test.go create mode 100644 internal/security/censor/policy.go create mode 100644 internal/security/censor/policy_test.go create mode 100644 internal/security/moderation/moderation.go create mode 100644 outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md create mode 100644 route/user/moderation.go create mode 100644 templates/user/moderation-strictness.html diff --git a/.workbuddy/memory/2026-09-10.md b/.workbuddy/memory/2026-09-10.md index a0c7f9a..9dfdd86 100644 --- a/.workbuddy/memory/2026-09-10.md +++ b/.workbuddy/memory/2026-09-10.md @@ -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` 里原本精确匹配 `