33 KiB
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 条无效,且给箱主"被平台打压"叙事。 正确梯子是从传播面往下收:停止分发 → 收窄公开 → 冻结发布(回答/公开)→ 停用页面 → 封禁。
- 反对「多次被禁自动封号」:本场景举报本身是攻击武器,自动级联等于给组织化举报装自动扳机。 必须"举报成立(带证据快照)+ 人工判定 + 可申诉 + 留痕"。
- 判据必须用行为指纹而非文本关键词:挂人内容占比、独立指涉对象数、处置后复发率。
- ⚠️ 本例箱主自述被"澄毒长期辱骂",可能是互殴一方 → 流程必须双向取证,否则单边处罚会激化对立。
追加:改为运营者人工裁定(用户倾向)
用户提出:被举报不自动封号,交由运营者本人判断。此方向认可,但补三条前提,否则会退化成"看心情处置":
- 判定标准必须先书面化(什么算挂人:可识别第三方 + 贬损/号召规避/曝光主页 + 无公共利益), 否则同一人不同时间判出不同结果,比误封更伤公信力。
- 证据包自动生成(内容快照 + 来源 IP 聚合 + 该箱主历史处置 + 同 token 的关联条目), 人工只做裁决,不做取证的体力活。
- 处置必须留痕 + 可申诉 + 有冷静期(举报后 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 腿:
- 举报采集:单题页举报按钮 →
content_reports表;对箱主可见走/user/questions?filter=reported(这是他自己的收件箱,不是站方后台),举报的处置第一责任人仍是箱主。 - 告警:达到阈值(同一箱主 24h 内 N 条举报)发邮件到
security_alert_email,这是运营者唯一需要的"入口"。 - 处置:
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(新):RestrictionLevelL1–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)。
实测踩到的两个坑:
- 只改
cmd/tamabox.go命令不生效 —— 真正入口是根tamabox.go,报No help topic for 'reports'。 - 证据包比例首版写成「举报次数 / 公开回答数」,实测出现 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:AnswerFollowUpL2+ 拒绝 —— 追问回答同样是公开内容且没有私密档可降。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:
- 基线不是 200 直接中止并打印排查建议(302 常见原因 + 查真实 domain 的命令);
- dry-run 在基线后短路,不再做状态断言(只验命令存在、基线、停用页);
- 新增 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 私下里是生效的。用户误判的两个原因:
- 他以箱主身份看单题页 —— 箱主本来就能看自己的私密内容,页面当然「一切正常」;
- 我的 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(新):Reporthandler。不要求登录(目击者是匿名访客); 理由白名单;同 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 失败会被统一提示成「请选择一个举报理由」);Reporthandler 签名加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+ 自定义 callbacktbReportCaptchaOk。 - 筛选:
/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-publicCLI 批量后同样标记。新增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", msgform.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"
旧结构 → 与浏览器缓存无关,是代码没更新。以后别先怀疑缓存。