Files
tamabox/.workbuddy/memory/2026-10-18.md
T
2026-10-18 22:18:21 +08:00

277 lines
21 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)。