# 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=` → `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=":<答案>"` → 登录成功; 再 `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`:回答下方 `⚑ 举报这条内容`,`
` 折叠 + 固定理由下拉, 仅 `{{ 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 / コミュニティルールとコンテンツの境界」; 处置梯度段落存在。 发现规则页**没有任何入口**(页脚原本全是外链,无站内链接),已加 `` 到 `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 可忽略。