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

37 KiB
Raw Blame History

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 是 0100,七牛 score 是 01;不归一化会让 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 误报。