Files
tamabox/.workbuddy/memory/2026-10-18.md
T
2026-10-19 01:11:28 +08:00

462 lines
34 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-10-18
## 运营侧内容治理:饭圈黑话拉踩/挂人问题(诊断 + 方向)
用户观察到部分箱主页面出现拉踩、黑话辱骂、挑动群体对立(样例 `/_/52mxtx1314`,85 条问答,
内容为「避雷/壁垒/挂人 + 圈内代称」,如咸菜坤/县城坤/猪肝/澄毒/忘羡姐/端水洁癖/蟹脚coser)。
### 已核实的事实(代码侧)
- `moderation.Check` 已覆盖提问入口(`route/question/page.go:441`)、回答、私聊、投票、追问;七牛只硬拦
politics/terrorism/porn,`review` 一律放行 → **通用审核对黑话零命中**,样例文本无一个传统脏字。
- 箱主自建屏蔽词上限:`conf.Limits.MaxBlockWords=10`、`MaxBlockWordLen=10`(`internal/conf/limits.go`),
对「一人一个代称」的场景容量不匹配。
- **全站无举报入口**:`grep Report` 无结果;处置能力只有箱主侧 Delete / SetPrivate / SetPublic。
- 现有成本控制:30 次/分钟限流、数学验证码、同 IP+同内容重复拦截(`shared.DuplicateGuard`)、
`harassment_setting` 的 register_only 档。`autoban` 只针对后台路径扫描,不涉及内容行为。
- `Question.FromIP` 已落库但**未被任何运营/聚合逻辑消费**。
- 单题页/提问箱页已全局 noindex(`internal/context/context.go:243` 的非公开页分支)→ SEO 不是主要传播面,
扩散主要靠截图外传。
### 关键判断
根因是**激励结构**而非审核强度:匿名零身份 + 提问进后台默认不公开 + 箱主逐条回答后公开(样例中连「?」这类
单字回答也算)+ 页面可分享。攻击者收益是「被朗读、被围观」,不是「发出去」。因此加词库/调最严档
投入产出极低且放大误伤。方向按三层:结构层降低收益(已回答≠自动公开、含第三方指涉的投稿默认私密)、
成本层用行为信号(来源配额、同 token 聚合)替代词库、处置层补举报-下架-按来源封禁并留痕。
本次仅给方向,未改代码。下一步待用户选定:出具体变更清单,或先落「举报 + 批量处置」最小闭环。
## 追加:箱主主动引导投稿(处罚口径讨论)
用户澄清:本例是**箱主引导用户投稿挂人**,不是被动受害;提议"禁用提问 N 天,多次累计直接封号"。
### 新核实的事实
- **单题分享卡是公开的一键外传通道**:`QuestionShareCard`(`route/question/share.go:792`)只要
`answer != "" && !IsPrivate && question.UserID == user.ID` 就渲染整张卡片(含箱主头像/背景/水印/二维码),
路由 `GET /_/{domain}/{questionID}/share-card` 在公开的 `Questioner` 分组内。挂人内容因此可以变成无痕图片流出站外。
- **首页/探索分发无任何信誉门槛**:`users.show_in_home` 由箱主自选,`GetShowInHome` / `GetByTag` /
`getRandomUsers` 只按 created_at 或随机排序,不查举报/处置记录。高互动挂人箱可直接获得平台曝光。
- **per-user 功能开关已存在**:`feature_controls`(user_id + feature + enabled + 时间窗 + quota),
`FeatureOpen` / `FeatureStateOf` 是唯一判断点 → 但它是**箱主自设**,`Save` 由本人调用,
若复用为处罚,箱主可自行 `enabled=true` 解除,语义也混淆。处罚需独立 "平台限制" 层,优先级高于用户设置。
- `User` 模型无任何限制/处罚字段;现有只有 Deactivate(自停用)、BanUserAndCleanup(物理删)、restore。
- 「公开可见 = 已回答」(`Questioner` 已挡私密/未回答),所以**「回答」这个动作同时是生产与公开**,处罚打这里最准。
### 立场(待用户确认)
- 赞同罚箱主:主动引导投稿属滥用产品组织围攻,不是内容治理能覆盖的。
- 反对罚「提问(收信)」:伤害来自「发布」,禁收信对已公开的 85 条无效,且给箱主"被平台打压"叙事。
正确梯子是从传播面往下收:停止分发 → 收窄公开 → 冻结发布(回答/公开)→ 停用页面 → 封禁。
- 反对「多次被禁自动封号」:本场景举报本身是攻击武器,自动级联等于给组织化举报装自动扳机。
必须"举报成立(带证据快照)+ 人工判定 + 可申诉 + 留痕"。
- 判据必须用行为指纹而非文本关键词:挂人内容占比、独立指涉对象数、处置后复发率。
- ⚠️ 本例箱主自述被"澄毒长期辱骂",可能是互殴一方 → 流程必须双向取证,否则单边处罚会激化对立。
## 追加:改为运营者人工裁定(用户倾向)
用户提出:被举报不自动封号,交由运营者本人判断。此方向认可,但补三条前提,否则会退化成"看心情处置":
1. 判定标准必须**先书面化**(什么算挂人:可识别第三方 + 贬损/号召规避/曝光主页 + 无公共利益),
否则同一人不同时间判出不同结果,比误封更伤公信力。
2. **证据包自动生成**(内容快照 + 来源 IP 聚合 + 该箱主历史处置 + 同 token 的关联条目),
人工只做裁决,不做取证的体力活。
3. 处置必须**留痕 + 可申诉 + 有冷静期**(举报后 24h 再判),这是平台履行"通知-删除"义务的自证材料;
自动封号反而更难自证。
分级矩阵(裁定 × 内容性质)作为处置口径:不成立→不处理(C 类转人工);成立首次→A 提示整改、
B L1 停止分发、C L1+L2;成立复发→A L1、B L2、C L3;恶意持续→A L2、B L3、C L4→L5。
互殴/选边风险:无法判定过错方时**对等处置**(双方同 L1),不选边;运营者本人有圈内立场,
封号类必须二次复核 + 统一模板公告,不写个人评价。L1 类建议静默生效(不单独通知),
避免暴露"平台在盯内容"后转为图片/缩写等更隐蔽表达。
运营侧还缺一个"处置台":举报列表、证据聚合、一键执行 L1–L5、留痕查询 —— 目前完全没有。
## 追加:用户明确要求「不要后台」,改为无 UI 实现
审阅后确认项目结构:`/user` 是箱主自助面板(`reqUserSignIn`),**无 admin 角色、无站方后台**;
CLI 骨架在 `cmd/tamabox.go`(`internal/cmd/*.go` 每个命令一个文件,范式见 `ban.go`:`conf.Init` → 拼 DSN →
`db.Init` → 操作 → 确认 → 执行)。告警可用 `conf.Security.SecurityAlertEmail`(`internal/conf/static.go:70`,
autoban 已在用)+ `mail.SendSecurityAlertMail`。
因此"处置台"改为三条非 UI 腿:
1. **举报采集**:单题页举报按钮 → `content_reports` 表;对箱主可见走 `/user/questions?filter=reported`
(这是他自己的收件箱,不是站方后台),举报的处置第一责任人仍是箱主。
2. **告警**:达到阈值(同一箱主 24h 内 N 条举报)发邮件到 `security_alert_email`,这是运营者唯一需要的"入口"。
3. **处置**:`TamaBox reports --domain` 出证据包、`TamaBox restrict --domain --level --days --reason`、
`TamaBox unrestrict`。状态写 `user_restrictions`(带 `expires_at`,判断时比较时间 → **过期自动失效,不需要 cron**)。
生效点(已定位):`QuestionShareCard`(share.go:808)、`GetShowInHome`/`GetByTag`/`getRandomUsers`
(users.go:620/643/749,注意 GetRandom 有 30s 缓存)、`FeatureOpen`(feature_control.go:364,可一处覆盖箱主自设)、
`PublishAnswer`/`SetPublic`/`AnswerFollowUp`、`question.Pager`(L4 整页停用)。
关键约束(已向用户说明):不能复用 `feature_controls`(箱主可自行 `enabled=true` 解除,语义混淆);
不用配置文件方案(要重启、无法表达到期时间);无后台 ⇒ 只能**事后处置**,不能事前审核,口径需写成
"发布后 24–48h 内可被处置";举报必须防刷(同 IP + 同 question 唯一),否则邮箱会被刷爆。
## 已落地(第 1–2 步 + L1 两个出口),已实测
备份:`C:/OtherFiles/Github/TamaBox-v4-mainline-before-abuse-restrict-20261018.tar.gz`(3.2M,排除 .git/.workbuddy)。
新增/改动:
- `internal/db/restriction.go`(新):`RestrictionLevel` L1–L4、`UserRestriction`、`ContentReport`、
`RestrictionsStore`(Create 替换语义 / RevokeActive / ActiveLevel / ActiveLevels / ListByUserID)。
两个模型都**刻意不嵌 dbutil.Model**:处置记录与举报都不软删(撤销走 `revoked_at`),
且带 `DeletedAt` 的唯一索引会留下"删了仍占槽位"的陷阱。
- `internal/cmd/restrict.go`(新):`reports` / `restrict` / `unrestrict`,复用 `fmtScan` 做 YES 确认。
- `internal/db/db.go`:`AllTables` 加两表,`db.Init` 装配 `Restrictions`。
- `tamabox.go` + `cmd/tamabox.go`:注册三个命令(并给根入口补上一直缺失的 `restore`)。
- `route/question/share.go`:`QuestionShareCard` 在任何级别处置下都拒绝生成分享图(fail-closed)。**L1 核心出口**。
- `internal/db/users.go`(CRLF,用 python 脚本改):`GetShowInHome` / `GetByTag` / `getRandomUsers`
加 `id NOT IN (activeRestrictionsSubQuery(...))`,把被处置箱主从首页/探索/标签/随机位摘除。
`GetRandom` 有 30s 缓存 → 随机位最多滞后 30s,其余即时。
- `internal/db/restriction_test.go`(新):覆盖替换 / 升级 / 到期失效 / 撤销幂等 / 曝光排除。**测试全绿**(3.87s)。
实测踩到的两个坑:
1. **只改 `cmd/tamabox.go` 命令不生效** —— 真正入口是根 `tamabox.go`,报 `No help topic for 'reports'`。
2. 证据包比例首版写成「举报次数 / 公开回答数」,实测出现 **166.7%**(5 次举报 / 3 条回答)。
已改为「被举报**条目数** / 公开回答数」,举报次数单独展示。
CLI 冒烟(临时 PG 库 + 临时 ini):reports 证据包 → restrict L1 → 证据包显示生效 → pending 列表 →
unrestrict → 显示"已撤销";升级 L1→L3 后旧记录 `revoked_at` 置位、新记录 L3 生效。清理已 DROP 临时库。
未做(下一步):L2/L3 发布侧拦截(`PublishAnswer`/`SetPublic`/`AnswerFollowUp` + `FeatureOpen` 覆盖箱主自设)、
L4 整页停用、举报按钮与箱主面板筛选、邮件告警。分享卡拦截未做 web 端到端验证(仅编译 + 单测覆盖 store 层)。
## 已落地(第二批:L2 / L3 / L4 发布侧),编译与单测通过,web e2e 受沙箱限制未做
- `internal/db/restriction.go`:加 `restrictionLevelRank` + `RestrictionAtLeast(level, target)`。
显式权重而非字符串比较(将来加 L10 不会静默排错)。空/未知级别恒 false,防止脏数据锁死箱主。
- `route/question/question.go`(CRLF,python 改):
- `PublishAnswer`:L3+ 拒绝(`question.errRestrictedPublish`);L2 **先 SetPrivate 再 AnswerByID**,
保证任何时刻都不出现「公开 + 已作答」的中间态(不是先答后藏)。
- `SetPublic`:L2+ 拒绝(`question.errRestrictedPublic`),否则等于箱主自行解除处置。
- `route/question/follow_up.go`:`AnswerFollowUp` L2+ 拒绝 —— 追问回答同样是公开内容且没有私密档可降。
- `route/question/page.go`:`Pager` 里 L4 → `Redirect("/restricted")`。此处 **fail-open**
(DB 出错放行):它不承担外传通道职责,fail-closed 会让所有箱子一起白屏。
- `route/user/restriction.go`(新)+ `templates/user/restricted.html`(新)+ `internal/route/route.go`
注册公开路由 `/restricted`(必须在 reqUserSignIn 之外)。
- i18n 4 语言各加 5 键(`question.errRestricted*` / `question.flash.answeredPrivate` / `restricted.*`),
`TestDictionaryComplete` 通过。i18n 与 route 文件均为 CRLF:用 python 插行 → `gofmt | 转 CRLF` 写回。
修正上一轮的判断:**`FeatureOpen` 不需要改**。L3 的语义是「冻结发布、仍可收信」,而 feature_controls 控制的是
收集(收信);关掉它反而违背 L3 定义。L4 已经由 Pager 整页挡住。
验证:`go build ./...` EXIT=0;`TestRestrictions` / `TestRestrictionLevelValidation` / `TestRestrictionAtLeast`
/ `TestDictionaryComplete` 全绿。**L4 与分享卡的 HTTP 行为未验证** —— 本沙箱无 redis,
`TamaBox web` 启动即 panic,详见 MEMORY.md 的构建与测试一节。
## 追加:部署自检脚本(用户要部署到测试服务器)
新增 `dev/devcheck-restrict.sh`:串起「基线 → 施加 L4(断言 302 + /restricted 200 + 文案)→ 降级 L1
(断言页面 200 + 分享卡 302)→ 撤销」,默认 dry-run,`RUN=1` 才执行,`trap EXIT` 兜底撤销。
只依赖 CLI 与 curl,不需要 psql。`bash -n` 通过,LF 行尾。
同时核实:`Dockerfile` 的构建命令是 `go build ... ./cmd/`(不是根目录),所以两个入口必须同步 ——
已写进 MEMORY.md。部署前必须先确认 `./NekoBox --help` 里有 `restrict`,否则是旧产物。
手册里另注明两项只能人工手测:L2(回答应保存为私密)、L3(回答应被拒绝)。
### 首次实跑反馈(用户 dry-run)
用户给 `DOMAIN=admin56` 跑 dry-run,结果 2 项 FAIL。日志证据:`SELECT * FROM "users" WHERE domain='admin56'`
→ **record not found**,说明该 domain 不存在(很可能把账号名当成了 domain)。所有 `/_/admin56` 的 302
都来自 `GetByDomain` 失败后的 `Redirect("/")`,与处置无关 → **L4 那项 PASS 是假阳性**。
同时 dry-run 不施加处置,前后状态没变,状态断言本来就无意义 —— 是脚本设计缺陷。
已修 `dev/devcheck-restrict.sh`:
1. 基线不是 200 直接中止并打印排查建议(302 常见原因 + 查真实 domain 的命令);
2. dry-run 在基线后短路,不再做状态断言(只验命令存在、基线、停用页);
3. 新增 1b「停用页是否部署」+ 文案断言,dry-run 也能验 i18n/模板。
教训(已与既有坑同类):**302 / 200 都不能单独证明处置生效**,必须先确立「基线 200」这个前提。
### 二次实跑:7/7 全绿(真实环境)
`DOMAIN=admin6`:基线 200 → L4 302 → L1 200 + 分享卡 302(qid=73)→ 撤销后 200。
证明 L1 分享卡拦截、L4 整页停用、撤销、以及 L4→L1 的**替换语义**在真实库上都按预期工作。
**随后发现并修掉一个顺序缺陷(L2)**:原实现把「强制私密」放在 `moderation.Check` 之前,
一旦审核失败(内容违规或审核源不可用,fail-closed),题目已被改成私密——箱主一次失败的编辑
会让原有公开内容凭空消失。现已移到审核通过之后、`AnswerByID` 之前,仍满足
「任何时刻不出现公开+已作答」(此刻它是未作答的私密题)。编译 EXIT=0、gofmt 干净。
⚠️ 该修复需重新部署才可在测试站手测 L2/L3。
L2/L3 的手测断言(比看 flash 可靠):
- L2 后 `select is_private, answer from questions where id=<qid>` → `t` 且 answer 非空,公开页不显示;
- L3 后 `answer` 仍为空串(未被写入),`is_read` 不变。
- 干扰项:回答会走七牛审核,审核不可用会 fail-closed 报「审核服务不可用」,那不是处置问题。
### L2 线上实测:功能生效,但「箱主无感知」被误判成没效果
用户报「L2 没效果」。我用匿名视角实测(这是我能独立做的部分):
- 公开页 `/_/admin6` 只出现 73,**不含 88**;
- 匿名访问 `/_/admin6/88` → **302 到首页**;对比 `/_/admin6/73` → 200。
结论:L2 私下里是生效的。用户误判的两个原因:
1. 他**以箱主身份**看单题页 —— 箱主本来就能看自己的私密内容,页面当然「一切正常」;
2. **我的 bug**:`question.flash.answeredPrivate` 只写进了 4 个 i18n 字典,**代码里从未使用**,
成功后仍提示「回答发布成功」。已修:L2 分支改用 `forcedPrivate` 布尔切换 flash 文案。
教训:这些 i18n 键写完必须 grep 调用点;「加了文案」不等于「接上了逻辑」。
### 用测试账号自测(已跑通登录态全链路,不需要用户点击)
用户提供测试箱主 `admin6`(密码不落盘)。用 python + urllib 跑通:
`GET /login` 取 `_csrf` → `GET /api/captcha/generate` 取 `{id, question}`(形如 `5 × 5 =`,
注意乘号是 `×` 不是 `*`)→ 算答案后提交 `g-recaptcha-response="<id>:<答案>"` → 登录成功;
再 `GET /_/admin6` 取表单 csrf → `POST /_/admin6`(字段 `content`)发提问 →
`POST /_/admin6/{id}/answer`(字段 `answer`)。
实测结论(qid=89):
- **L2 服务端生效**:回答保存成功,但该题变私密 —— 匿名访问 302 到首页,公开页只有 73;
- **flash 仍是旧文案**「回答发布成功」→ 服务器跑的是 21:22 部署的二进制,我 21:45 的 flash 修复未部署。
- 88(用户手测)与 89(我自检)都是测试数据,均已被 L2 强制转私密,测完需在后台清理或保持私密。
### L3 验证(重新部署 + 切 L3 后)
qid=90:发提问成功(L3 允许收信 ✔);`POST /answer` **没有重定向**、URL 停在 `/answer`、
重新加载单题页仍有回答表单 → **发布确实被拒绝、内容未写入**。
但响应里**没有任何提示文案**(`uk-alert` 只出现在 CSS 里)→ 又一个 bug:
⚠️ **同请求渲染不能用 `SetErrorFlash`**。`ctx.Success("question/item")` 是直接出页面(没有 302),
而 `SetErrorFlash` 写的是 session、要等下一次请求才读 → 提示静默消失,箱主看到的是「点了没反应」。
已把 3 处(PublishAnswer L3 / SetPublic L2 / AnswerFollowUp L2)改成 `ctx.SetError(...)`,
并顺手修了 `follow_up.go` 里同类的 moderation 失败分支(既有缺陷)。编译 EXIT=0。
**该修复需再部署一次**;部署后当前仍是 L3,可直接验提示是否出现。
### 重新部署后 L3 提示验证:通过
qid=90 再答一次:`ALERT: 当前处于平台处置期,已暂停发布回答` 已渲染;仍无重定向、回答表单仍在
(未写入)。L3 全链路闭环。待办:用户要求后续做「`SetErrorFlash + Success` 全量清理」专项。
剩余未验:L2 的「已保存为私密」提示(需把 admin6 切到 L2 后自测),以及举报采集链(未开始)。
### L2 提示验证:通过(qid=90)
```
post url: /_/admin6/90 (正常 302 回题目页)
ALERT: 已保存为私密:当前处于平台处置期,回答不会公开
匿名访问 90 -> 首页;公开页 ids: [73]
```
L1/L2/L3/L4 至此全部有真实环境实测证据:
- L1:页面 200 + 分享卡 302(qid=73)
- L2:回答成功 + 转私密 + 匿名不可见 + 专用提示
- L3:回答被拒(无重定向、表单仍在、未写入)+ 拒绝提示
- L4:整页 302 → /restricted 200 + 四语言文案
测试数据:88(用户手测)、89(L2 自检)、90(L3/L2 自检,现已答且私密),均需在测试站清理。
admin6 当前处于 L2(到期次日),必要时 `unrestrict` 解除。
## 举报采集链(已落地,编译 + 单测 + 模板检查通过,未部署验证)
新增/改动:
- `internal/db/report.go`(新):`ReportsStore` —— `Create`(唯一索引冲突静默为幂等成功)、
`QuestionIDsByUser`(箱主列表标标,排除已删除提问)、`CountByUserSince`(告警阈值)。
理由枚举 `harassment/doxxing/divisive/other` + `IsValidReportReason`。
- `internal/form/question.go`:`ReportQuestion{Reason}`(`valid:"required;maxlen:32"`)。
- `route/question/report.go`(新):`Report` handler。**不要求登录**(目击者是匿名访客);
理由白名单;同 IP 同题幂等且提示与首次一致(避免变成内容存在性探测接口);只写库不改可见性。
- `route/question/abuse_notify.go`(新):阈值告警 24h≥8 或 7d≥20,24h 冷却(进程内 map + mutex),
复用 `conf.Security.SecurityAlertEmail`,`shared.SafeGo` 异步发送。**只提醒不自动处置**。
- `internal/mail/mail.go` + `templates/mail/abuse-alert.html`:`SendAbuseAlertMail`。
邮件只给计数与下一步命令,**不贴举报内容原文**(邮件会被转发归档,等于又复制一份挂人内容)。
- `internal/route/route.go`:`POST /_/{domain}/{questionID}/report`,`rateLimitMiddleware(10, 1*time.Minute)`。
- `templates/question/item.html`:回答下方 `⚑ 举报这条内容`,`<details>` 折叠 + 固定理由下拉,
仅 `{{ if not .CanDelete }}`(非箱主)可见。
- `templates/user/question-list.html` + `route/user/question.go`:箱主列表加「被举报 N 次」红色 badge,
**只给数量不给举报人 IP**。
- i18n 4 语言共 11 个新键(report.* / qlist.reported*)。
验证:`go build ./...` EXIT=0;`TestReports` PASS(首次写入 / 同 IP 幂等 / 异 IP 计数 /
时间窗口 / 驳回后不计入);`TestDictionaryComplete` PASS;`go run ./dev/checktpl` → all templates syntax OK
(项目自带模板语法检查工具,改模板后必跑)。
未做:箱主列表的「只看被举报」筛选(目前只有 badge);举报驳回/成立的处置动作(目前 status 只有 pending)。
### 举报链线上验证:4/4 通过
- 入口:单题页 `/73` 出现「举报这条内容」+ form action `/_/admin6/73/report`;
- 首次举报:POST 302 → flash「已收到举报,感谢反馈」;
- 同 IP 重复:同样 302 + 同样文案,无失败提示(幂等);
- 箱主 `/user/questions`:出现「被举报 **1** 次」(多次重复提交后计数仍为 1,反证幂等生效)。
**过程教训**:中途 python urllib 报「读超时 25s」,一度怀疑服务端挂起。实测 curl 同一接口
1.6~1.7s 正常返回 302,`location=/_/admin6/73`,手动 GET 也 200。真因是 python 走本地代理
(`https_proxy=127.0.0.1:54881`)偶发挂起 + `/tmp` 路径在 Windows python 与 MSYS 之间不互通。
已写进 MEMORY.md:**验证线上行为优先用 curl**。
### 告警触发验证(进行中)
直接 SQL INSERT 不会触发告警(`notifyAbuseIfNeeded` 只在 HTTP 举报 `created=true` 时调用)。
正确姿势:SQL 补到 7 条 → 删掉自己那条旧记录 → 再用 HTTP 提交一次,使 count 恰好到 8。
已按此执行(curl 提交返回 302 + 「已收到举报」),等待确认日志
`Abuse alert mail sent` / `Failed to send abuse alert mail` / 什么都没有(= `security_alert_email` 为空)。
### 邮件告警验证通过
配置 `security_alert_email` + 重启后,补到 7 条再 HTTP 提交第 8 条,邮件成功收到;测试数据已清理。
举报链至此全部闭环。
### 静默丢失专项清理(用户要求的那项)
静态扫描 `route/**/*.go`:flash 调用后第一个 `Success(`/`Redirect(` 判定,共 **9 处**缺陷
(`follow_up.go` 2、`page.go` 1、`question.go` 1、`profile.go` 5),全部改为
`SetError(err[, f])` / `SetInternalError()`。`Set*Flash + Redirect` 112 处正常,未动;
`vote.go:702/704` 是扫描误报(其后 switch 之后才是 Redirect)。
`go build ./...` EXIT=0;`go test ./internal/db/ ./internal/i18n/` 全绿(36s)。
⚠️ 这 9 处只做了编译与静态验证,未逐条构造触发条件(需传超大图/失效追问 id),
部署后实际遇到对应错误时观察提示是否可见。
### 举报加验证码 + 箱主列表「只看被举报」筛选
- 举报验证码:`form.ReportQuestion` 加 `Recaptcha`(**刻意不加 valid:required**,空值交给
`captcha.Verify` 判定,否则 Bind 失败会被统一提示成「请选择一个举报理由」);
`Report` handler 签名加 `recaptcha recaptcha.RecaptchaV3`,调 `captcha.Verify`(与提问/私聊同源)。
模板按 `CaptchaType == "math"` 双分支:math 用隐藏域 + `.math-captcha-trigger`(header 的全局弹窗
用 `btn.closest('form')` 定位表单、`form.querySelector('[name="g-recaptcha-response"]')` 回填,
**不依赖 id**,所以举报表单可直接复用);非 math 用 `.g-recaptcha` + 自定义 callback `tbReportCaptchaOk`。
- 筛选:`/user/questions?reported=reported`,复用已查出的 `reportCounts` 在内存过滤,
新增 `SelectedReported` + 模板第 5 个下拉(reportFilter / reportedOnly)+ 4 语言文案。
验证:`go build ./...` EXIT=0;`go run ./dev/checktpl` → all templates syntax OK;
`go test ./internal/i18n/` 通过。**未部署**,且加了验证码之后原来的 curl 测试方式要改成先算题再提交。
### 部署后验证:4/4 通过
- 无验证码提交 → 302、**未写库**、提示「验证码不能为空」(i18n key `captcha.errEmpty`,非硬编码);
- 带正确算数验证码 → 302 + 「已收到举报,感谢反馈」;
- 同 IP 重复(换新验证码)→ 同样成功提示(幂等,未新增记录);
- 筛选:全部页 `73, 88, 89, 90`,`?reported=reported` 只剩 `73`,下拉 `option value="reported" selected`。
测试遗留:73 号题有 1 条举报(我的测试),需 `DELETE FROM content_reports WHERE question_id = 73;` 清理。
## 新增 `TamaBox hide-public`(存量批量处置)
`internal/cmd/hide_public.go` + 两个入口注册。默认只动「公开且已回答」的题;
`--only-reported` 只处理有 pending 举报的(推荐);`--since N` 限时间窗。
编译 EXIT=0,gofmt 干净。**未在服务器验证**(需部署)。
## 待办与建议(已告知用户,按优先级)
- P0 举报状态闭环:箱主删除/设私密后,该题 pending 举报应置 `accepted`,否则告警阈值会被已处理的旧举报
不断推高(狼来了),且无法统计「成立率」。当前 status 永远停留在 pending。
- P0 箱主侧处置可见性:`/user/questions` 顶部常驻条幅(读 restrictions),避免像今天这样 L2 生效但箱主不知情。
- P1 箱主侧举报通知(某条 ≥3 次时告知箱主自查)、`reports --stats` 全站视图(发现恶意刷举报)。
- P2 社区规则页 `/rules`,处置时引用条款。
- 明确不建议:举报数达标自动处置(武器化)、箱主申诉后台(违背无后台约束)。
## 建议落地(P0/P1/P2 全部完成,编译 + 单测 + 模板检查通过,未部署)
- **P0 举报闭环**:`ReportsStore.MarkAcceptedByQuestion`,在箱主 `Delete` / `SetPrivate` 成功后调用
(失败只记日志,不回滚用户操作);`hide-public` CLI 批量后同样标记。新增 `reportPendingTTL = 30d`:
`QuestionIDsByUser` / `CountByUserSince` / `CountByQuestion` 都按时间窗过滤,**超期 pending 视为过期**,
用查询过滤而不是定时任务(无后台项目不需要调度器)。
- **P0 处置期条幅**:`/user/questions` 顶部读 `Restrictions.ActiveLevel` 渲染常驻 warning
(`qlist.restrictionBanner`,四语言,带级别与到期日)。解决「L2 生效但箱主不知情」。
- **P1 箱主自查提醒**:单条内容待处理举报**恰好等于 3** 时给箱主发邮件
(`mail/question-reported.html`),用「恰好等于」避免每条举报都发;箱主 `Notify=none` 时不发。
邮件明确写「不代表平台已判定违规」。
- **P1 `reports --stats`**:全站 Top 被举报箱主 + Top 举报 IP,用于识别「举报被武器化」。
- **P2 `/rules` 社区规则页**:公开页 + `templates/rules.html` + 四语言 10 个键。
内容含「明确禁止的 4 类」「处置梯度 5 级」「被举报怎么办」「被攻击怎么办」,是处置的引用依据。
⚠️ 文案由我起草,**需用户过目**(尤其对外承诺的措辞)。
验证:`go build ./...` EXIT=0;`checktpl` OK;`TestReports`/`TestRestrictions` 全绿。
### 部署后验证:3 项通过,1 项 500 待查
- ✅ 处置期条幅:登录 admin6 的 `/user/questions` 出现「限制期」文案(1 处);
- ✅ 举报 badge:「被举报 1 次」;
- ✅ 闭环:`POST /set-private` → badge 归 0、公开页不再出现 73;
`set-public` 返回 200(不是 302)是**预期** —— L2 禁止设公开,不是 bug。73 现处于私密,需先 `unrestrict` 才能恢复。
- ❌ **`/rules` 返回 500**(其余公开页 `/`、`/about-me`、`/sponsor`、`/deactivated` 均 200)。
已在本地排除:模板语法 checktpl 通过、i18n 四语言键齐全(grep 确认在 map 内)、`t` 函数返回 string 不抛错。
**待用户提供服务端日志**(500 只回 22 字节 "Internal Server Error",无细节)。
顺带修掉一个**我引入的回归**:`templates/render_test.go` 的 `mockItem` 缺 `ReportCount`,
导致 `user/question-list` 渲染测试失败(`can't evaluate field ReportCount in type interface {}`)。
已补字段;同时把 `{{ if gt .ReportCount 0 }}` 改为 `{{ if .ReportCount }}`(更宽容,零值即假)。
`go test ./templates/` 恢复全绿。
### /rules 500 根因:模板没进 embed 清单
日志:`template: rendering error="html/template: \"rules\" is undefined"`。
**`templates/embed.go` 是显式文件清单**(并非我原先以为的整目录 embed)。新增的顶层
`rules.html` 没写进 `//go:embed ...` 那行 → 没被打包 → `ctx.Success("rules")` 找不到模板 → 500。
编译、checktpl、单测全部无感(测试不渲染该页),只有实际访问才炸。
已把 `rules.html` 加进清单,编译 EXIT=0、`go test ./templates/` 通过。
⚠️ 以后新增**顶层**模板必须改 embed 指令;子目录下的文件已被目录项覆盖。
MEMORY.md 里那条错误认知已纠正。
### /rules 修复后验证:四语言全通过
`rules=200`(39KB);标题依次为「社区规则与内容边界 / 社群規則與內容界線 /
Community rules and content boundaries / コミュニティルールとコンテンツの境界」;
处置梯度段落存在。
发现规则页**没有任何入口**(页脚原本全是外链,无站内链接),已加
`<a href="/rules">` 到 `templates/base/footer.html` + `site.footer.rules`(四语言)。
编译 EXIT=0、checktpl OK、i18n 与 templates 测试全绿。**需再部署一次生效**。
补充(用户要求):处置条幅里「如有疑问请通过页脚找站长联系」后面再挂一个 `/rules` 链接,
用 `·` 分隔(`qlist.restrictionRulesLink`,四语言)。模板压成单行避免多余空白。
编译 EXIT=0、checktpl OK。仍需部署。
## 规则页扩展 + 注册强制同意(用户要求)
- **规则分两类**(关键设计):`rules.forbidden` 重写为
「一、严重违规(直接删内容 + 按情节封禁,**不适用分级**)」6 条(涉政/暴恐/色情涉未成年/违禁品赌博诈骗/侵害未成年人/隐私),
「二、社区冲突(**按影响面分级**)」4 条(挂人、辱骂、群体对立、垃圾广告)。
`rules.enforce` 相应说明「分级只适用于社区冲突;严重违规不走分级,涉法配合有关部门」。
四语言同步。这样避免「挂人只停分发、涉政也停分发」的错误预期。
- **注册强制同意**:`form.Register` 加 `AgreeRules string`(`form:"agree_rules" valid:"required"`,
msg `form.register.agreeRules`);注册页在 language 之后加 checkbox + `/rules` 链接(`target="_blank" rel="noopener"`)。
**服务端校验是必须的**:前端 required 可绕过,而这个勾选是「用户已知悉规则」的唯一凭证。
- 顺带同步 `dev/devcheck-e2e.py` 的三处注册 POST,加 `"agree_rules": "1"`,否则 e2e 注册步骤会全部失败。
验证:`go build ./...` EXIT=0;checktpl OK;i18n 与 templates 测试全绿。
未用 modal 而用新标签页:规则正文很长,iframe 弹窗体验差且要重复维护内容。
### 部署后验证(规则扩展 + 注册同意):全通过
- `/rules`:含「严重违规」(6 条底线条目 + 涉政表述)、「社区冲突」(4 条);
- 四语言标题正确;
- `/register` 有 `agree_rules` 勾选框 + `/rules` 链接;
- **不勾选直接 POST 被服务端拦下**(提示走站点默认语言 ja:
`コミュニティルールをお読みのうえ同意してください`),且**未创建账号**(`/_/tbprobe` 仍 302);
- 页脚与处置条幅里的「查看社区规则」链接都在。
### UI 微调(已改,未部署)
- `rules.html` 底部「返回首页」容器加 `uk-text-center`(原先左对齐)。
- `question/success.html` 两个按钮改为 flex 等宽:`flex: 1 1 160px; max-width: 220px`,
容器 `uk-flex uk-flex-center uk-flex-wrap`。**用 flex 均分而不是固定宽度** —— 各语言文案长短差异大,
等宽不能靠文字长度。纯布局改动,不涉及 i18n,四语言自动一致。
补充:验证码弹窗「验证并提交 / 取消」两个按钮也不等长,按用户要求把 `captcha.submit` 改短:
提交 / 送出 / Submit / 送信(该键只被这个弹窗使用)。
⚠️ 用户反馈「成功页按钮还是老样子」时,先确认**是否真的部署了**:直接抓线上 HTML 看有没有新标记
(`grep 'uk-margin-large-top uk-text-center'` 在 `/rules` 上)。实测那次线上仍是 `uk-margin-large-top"`
旧结构 → 与浏览器缓存无关,是代码没更新。以后别先怀疑缓存。
(第二次复测已生效:`uk-text-center` 在、弹窗按钮 `>送信<`、成功页 `flex: 1 1 160px` 出现 2 次。)
## 受控用户强制最高审核档(用户要求:不分级,全部级别)
- `moderation.EffectiveStrictness(ctx, userID, raw)`:**有任何处置(L1–L4)就返回 strict**,忽略账号设置。
**处置查询失败时回退账号自身设置**,而不是"失败即强制 strict" —— 否则 DB 抖动会让全站突然加严、
正常提交被大面积误拒,影响面远大于漏放一个受控账号。
- 7 个审核调用点全部从 `StrictnessFor(raw)` 改为 `EffectiveStrictness(...)`:chat_handler、follow_up×2、
page、question、user/vote×3、vote/vote。`StrictnessFor` 保留为纯解析函数(模板回显用)。
- `UpdateModerationStrictness`:处置期内直接拒绝保存并 flash `admin.flash.strictnessLocked`。
- 设置页 `templates/user/moderation-strictness.html`:`$root.StrictnessLocked` 时提示 + select/按钮 disabled。
⚠️ 该文件仍只能用 shell/python 改(Edit/Write 会注入 `data-page-node-id`),本次用 python 精确替换。
- Profile handler 注入 `StrictnessLocked`(读 ActiveLevel)。
验证:`go build ./...` EXIT=0;checktpl OK;db/i18n/templates 测试全绿。
性能:每次内容提交多一次 `user_restrictions` 查询(走 user_id 索引),审核本身要走七牛网络,1ms 可忽略。