37 KiB
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 纳入硬拦),照做会放大误伤。
- 误伤两个真实成因(已代码复核):
- 七牛把正常内容判
abuse且suggestion=block→qiniu.go:55-68拒绝 → 提问/私聊直接发不出去; - 审核服务不可用时 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,七牛1;不归一化会让score是 0abuseScoreFloor=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,保持一致)。
待用户处理 / 决策
templates/user/moderation-strictness.html被注入data-page-node-id属性(全仓仅此文件有,备份/其它 40+ 模板均无 → 非项目约定,疑为页面编辑器标注)。它会污染生产 HTML,并让render_test.go的精确子串断言失败。我未擅自删除(可能属你侧编辑器状态);但已把断言改为正则selectedStrictnessOptions(),不再耦合属性顺序 → 现在两种情况下测试都稳。请确认是否要清掉这些属性。DefaultStrictness维持 strict 还是改 standard(见上一节):待拍板,一行常量。- CHANGELOG 未记入:新功能属用户可见变更,按项目惯例应在
dev/changelog-source.md顶部加条目并跑python dev/gen_changelog.py重新生成static/tamabox.changelogs.json。刻意留着等你定了档位口径再写文案(严格/标准会改变措辞)。
第三轮:用户三项指示
- 已删除
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()(正则),这样属性在不在都不会误报。
DefaultStrictness维持 strict(用户已拍板)。仅更新了policy.go的注释记录该决定与理由,代码不变。- 新增
[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 源码 + 比对官方文档,未凭猜测)
- 我们的
aliyun.go与上游 NekoBoxwuhan005/NekoBox的 aliyun.go 逐字节一致, 也与阿里云官方 Go 示例一致(green.CreateTextScanRequest()+SetContent(json),{"scenes":["antispam"],"tasks":[{"content":...}]})。→ 请求构造不是本项目引入的问题。 - 走通了 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。
- 顺带确认真实端点解析正确:
green.EndpointMap不含cn-shanghai,但会一路回落到GetEndpointRules→green.cn-shanghai.aliyuncs.com(正确)。 - 找到一处确定的代码缺陷(不是根因,但让根因不可见):旧 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 永久复现。
两个设计取舍(容易被后人误删,务必保留):
- 归一化响应带
api:"TextModeration"格式标记,解析器拒绝一切非该格式的载荷;censor.go阿里云缓存回放解析失败时退回实时调用(原来是直接把错误返回上层)。 作用:迁移前 1.0 落库的缓存既不会被误判成「通过」(那会成为绕过审核的后门), 也不会因 fail-closed 让这些历史文本在 31 天缓存过期前一直发不出去。 - 首次账号级失败(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 功能正常,但发现私聊页面被审核拦截后「无任何提示 + 输入框内容消失」。
根因(两处)
- 无提示:
templates/chat/conversation.html没挂{{template "base/alert" .}}, 导致SendMessage里所有SetErrorFlash(过长/被拉黑/审核拦截…)在这页都渲染不出来。 - 内容消失:审核失败走
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 idsecurity)。 - 「页面访问密码」一并迁入(原先在「用户设置」面板)。
- 旧位置完全移除,不留跳转提示。
改动
后端
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):- 档位表格:档位列
uk-table-shrink+white-space:nowrap、范围列uk-table-expand—— 修掉右侧长文案把 4 字档位名(畅所欲言/正常水平/不听坏话)挤成逐字折行。 - 选择区改三行:标题(显式
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)
.go139 文件 29,185 行,注释 3,102 行(10.6%);.html41 文件 7,784 行,注释 152 行(2.0%)。- 最重:
policy.go38.9%、conf/limits.go37.8%、shared/ip.go33.0%、conf/static.go27.8%、harassment.go25.2%、aliyun.go24.7%(152 行,绝对量最大)、text.go22.2%、censor.go21.3%、form.go19.8%、gzip.go19.3%、markdown.go18.1%、route.go17.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 必须删对方权威头。
⚠️ 复用方法(重要)
- 删注释会触发 gofmt 重排:删掉字段/映射项之间的注释后,相邻行合并成一个对齐组,
列宽会变。手算列宽易错 → 用脚本
gofmt_keep_crlf.py <files>: 跑 gofmt 后按原文件行尾还原 CRLF(直接gofmt -w会把 CRLF 转成 LF,造成全量 diff 污染)。 - 仓库行尾本就不统一(
policy.go/aliyun.go/text.go/dict_*/profile.html是 CRLF;limits.go/features.go/password.go是 LF)。改动前先确认,改后保持一致。 grep -c $'\r' file在 Git Bash 下不可靠($'\r'可能被当空模式,导致「全部行命中」)。 查行尾要用 Python 数b'\r\n'。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 误报。