This commit is contained in:
tamakyi
2026-10-19 01:58:00 +08:00
parent bd613a6f5a
commit 4592b6ef24
14 changed files with 608 additions and 244 deletions
+47
View File
@@ -0,0 +1,47 @@
# 2026-10-19
## 部署后复测(强制严格档那批)
- 规则页返回首页已居中:线上 `<div class="uk-margin-large-top uk-text-center">` ✔
- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
- 箱主面板 `/user/questions`:限制期条幅 + 「查看社区规则」链接都在 ✔
- 严格度锁定(admin6 当前 L2):
- `/user/profile` 出现「处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置」✔
- `select[name=strictness]` 与「保存审核严格度」按钮均带 `disabled` ✔
- 直接 POST `/user/moderation-strictness/update?tab=security` + `strictness=lax` → 302 回 profile,
flash 仍是「已锁定」,且选中档仍为 `strict`(说明未写入)✔
- 踩坑:表单 action 是 `/user/moderation-strictness/update`(**没有** `/profile` 段);用错路径返回 302 到 `/`,
容易误判成「被中间件拦」。另外每次 POST 都要重新取 `_csrf`,用上一页(已跳首页)的 token 会 400。
## 记忆维护
MEMORY.md 一度 19KB 被注入截断,已重写压缩到约 4.9k 字符(8.1KB),保留构建/行尾/embed/审核/处置/CLI 等硬约束,
删去已在日志里的过程细节。
## CLI 文档 + 全量代码审阅(同日)
新增 `docs/cli.md`:命令总览、reports 三种用法与口径、restrict 级别语义/副作用、unrestrict、
hide-public、ban/restore、典型处置流程、排错表、明确不做的事。
审阅中发现并修复(全部本地临时库实测):
1. **`hide-public` 举报闭环静默失效**(真 bug):`build()` 带 `is_private=false`,在 Update 之后拿它当子查询恒为空集
→ 隐藏成功但 `content_reports` 仍 pending。改为 Update 前 `Pluck("id")` 快照,再按 ID 更新/标记。
实测:3 条 pending → accepted,未波及其他箱主。
2. **整箱分享卡未拦截**:`ShareCard` 直接走缓存,L1–L3 仍能生成整箱图。已在缓存查询前加拦截(fail-closed)。
3. **L2 通知邮件误导**:强制私密后仍给提问者发「你的提问有回答」,链接点开 302。改为 `!forcedPrivate` 才发。
4. **L2 写入顺序**:`SetPrivate` + `AnswerByID` 两条语句有「公开+已作答」窗口,第二条失败还会留下「私密+无回答」。
新增 `Questions.AnswerByIDPrivate`(单条 UPDATE 同时写 answer/is_read/is_private),并补测试 `TestQuestions/AnswerByIDPrivate`。
5. **`ListAPI` 绕过 Pager**:L4 停用与页面访问密码只挡 HTML,`/api/v1/user/{domain}/questions` 仍能整页拉。
已补两道校验(均返回「用户不存在」,不暴露原因)。**页面密码可被 API 绕过是升级前就存在的问题。**
6. **L1 曝光残留**:`GetShowInHome` 的 Count 未过滤(分页总数虚高、末页空白)、热门标签查询未过滤。已补同一子查询。
7. **性能**:`ActiveLevel` 由两次查询改一次(取最高 rank);新增 `moderation.EffectiveStrictnessFor(level, raw)`,
发布路径上不再重复查库(原先一次发布最多 4 次处置查询)。
8. **证据包口径**:分母是「累计公开回答」不是窗口产出 → 文案加「累计」;新增 `待处理 N` / `已处理` 标记,
避免把已闭合的举报误读成还挂着;补 `q.deleted_at IS NULL`;`--stats` 说明最长回溯 30 天;CLI 对 `active` 做 nil 守卫。
验证:`go build ./...` EXIT=0;db/i18n/templates 测试全绿;CLI 全流程(restrict L3 → 证据包 → unrestrict)实测通过;
临时库已 DROP。
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
+83 -189
View File
@@ -1,211 +1,105 @@
# TamaBox-v4 项目长期笔记
# TamaBox-v4 长期笔记
## 协作惯例
## 协作
- 结论分级 P0/P1/P2 + 🔴/🟠;给修复建议前先做根因取证与实测复现,不要只静态推断。
- **改代码前先出「方案/变更清单」获批准,再分批落地**。用户常授权自主推进(「你先继续,后面我发现问题再说」),但重大问题仍要显式指出。
- 工作场景用 `@scene#N:"名称"` 标记。
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单获批准;用户常授权自主推进,但重大问题仍要显式指出。
- 用户是运营者也是圈内人:处置功能必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试
- **本机无 libwebp/GCC**,`chai2010/webp`(cgo) 会挡住 `go build ./...`;必须用 overlay 把
`internal/storage/s3.go` 换成 `dev/s3stub/s3_stub.go`:
`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`(`dev/overlay.json` 里的短名路径可能失效)。
- **「编译卡死」真因 = `proxy.golang.org` 不可达**(本沙箱 curl 超时;`goproxy.cn` 正常)。
每条 go 命令前必须:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
代理正确时 `go build ./...` EXIT=0、`./route/user/` 约 7s。**超时 ≠ 慢**:先用 `go list -deps` 拆开「模块解析/编译」两层。
`go env -w` 在本沙箱失败(无 `%AppData%`),无法持久化。**1C1G 是用户正式生产环境,禁止用「沙箱慢」解释超时。**
- `internal/db` 测试需真实 DB。本地 PG:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm
DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名,`NewTestDB` 自建 `tamabox-test-<rand>` 并 DROP)。
2026-09-15 起「带 DB 的 `go test ./...`」**全绿**;此前的失败**全是测试自身缺陷**,已修。
- ⚠️ `internal/db` 包级 store 单例只在 `db.Init` 装配;store 方法内**不要引用包级全局**,用 `NewXStore(db.DB)`,
否则测试下 nil panic。唯一例外:`feature_control.go` 的 `FeatureStateOf`(有 nil 守卫)。
- **行尾是混合的,不能按规律推断**:`internal/db/*`、`internal/mail/mail.go`、`route/user/profile.go` 是 LF;
`internal/route/route.go`、`internal/db/users.go`、`internal/i18n/dict_*.go` 是 CRLF。
编辑前用 Python 数 `b'\r\n'` 确认(`grep -c $'\r'` 不可靠)。`gofmt -l` 会把 CRLF 文件误报为未格式化。
格式化统一走 `dev/gofmt_keep_crlf.py <files>`。
- `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。
- ⚠️ **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 直接报错拒绝)。
要在本机类型检查依赖 cgo 的真实代码(如 `internal/storage/s3.go`),唯一可行方案:
**`tar` 复制项目到临时目录 → 在副本里 `go mod edit -replace <cgo依赖>=../本地stub` → 编译/测试**。
副本约 15MB、编译约 2~7 分钟。**别用 `s3stub` overlay 检查 s3.go 本身**——overlay 会把 s3.go
换成桩,等于根本没检查它(只能检查调用方)。见 2026-10-15 的批次 A 落地记录。
- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。
- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。
这类测试要写成「检测到桩就 `t.Skip`」,否则 CI/本机表现不一致。
- ⚠️ **本沙箱起不了 `TamaBox web`**:flamego/session 固定走 redis store,本机 **6379 未开**,
启动即 `panic: session: open database: default addr for network ...`。所以任何需要 HTTP 的
端到端验证(L4 停用页、分享卡拦截、发布拦截)只能部署到 box-dev 后做,别在本地硬试。
- ⚠️ **探测端口 200 可能是别的进程**:8899 被本机无关进程占用,服务 panic 后 curl 仍返回 200,
差点误判成「改动没生效」。起服务后必须看任务日志确认 `Starting web server` 且无 panic,
再信任 curl 结果;端口探测本身不能证明是自己的服务。
- ⚠️ **本机 python urllib 不稳定**:`https_proxy=127.0.0.1:54881` 下偶发「响应读超时」
(同一时刻 curl 完全正常)。曾据此误判「举报接口卡死 25 秒」,实际是客户端问题。
**验证线上行为优先用 curl**;必须用 python 时加短超时 + 重试,且不要用 `/tmp` 路径
(Windows python 把 `/tmp/x` 解析成 `C:\tmp\x`,而 MSYS curl 写的是 MSYS 的 `/tmp`,两者不通)。
- **无 libwebp/GCC**,cgo 的 `internal/storage/s3.go` 挡编译 → 用 overlay 换 `dev/s3stub/s3_stub.go`:
`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`。
但 overlay 会把它当桩,**检查 s3.go 本身要 tar 复制项目 + `go mod edit -replace`**(overlay 不能替换 GOMODCACHE 文件)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前:
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。`go env -w` 在本沙箱失败。
1C1G 是正式生产环境,**别用「沙箱慢」解释超时**。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 目前全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 例外,有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定走 redis,6379 未开,启动即 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常),曾误判「接口卡死」。线上验证优先 curl;
python 别用 `/tmp`(Windows 解析成 `C:\tmp`,与 MSYS `/tmp` 不通)。
## 图片上传与编码(`internal/storage/s3.go`)
## 行尾 / 模板
- 三档编码规格 `EncodeSpec{MaxSide, Quality}`:头像 512/q78、背景 1920/q80、通用 1600/q80。
头像依据:模板最大展示 120px(`.tb-frame-avatar`),前端 cropper 输出上限 512。
⚠️ 档位只作用于**新上传**,线上已有图片不会被重压。
- 所有转换分支都有 `len(webp) >= len(原图)` 回退——线框图/小图标上有损 WebP 反而更大
(实测 200x200 线框图 PNG 652B vs WebP 908B),不加兜底会越转越大。
- **EXIF 方向必须在转码前归一**(`exif.go` 手工解析 APP1,无外部依赖)。
`image.Decode` 与 Pillow 默认都不读 EXIF;**转成 WebP 后 EXIF 被丢弃、方向永久丢失**。
顺序铁律:**先归一方向 → 再限边缩放**(归一会交换宽高)。
⚠️ **比例校验必须用同一套方向**(`aspect.check(cfg, orientation)` + `orientationSwapsDimensions`),
否则手机竖拍照片(像素横躺 + Orientation=6)直传头像/背景会被误判「比例不符」而拒。
比例校验只交换宽高、**不解码像素**(大图内存)。前端 cropper 路径不受影响(canvas 已归一)。
⚠️ **头像 1:1 测不出这个缺口**(对称比例,交换宽高比例不变);**只有背景 16:9 能触发**。
线上验证脚本是 `dev/devcheck-upload.py`(不是 devcheck-avatar.py),12/12 覆盖
A 分档 / 背景对照 / EXIF ori6 放行 / **像素级方向比对(vs Pillow exif_transpose 红条包围盒)** / 反向用例(无 EXIF 竖图仍被拒)。
⚠️ 判定「方向是否归一」别用区域平均亮度:采样带比标记条还宽会稀释颜色,误判成「没归一」。
按颜色阈值定位像素包围盒才靠谱。**排查先立黄金标准(Pillow exif_transpose),否则会去改本来正确的代码。**
- **上传管线只完整解码一次**:`image.DecodeConfig` 一次同时拿 format + 宽高,
供像素上限 / 比例 / WebP 透传三处共用;完整解码只在真正转码时发生。
`ConvertStaticImageToWebP(data []byte, spec)` 收字节而非 `io.Reader`。
实测堆峰值:JPEG 4000×3000 单解 18MB / 双解 36MB(正好 2 倍),1C1G 上很关键。
- GIF 帧数上限 `maxGIFFrames = 200`:`CheckPixelLimit` 只看逻辑屏尺寸,
几百帧的大 GIF 体积小但能让 `cwebp` 长时间吃 CPU。
- **method=6 已评估并否决**:实测耗时只增 1.2~1.9x(不是原以为的 5~10x),
但体积只省 ~3%,1C1G 上不划算。
- `cwebp` 缺失时的 GIF 静默回退已加 `logrus.Warn`;生产机(裸 Linux)已确认装有 `cwebp`。
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`question-list.html` 是 CRLF)。改前用 Python 数 `b'\r\n'`;
`gofmt -l` 会把 CRLF 误报为未格式化。格式化走 `dev/gofmt_keep_crlf.py`。
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 会改成 `”`)。
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed` 那行,否则运行时
`html/template: "xxx" is undefined` → 500,编译期无提示(`checktpl` 也只查语法)。子目录已被目录项覆盖。
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
## 审核
## 内容审核
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 会绕过档位)。不可用时 **fail-closed**。
- ⚠️ **圈内黑话(代称+群体标签+避雷叙述)任何通用审核都拦不住**,别指望词库;靠处置 + 举报 + 人工判断。
- 严格度三档 lax/standard/strict,存 `users.moderation_strictness`。**受处置用户强制 strict**:
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已全部改用它);**查询失败时回退用户设置**
(否则 DB 抖动会让全站突然加严)。处置期内 `UpdateModerationStrictness` 拒绝保存 + 设置页 disabled。
- 线上生效通道 = **七牛云**(`ai.qiniuapi.com/v3/text/censor`,scene=antispam);阿里云代码在但 key 空。
- 通道可配:`[security] text_censor_source = qiniu | aliyun | auto`(留空同 auto)。常量在 `conf`,
装配在 `censor.censorChain()`,启动校验 `conf.validateTextCensorSource`;**显式选通道却缺凭据会启动报错**。
- 七牛 `IsPass()` 只硬拦 politics/terrorism/porn,`review` 一律放行,且不支持白名单 →
拦截判定收敛到策略层:`censor/policy.go`(档位裁决)+ `moderation/moderation.go`(唯一入口 `Check`)。
- **新增文本审核调用点必须走 `moderation.Check`**,不要直接调 `censor.Text`(会绕过审核严格度档位)。
三场景:提问/回答、投票、私聊。
- `LabelScore.Score` 统一归一化 0~1(阿里云 2.0 无逐标签置信度,按 riskLevel 折算 high=1/medium=0.6/low=0.3)。
- 审核服务不可用时 **fail-closed**,未经批准不要改 fail-open。⚠️ 推论:显式选定的通道一旦坏掉,
整站发布+私信全被拒且现象隐蔽(只在日志)。
## 提示(Flash)
### 阿里云 green(`/green/text/scan`)
- 实现与上游/官方示例一致;`SetContentType(requests.Json)` 仅为接口契约对齐,**已证伪与故障无关**。
- 根因(2026-09-10 实证):`code=596 "You have not opened Yundun Content Moderation Service"`,
阿里云**请求被拒也回 HTTP 200 + success:false + 无 data**。现已把 code/msg/requestId + 响应摘要写进错误。
- ⚠️ **硬门槛:「内容安全 1.0」需企业实名认证**(官方 69806),个人认证账号永远开不了。
替代:迁 `green-cip`(2.0,标签体系不同、需改代码)或退回 `auto`/`qiniu`。RAM 需 `AliyunYundunGreenWebFullAccess`。
- `data[].code != 200` 仍 fail-closed 置 block,但现在会打 Warn(区分「审核失败」与「真违规」)。
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,要下一次请求才读 →
提示静默丢失(「点了没反应」)。2026-10-18 已清理 9 处;`Set*Flash + Redirect` 是正常模式。
扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(` → 前者即缺陷。
## 提示与 Flash(route 层)
## 处置与举报
- ⚠️ **同请求渲染必须用 `ctx.SetError` / `ctx.SetSuccess`,不能用 `*Flash`**:`ctx.Success("...")` 直接出页面时,
`SetErrorFlash` 写的是 session、要等**下一次请求**才会被读取,提示会静默丢失(表现为「点了没反应」)。
`*Flash` 只适用于 `Redirect` 之后再渲染的页面。
2026-10-18 实测:L3 拒绝发布时箱主完全看不到提示,改 `SetError` 后正常显示。
- 项目里仍有若干 `SetErrorFlash + Success("question/item")` 的既有组合(`follow_up.go` 的 errNotFound、
`question.go` 的上传图片过大等),同属该缺陷,遇到时顺手改成 `SetError`。
- ✅ **2026-10-18 已完成全量清理**:扫描 `route/**/*.go` 中 `Set*Flash` / `SetInternalErrorFlash`
后 8 行内跟 `ctx.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` 是扫描器误报(其后是 Redirect)。
扫描脚本思路可复用:逐行找 flash 调用 → 向下找第一个 `Success(`/`Redirect(` → 前者即缺陷。
- L1 停止分发(禁分享卡 + 首页/探索/标签/随机位摘除)/L2 收窄公开(新回答强制私密、禁止 set-public)/
L3 冻结发布(仍可收信)/L4 整页停用(`/restricted`)。到期靠 `expires_at` 自动失效,**无 cron**;
`restrict` 是**替换语义**;级别比较用 `RestrictionAtLeast`(显式权重)。
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
拆成「先 SetPrivate 再 Answer」会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验
(已在 2026-10-19 补;否则「整页停用」和访问密码都能被 API 绕过)。
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**(拦截必须在缓存查询之前,否则 30 分钟内还能拿到旧图)。
- 举报 `POST /_/{domain}/{qid}/report`:公开免登录 + 验证码 + 限流;理由固定枚举;同 IP 同题唯一索引 →
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
- 闭环:箱主删除/设私密或 `hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**:更新后再用带 `is_private=false` 的子查询恒为空集(曾静默失效)。
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却,进程内);单条恰好 3 人举报 → 提醒箱主。
⚠️ 该邮箱为空时静默跳过,无日志;改配置必须重启。
- `/rules` 是处置依据页(四语言);注册页必须勾选 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
## 表单校验
## GORM
- 长度/必填由 `valid:"required;maxlen:N"` 标签在 `Bind()` 阶段执行(`internal/form/form.go:131` govalid.Check),
handler 顶部用 `ctx.HasError()` 承接,文案经 `msg:` 指向 i18n 键。
- ⚠️ **不要再在 handler 手写长度校验**:那是永不触发的死代码,且易与真实上限冲突
(profile 实际 Name≤20 / Intro≤100 / link≤30)。2026-09-15 曾据此误判 P2 并已回滚。
- 判断字段有无校验:**先看 `internal/form/*.go` 的 struct 标签**。
- 默认值必须带引号;`default:` 会把零值替换,空串有语义的字段别加。
- ⚠️ 事务里复用同一 `tx` 会串条件,每句 `tx.WithContext(ctx).<链式>`。
- ⚠️ 软删除 + 唯一索引 = 槽位永久占用:停用(可恢复)= 物理删内容 + 软删用户行,注册侧 `Unscoped` 给
`ErrDeactivatedEmail/Domain`;封禁 = 物理删用户行,邮箱释放。级联删除一律 `Unscoped()`。
- ⚠️ GORM 链式对象 Count/Find 后带状态,统计/预览/更新必须各自重建 query。
## GORM 约定
## 其它约定
- 字符串列默认值必须带引号:`gorm:"default:'none'"`。
- `default:` 会让 GORM 在 Create 时把零值替换为默认值(曾致「取消勾选保存后库里仍 true」)。
「空串有语义」的字段不要加 `default:`。`Updates(map)` 不受影响。
- 启动期「历史数据归一」惯例:`db.Init` 的 AutoMigrate 之后追加幂等函数(`backfillFeatureControls` 等),
失败仅告警不阻断启动。
- ⚠️ **事务里复用同一个 `tx` 句柄会串条件**(事务句柄 clone==0)。每条语句都要重新取句柄:
`tx.WithContext(ctx).<链式>`。曾生成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → 42703。
- ⚠️ **软删除 + 唯一索引 = 槽位被永久占用**。User.email/domain、Conversation.token、VoteSubmission 复合唯一索引
都不含 deleted_at,而 gorm 查重默认带 `deleted_at IS NULL` → 「查重放行、INSERT 被索引拦下」= 500。两种语义:
- **停用(可恢复)**:`users.Deactivate` = `purgeUserContent()` 物理删内容 + **软删用户行**(身份占位)。
邮箱/域名继续占用是**有意为之**;注册侧必须 `Unscoped` 识别并给 `ErrDeactivatedEmail/Domain`
(见 `validate`/`fieldTaken`),**不能**当普通重复邮箱。恢复用 `TamaBox restore`。
- **封禁(不可恢复)**:`BanUserAndCleanup` = `purgeUserContent()` + **物理删用户行**,邮箱释放。
- 级联内容删除一律 `Unscoped()`;`voteSubmissions.DeleteByVoteID` 同理。
- 唯一键冲突兜底 `isDuplicateKeyError(err)`(MySQL 1062 / PG 23505);注册不需要额外兜底。
- ⚠️ 新增 CLI 命令记得同时注册到 `cmd/tamabox.go` 的 `app.Commands`(`ban.go` 曾写好却没注册 → 命令不可达)。
## 文件工具陷阱
- `templates/user/moderation-strictness.html`:**用 Write/Edit 会被注入 `data-page-node-id`**(shell/sed 不会)。
→ 该文件今后只用 shell 改。
- 往 gofmt 对齐组**中间**插 map 键/`//` 注释会打断对齐组 → 污染 diff。新键插在**组末尾**或独立成组。
验证:`tr -d '\r' < f.go > a.go; gofmt a.go > b.go; diff a.go b.go`(无输出=不重排)。
- ⚠️ gofmt 会把 doc comment 里的 `''` 规范化为 `”`(Go 1.19+),导致反复报未格式化。**注释里别用 `''`**。
- ⚠️ gofmt 是 Windows 原生程序,看不到 MSYS `/tmp`;临时比对文件放 Windows 可见路径。
## 会话失效后的落点
- **终止性操作(停用/注销)落「独立公开成功页」**,不要落登录页:既有 `/deactivated`
(`user.DeactivatedProfile` → `templates/user/deactivated.html`)。停用后 `Session.Flush()` 清空会话,
落 `/login` 会让用户以为没生效;该路由必须注册在**公开区**(`reqUserSignIn` 之外)。
- **继续可登录的操作(改密码后重登)才用闪信 + `/login`**:顺序**先 `Session.Flush()` 再 `SetSuccessFlash()`**。
- i18n 键名:`deactivate.*` = 停用表单页;`deactivated.*` = 停用成功页。
- ⚠️ **`templates/embed.go` 是显式清单,不是整目录 embed**:
`//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html`
新增**顶层**模板必须加进这行指令,否则 `ctx.Success("xxx")` 在运行时报
`html/template: "xxx" is undefined` → 500,而**编译期毫无提示**(测试也不渲染该页)。
子目录(`user/` `mail/` `question/` 等)已被目录项整体覆盖,新增其中的文件不用改指令。
2026-10-18 踩过一次:新增 `/rules` 页时漏加,线上只有该页 500。
## 仓库状态
- **git 仓库已损坏**,用户明确要求「不要动 git」。备份/回滚用 tar.gz(排除 `.git`、`.workbuddy`),
放项目同级:`C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,且用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
要开管理页先问:能不能用 CLI 表达?
- git 仓库已损坏,**不要动 git**;备份用 tar.gz 排除 `.git`/`.workbuddy`,放项目同级。
## 测试环境 box-dev-api.337845818.best
- **跑的就是本工作区的构建**(实测本地新模板/路由/文案能在线访问),但**部署有延迟** →
改完代码要提醒用户重新部署,不要假定已生效。
- 站点默认语言 **ja**(`shared.App.Lang()`),匿名断言别写中文,用语言无关标记。
- 验证码是**数学题**(`CaptchaType == "math"`):`GET /api/captcha/generate` 取 `{data:{id,question}}`,
算 `a op b`,提交 `g-recaptcha-response = "<id>:<答案>"`;表单还需页面里的 `_csrf` 隐藏域。
- 可复用端到端巡检脚本:`dev/devcheck-e2e.py`(28 项)——注册/登录/投票**全链路**(建票发布/公开页/匿名提交/
导出计数/草稿/表单型)/登录态接口与导出/匿名提问/停用/重注册被拒/公开页/四语言。
单轮会留下一个已停用的身份占位,清理见「管理 CLI」。
⚠️ 投票单选导出的 CSV **末行是 `总参与,N`**,与选项票数行同形,解析票数时必须单独匹配该行。
- 表单字段名易错:提问 `content`;回答 `answer`。`GET /user/votes` **故意** 302 到 `/user/questions#votes`;
匿名无邮箱提问成功**直接 200 渲染成功页**(不重定向)——都不是 bug。
- 跑本工作区构建,但**部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→
先抓线上 HTML 比对标记,别先怀疑缓存。
- 默认语言 **ja**;四语言文案要一起加(`TestDictionaryComplete` 查齐全)。验证码是数学题:
`GET /api/captcha/generate` → 算 `a op b` → `g-recaptcha-response="<id>:<答案>"`,并带页面 `_csrf`。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(**基线非 200 就别往下走**;`RUN=1` 才真跑;
`trap` 保证撤销)。
- 图片:编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 后会丢),
比例校验用同一套方向(头像 1:1 测不出,只有背景 16:9 触发)。
## 管理 CLI(`TamaBox <cmd>`)
## CLI(`TamaBox <cmd>`)
- ⚠️ **构建入口有两个,必须同步维护**:
- 根目录 `go build .` → 用根 `tamabox.go` 的 `app.Commands`;
- `Dockerfile` 里是 `go build ... -o NekoBox ./cmd/` → 用 `cmd/tamabox.go` 的 `app.Commands`。
两处命令列表必须一致,否则「本机构建能用、Docker 部署没有该命令」(或反之)。`cmd/tamabox.go`
曾是只注册到 `ban` 的过期副本,连 `restore` 都漏了;2026-10-18 已把两处对齐到 8 个命令。
新增命令只改其中一处时,实测报 `No help topic for '<cmd>'`。
- 现有:`web` / `censor` / `uid` / `ban` / `restore` / `reports` / `restrict` / `unrestrict`。
- `restrict --domain|--email --level L1|L2|L3|L4 --days N --reason "..." [--yes]` → 写 `user_restrictions`,
**替换语义**(先撤销该用户所有生效中记录再写新的),到期靠 `expires_at` 比较时间自动失效,无 cron。
- `reports --domain|--email [--days N]` 出证据包(被举报条目 / 公开回答条数 / 占比 / 历史处置);
`reports --pending` 列全站待处理;`reports --stats --days N` 出全站 Top 被举报箱主 + Top 举报 IP
(识别「举报被武器化」)。三者只读。
- `unrestrict --domain|--email` 撤销全部生效中处置。
- `hide-public --domain|--email [--only-reported] [--since N] [--yes]` → 把该箱主「公开且已回答」的题
批量置为私密。`--only-reported` 是推荐用法(只收被投诉的,保留正常内容);`--since N` 限最近 N 天。
⚠️ GORM 链式对象在 Count/Find 后带 Statement 状态,**统计/预览/更新必须各自重新 build query**,
复用一个实例会条件污染。
- `ban --email|--domain [--yes]` → 物理删除用户+内容(不可恢复,邮箱释放)。
- `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。
`--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。
- 本地冒烟:仓库只有 `conf/app.sample.ini`,用 `NEKOBOX_CONFIG_PATH=<ini>` 指定;`conf.Init` 强制
`app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。
## 架构约定:刻意不做站方管理后台
- 项目只有**箱主自己的** `/user` 面板(`reqUserSignIn`),**没有管理员角色、没有站方后台**,用户明确
要求后续也不要加。因此站方的一切运营动作只能落在:**CLI 子命令 / 邮件告警 / 改配置后重启**。
- 判断"要不要新增管理界面"一律先问:能不能用 CLI 表达?能就不用 UI。
- 站方→用户的告知走**模板渲染**(读库里的状态字段渲条幅/独立页),不给运营者开写入口。
- `/user/questions` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。
- ⚠️ **两个构建入口必须同步**:根 `go build .`(根 `tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`(Top 被举报箱主 + Top 举报 IP);
`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
+292
View File
@@ -0,0 +1,292 @@
# TamaBox 命令行操作手册(站方运维)
本项目**没有管理后台**:站方的全部运营动作只落在三类操作上 —— **CLI 命令 / 邮件告警 / 改配置后重启**。
这份文档只讲 CLI,覆盖处置、举报取证、批量隐藏与账号管理。
---
## 0. 前置
### 二进制从哪里来
```bash
# 仓库根目录构建(与 Dockerfile 是两条不同入口,命令列表必须双向同步)
go build -o tamabox .
# Docker 用的是这一条
go build -o NekoBox ./cmd/
```
两处都注册在同一份 `app.Commands` 列表中。**新增命令时必须同时改 `tamabox.go` 和 `cmd/tamabox.go`**,
否则会出现「本机二进制有、线上没有」(或反之),典型报错是 `No help topic for '<cmd>'`。
确认手头二进制包含全部命令:
```bash
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public
```
### 配置文件
CLI 与 `web` 读同一份配置,路径由环境变量指定:
```bash
NEKOBOX_CONFIG_PATH=/path/to/app.ini ./tamabox reports --domain xxx
```
必要条件(缺失会直接启动失败):
- `app.external_url`
- `server.salt`、`server.xsrf_key`
- `[database]` 段的 type / host / port / user / password / name
⚠️ CLI 启动时会跑 **AutoMigrate**(与 web 相同)。这是特性也是风险:迁移有问题时连只读的
`reports` 都跑不起来。做重要操作前先 `pg_dump`。
### 操作前先备份
```bash
pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
```
---
## 1. 命令总览
| 命令 | 作用 | 是否改数据 | 典型用途 |
|---|---|---|---|
| `reports` | 举报取证 / 待处理列表 / 全站统计 | 否 | 处置前看证据 |
| `restrict` | 施加处置(L1–L4) | 是 | 停止分发、冻结发布、停用页面 |
| `unrestrict` | 撤销全部生效中处置 | 是 | 解封、误操作回滚 |
| `hide-public` | 批量把公开回答转为私密 | 是 | 清理存量挂人内容 |
| `ban` | 永久封禁(删用户+内容) | 是(不可逆) | 严重违规账号 |
| `restore` | 恢复已停用账号 | 是 | 用户申诉后恢复 |
| `censor` | 重新审核历史内容 | 是 | 审核规则变更后复检 |
| `uid` | 为用户生成/补齐 uid | 是 | 数据修复 |
| `web` | 启动 Web 服务 | — | 生产入口 |
除 `reports` 外,所有写操作都要求交互式输入大写 `YES` 确认;脚本化时加 `--yes`。
---
## 2. `reports` —— 处置前的取证
三种互斥用法(`--stats` / `--pending` 优先于 `--email/--domain`)。
### 2.1 单个箱主的证据包
```bash
./tamabox reports --domain 52mxtx1314
./tamabox reports --email [email protected] --days 90 --limit 50
```
输出包含:
- **当前处置**:级别、到期时间、原因
- **近 N 天:被举报 X 条 / 公开回答 Y 条(累计,Z%);举报 W 次,其中待处理 V 次**
- **被举报条目**:按举报数降序,带题号、内容前 48 字与状态(`待处理 N` / `已处理`)
- **历史处置**:每条的时间、级别、原因、状态(生效中 / 已撤销 / 已到期)
口径(容易误读,务必看清):
- 分子 = 窗口内**被举报的条目数**(不是举报次数)。同一条被 5 个人举报只算 1 条。
- 分母 = 该箱主**当前累计**的公开回答数,**不是**窗口内产出。
所以 L2 收窄公开之后,分母会变小,比例可能超过 100% —— 这不是 bug,是分母口径问题。
判断「组织化挂人」时优先看**绝对条数**与**指涉对象分布**,比例只作参考。
- 已删除的提问不计入(`deleted_at IS NULL`)。
### 2.2 全站待处理举报
```bash
./tamabox reports --pending --limit 50
```
按箱主分组列出所有 `status = pending` 的举报(不受 30 天窗口限制,方便清理积压)。
### 2.3 全站维度统计(识别「举报被武器化」)
```bash
./tamabox reports --stats --days 7
```
输出两个 Top 10:
- **被举报最多的箱主**(含 domain / 昵称 / 待处理举报数)
- **举报来源 IP 最多**(同一 IP 反复举报同一批内容)
怎么读:
| 现象 | 更可能是 |
|---|---|
| 多个 IP 打同一个箱主,且内容指向多个不同对象 | 组织化挂人 |
| 少量 IP 打多个不同箱主 | 举报被武器化(报复性举报) |
| 同一 IP 单点爆发 | 脚本刷量(已由验证码 + 唯一索引限制) |
⚠️ 统计只覆盖**待处理**举报,且最长回溯 30 天(超期未处理的举报会自动退出统计池)。
### 2.4 举报计数的前提:客户端 IP
举报去重键是 `(question_id, reporter_ip)`,计数与统计全部依赖真实客户端 IP。
- 站点在反向代理 / CDN 之后时,**必须**在 `[security]` 配置 `trust_proxy = true`,
并按需配置 `trusted_proxies`。
- 若 `trust_proxy = false` 而站点实际在代理后,所有访客会被识别成同一个代理 IP →
每条内容最多只有 1 条举报,阈值告警永远不会触发。
- 验证方法:从两个不同网络各举报一次,然后 `reports --pending` 看是否出现两个不同 IP。
---
## 3. `restrict` —— 分级处置
```bash
./tamabox restrict --domain <domain> --level L1 --days 7 --reason "组织挂人投稿"
./tamabox restrict --email <email> --level L3 --days 14 --reason "复发" --yes
```
参数:
| 参数 | 说明 |
|---|---|
| `--domain` / `--email` | 二选一;都填时按 email 查 |
| `--level` | `L1` / `L2` / `L3` / `L4`,默认 L1,大小写敏感 |
| `--days` | 生效天数,从当前时间起算,必须 > 0 |
| `--reason` | **必填**,写入审计记录 |
| `--yes` | 跳过 `YES` 确认 |
### 级别语义
| 级别 | 名称 | 实际效果 | 生效点 |
|---|---|---|---|
| **L1** | 停止分发 | 单题/整箱分享卡不可生成;从首页、探索、标签检索、随机位摘除;热门标签不再出现 | `share.go`、`users.go` |
| **L2** | 收窄公开 | L1 全部 + 新回答强制私密(单条 UPDATE 同时写回答与 `is_private`)+ 禁止「设为公开」+ 禁止回答追问 | `question.go`、`follow_up.go` |
| **L3** | 冻结发布 | L2 全部 + 禁止回答(**仍可收信**) | `question.go` |
| **L4** | 页面停用 | 整个 `/_/{domain}` 全部 302 到 `/restricted`(单题、私聊、分享卡、JSON 列表接口一并挡住) | `page.go` Pager + `ListAPI` |
设计要点:
- **L3 不禁收信**。伤害来自「发布」,剥夺收信能力没有治理收益,还会给对方「平台封口」的说法。
- **到期自动解除**:判定是 `expires_at > now()`,没有定时任务,也不需要手工解封。
- **替换语义**:同一用户再次 `restrict` 会先撤销旧的生效记录再写新的,任何时刻只有一条生效。
升级(L1→L3)与降级(L3→L1)都走这条路径。
- **处置期间审核严格度锁定为最高档**(`strict`),箱主无法自行改档;解禁后自动恢复他原本的设置
(`users.moderation_strictness` 全程未被改写)。
### 副作用(需要提前知道)
- 处置期间箱主在自己后台能看到橙色条幅(级别 + 到期日 + 规则页链接)。
- **L1 就强制 strict**:L1 只停分发、内容仍能发布,但审核已按最严处理,
正常的吐槽式提问也可能被拦。若线上发现大面积误拒,把 `moderation.EffectiveStrictness`
改成「L1 沿用原设置」即可(一行)。
- **没有任何邮件通知箱主**。当前只有登录后的条幅,纯邮件用户不会知道。
---
## 4. `unrestrict` —— 撤销
```bash
./tamabox unrestrict --domain <domain>
./tamabox unrestrict --email <email>
```
- 把该用户**所有生效中**的处置写 `revoked_at`(保留记录,不删除,审计可追溯)。
- 幂等:没有生效中处置时输出「没有生效中的处置,无需操作」。
- 撤销后:页面/发布/分享卡立即恢复;`hide-public` 已经转为私密的内容**不会**自动变回公开,
需要箱主自己逐条「设为公开」。
---
## 5. `hide-public` —— 清理存量内容
```bash
# 最推荐:只隐藏「有未处理举报」的公开回答
./tamabox hide-public --domain <domain> --only-reported
# 全部公开回答(激进的兜底)
./tamabox hide-public --domain <domain>
# 只处理最近 30 天
./tamabox hide-public --domain <domain> --only-reported --since 30
```
| 参数 | 说明 |
|---|---|
| `--only-reported` | 只处理有 `pending` 举报的题目 |
| `--since N` | 只处理最近 N 天创建的题目;0 = 不限 |
| `--yes` | 跳过确认 |
行为:
- 只动「公开且已回答」的题(`answer != '' AND is_private = false`),不碰私密与未回答。
- 执行前打印总数 + 最新 10 条预览。
- **可逆**:只改可见性,箱主可逐条改回公开(但该箱主处于 L2 及以上时改不回去)。
- 隐藏成功后自动把相关 `pending` 举报标记为 `accepted`,让它们退出告警统计池。
- 单独用它不足以「停止分发」,建议配合 `restrict --level L1`。
---
## 6. 账号管理(`ban` / `restore`)
```bash
# 永久封禁:物理删除用户与其内容,邮箱释放。不可恢复。
./tamabox ban --email [email protected] --yes
./tamabox ban --domain xxx --yes
# 恢复「自助停用」的账号(软删除行),可同时重置密码
./tamabox restore --email [email protected]
./tamabox restore --email [email protected] --password 'NewPassw0rd' --yes
```
两者的区别(涉及唯一索引,容易踩):
- **停用(可恢复)**:内容物理删除 + **用户行软删除**。邮箱/域名继续被占用是**有意为之**,
恢复靠 `restore`。
- **封禁(不可恢复)**:内容物理删除 + **用户行物理删除**,邮箱立即可重新注册。
- `restore --password` 必须在恢复之后执行,否则改不到(普通作用域查不到软删除行)。
---
## 7. 典型处置流程
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
3. **冷静期**:举报后 24 小时内不作判定(避免被集中举报推着走,也避免情绪化处置)。
4. **止血**:`restrict --level L1 --days 7 --reason "..."`(静默生效,不单独通知)。
5. **清存量**:`hide-public --domain <domain> --only-reported`。
6. **观察**:7 天内复发 → 升 L2/L3;双方互挂 → **两边同级别处理,不选边**。
7. **收尾**:到期自动解除;需要提前解封用 `unrestrict`。
8. **留痕**:`reports --domain <domain>` 的历史处置段落就是处置记录(谁、何时、级别、原因)。
判定「挂人」的三要件(同时成立才处理):
- **可识别性**:能让圈内人定位到具体账号/作品/主页(昵称、代称、@、外链、作品标签)
- **贬损或号召规避**:带「避雷/挂/别信/辱骂」这类定性,而不是中性描述
- **无公共利益**:纯私人纠纷、粉籍立场之争
只成立一两条 → 不处理或仅提示。
---
## 8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| `No help topic for 'xxx'` | 构建入口不同步(根 `tamabox.go` vs `cmd/tamabox.go`) | 两处都注册后重新编译 |
| `find user: record not found` | `--domain` 填了昵称/登录名 | domain 是 URL 里的那一段,`/_/{domain}` |
| 处置后页面无变化 | 二进制是旧的 / 服务没重启 | `--help` 看命令在不在;抓线上 HTML 比对标记 |
| 待处理列表是空的 | 举报已被箱主处理(自动 accepted)或超过 30 天 | 正常;必要时直接查库 |
| 告警邮件一直没收到 | `[security] security_alert_email` 为空(静默跳过,无日志) | 配好并**重启** |
| 举报数始终是 1 | `trust_proxy` 未按部署环境配置,全部识别为代理 IP | 见 §2.4 |
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
改完配置**必须重启**才生效(配置只在启动时读取一次)。
---
## 9. 明确不做的事
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
- **不用配置文件当黑名单**:无法表达「N 天后自动解除」,最终会变成手工维护灾难。
- **不要在 CLI 之外直接改库**:`user_restrictions` 的写入必须带 `revoked_at` 语义,
手工 UPDATE 容易留下多条同时生效的记录(读取端会取最高级别,但不便于审计)。
+14 -2
View File
@@ -114,7 +114,19 @@ func runHidePublic(ctx *cli.Context) error {
}
}
result := build().Update("is_private", true)
// ⚠️ 必须先快照目标 ID 再更新:build() 的条件里有 is_private = false,
// 一旦执行了 Update,再拿 build() 当子查询就恒为空集,举报闭环会静默失效
// (实测:隐藏成功但 content_reports 仍是 pending)。
var targetIDs []uint
if err := build().Pluck("id", &targetIDs).Error; err != nil {
return errors.Wrap(err, "collect target question ids")
}
if len(targetIDs) == 0 {
fmt.Println("没有需要隐藏的内容")
return nil
}
result := database.Model(&db.Question{}).Where("id IN ?", targetIDs).Update("is_private", true)
if result.Error != nil {
return errors.Wrap(result.Error, "hide public questions")
}
@@ -129,7 +141,7 @@ func runHidePublic(ctx *cli.Context) error {
// 否则这些举报会继续计入告警阈值(详见 internal/db/report.go 的说明)。
accepted := database.WithContext(ctx.Context).
Model(&db.ContentReport{}).
Where("question_id IN (?)", build().Select("id")).
Where("question_id IN ?", targetIDs).
Where("status = ?", db.ReportStatusPending).
Update("status", db.ReportStatusAccepted)
if accepted.Error != nil {
+35 -15
View File
@@ -141,8 +141,13 @@ func runRestrict(ctx *cli.Context) error {
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
if activeLevel != "" {
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n",
activeLevel, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason)
// active 理论上不会为 nil(有级别就有记录),但脏数据/并发下宁可少打一行也不要 panic。
until, reason := "-", "-"
if active != nil {
until = active.ExpiresAt.Format("2006-01-02 15:04")
reason = active.Reason
}
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n", activeLevel, until, reason)
} else {
fmt.Println("当前处置: 无")
}
@@ -212,6 +217,7 @@ type reportedQuestionRow struct {
Content string
CreatedAt time.Time
ReportCount int64
PendingCount int64 // 其中仍为 pending 的条数(已处理的举报仍保留在证据里)
}
// pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。
@@ -261,8 +267,12 @@ func printUserReport(database *gorm.DB, ctx *cli.Context) error {
if level == "" {
fmt.Println("当前处置: 无")
} else {
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n",
level, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason)
until, reason := "-", "-"
if active != nil {
until = active.ExpiresAt.Format("2006-01-02 15:04")
reason = active.Reason
}
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n", level, until, reason)
}
// 公开回答总数:用于计算「被举报内容占其公开产出」的比例,
@@ -278,19 +288,21 @@ func printUserReport(database *gorm.DB, ctx *cli.Context) error {
var rows []*reportedQuestionRow
if err := database.WithContext(ctx.Context).
Raw(`SELECT q.id AS id, q.content AS content, q.created_at AS created_at,
COUNT(r.id) AS report_count
COUNT(r.id) AS report_count,
SUM(CASE WHEN r.status = ? THEN 1 ELSE 0 END) AS pending_count
FROM questions q
JOIN content_reports r ON r.question_id = q.id
WHERE q.user_id = ? AND r.created_at >= ?
WHERE q.user_id = ? AND q.deleted_at IS NULL AND r.created_at >= ?
GROUP BY q.id, q.content, q.created_at
ORDER BY report_count DESC, q.created_at DESC
LIMIT ?`, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil {
LIMIT ?`, db.ReportStatusPending, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil {
return errors.Wrap(err, "query reported questions")
}
var totalReports int64
var totalReports, totalPending int64
for _, row := range rows {
totalReports += row.ReportCount
totalPending += row.PendingCount
}
// 比例的分母是「公开回答总数」,分子必须是「被举报的条目数」——
// ⚠️ 不能拿举报次数当分子:同一条内容可被多人举报,次数可以超过条目数,
@@ -300,16 +312,24 @@ func printUserReport(database *gorm.DB, ctx *cli.Context) error {
ratio = float64(len(rows)) / float64(publicAnswered) * 100
}
fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(%.1f%%),累计 %d 次举报\n",
days, len(rows), publicAnswered, ratio, totalReports)
// 分母是「当前累计公开回答」,不是窗口内的产出 —— 文案必须写清「累计」,
// 否则读者会以为分子分母同窗口(实测箱主被 L2 收窄后分母骤降,比例会超过 100%)。
fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(累计,%.1f%%);举报 %d 次,其中待处理 %d 次\n",
days, len(rows), publicAnswered, ratio, totalReports, totalPending)
if len(rows) == 0 {
fmt.Println("没有举报记录")
} else {
fmt.Println("被举报条目(按举报数降序):")
for _, row := range rows {
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
// 明确区分「总举报」与「仍待处理」:箱主处理过内容后举报会自动变 accepted,
// 只报总数会让人误以为还有 3 条挂着。
state := "已处理"
if row.PendingCount > 0 {
state = fmt.Sprintf("待处理 %d", row.PendingCount)
}
fmt.Printf(" 举报 %-3d #%-6d %s %s(%s)\n",
row.ReportCount, row.ID, row.CreatedAt.Format("2006-01-02 15:04"),
truncateForCLI(row.Content, 48))
truncateForCLI(row.Content, 48), state)
}
}
@@ -349,9 +369,9 @@ func printPendingReports(ctx context.Context, database *gorm.DB, limit int) erro
FROM content_reports r
JOIN questions q ON q.id = r.question_id
JOIN users u ON u.id = q.user_id
WHERE r.status = ?
WHERE r.status = ? AND q.deleted_at IS NULL
GROUP BY u.domain, q.id, q.content
ORDER BY report_count DESC, last_at DESC
ORDER BY u.domain, report_count DESC, last_at DESC
LIMIT ?`, db.ReportStatusPending, limit).Scan(&rows).Error; err != nil {
return errors.Wrap(err, "query pending reports")
}
@@ -395,7 +415,7 @@ func printReportStats(ctx context.Context, database *gorm.DB, days int) error {
return errors.Wrap(err, "query top reporters")
}
fmt.Printf("统计窗口:近 %d 天(仅待处理举报)\n", days)
fmt.Printf("统计窗口:近 %d 天(仅待处理举报;待处理最长回溯 %d 天)\n", days, 30)
fmt.Println(strings.Repeat("-", 60))
fmt.Println("被举报最多的箱主:")
+23
View File
@@ -30,6 +30,12 @@ type QuestionsStore interface {
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error
// AnswerByIDPrivate 写入回答的同时把题目置为私密(站方 L2「收窄公开」)。
//
// 做成单条 UPDATE 而不是「先 SetPrivate 再 AnswerByID」:
// - 两条语句之间存在「公开 + 已作答」的窗口,而 L2 的全部意义就是消灭这个窗口;
// - 若第二条失败,题目会变成「私密 + 无回答」,箱主等于凭空丢了一条内容。
AnswerByIDPrivate(ctx context.Context, id uint, answer string) error
DeleteByID(ctx context.Context, id uint) error
UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error
Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error)
@@ -371,6 +377,23 @@ func (db *questions) getBy(ctx context.Context, cursor *dbutil.Cursor, whereQuer
return questions, nil
}
// AnswerByIDPrivate 回答并强制私密。见接口注释:必须是一条 UPDATE。
func (db *questions) AnswerByIDPrivate(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
Updates(map[string]interface{}{"answer": answer, "is_read": true, "is_private": true}).Error; err != nil {
return errors.Wrap(err, "update question answer as private")
}
return nil
}
func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
+28
View File
@@ -31,6 +31,7 @@ func TestQuestions(t *testing.T) {
{"GetByUserID", testQuestionsGetByUserID},
{"GetByAskUserID", testQuestionsGetByAskUserID},
{"AnswerByID", testQuestionsAnswerByID},
{"AnswerByIDPrivate", testQuestionsAnswerByIDPrivate},
{"DeleteByID", testQuestionsDeleteByID},
{"UpdateCensor", testQuestionsUpdateCensor},
{"Count", testQuestionsCount},
@@ -322,6 +323,33 @@ func testQuestionsAnswerByID(t *testing.T, ctx context.Context, db *questions) {
})
}
// testQuestionsAnswerByIDPrivate 覆盖 L2「收窄公开」的写入语义:
// 回答与 is_private 必须在同一次写入里生效,不能出现「公开 + 已作答」的中间态。
func testQuestionsAnswerByIDPrivate(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.AnswerByIDPrivate(ctx, 1, "Answer - 1")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Answer - 1", got.Answer)
require.True(t, got.IsPrivate)
})
t.Run("not found", func(t *testing.T) {
err := db.AnswerByIDPrivate(ctx, 404, "Answer - 1")
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsDeleteByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
+17 -17
View File
@@ -202,29 +202,29 @@ func (s *restrictions) RevokeActive(ctx context.Context, userID uint) (int64, er
}
func (s *restrictions) ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) {
levels, err := s.ActiveLevels(ctx, []uint{userID})
if err != nil {
return "", nil, err
}
level, ok := levels[userID]
if !ok {
return "", nil, nil
}
var restriction UserRestriction
// 只查一次:这个方法在每次内容提交时都会被调用(发布拦截 + 审核严格度),
// 早先「先 ActiveLevels 再 First」是两次往返,1C1G 上没必要。
var rows []*UserRestriction
if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Where("level = ?", string(level)).
Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now()).
Order("created_at DESC").
First(&restriction).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return level, nil, nil
Find(&rows).Error; err != nil {
return "", nil, errors.Wrap(err, "list active restrictions")
}
return "", nil, errors.Wrap(err, "find active restriction")
// 正常情况最多一条(Create 是替换语义),这里取最高级别以防历史脏数据叠加。
var best *UserRestriction
bestRank := 0
for _, row := range rows {
if rank, ok := restrictionLevelRank[RestrictionLevel(row.Level)]; ok && rank > bestRank {
best, bestRank = row, rank
}
return level, &restriction, nil
}
if best == nil {
return "", nil, nil
}
return RestrictionLevel(best.Level), best, nil
}
func (s *restrictions) ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error) {
+8 -1
View File
@@ -621,7 +621,12 @@ func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort str
var users []*User
var count int64
if err := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ?", true).Count(&count).Error; err != nil {
// 计数必须与下面的列表用同一套过滤,否则总数包含被处置箱主、实际行数更少,
// 首页会出现「最后一页空白」。
if err := db.WithContext(ctx).Model(&User{}).
Where("show_in_home = ?", true).
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count show in home users")
}
@@ -798,6 +803,8 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
if err := db.WithContext(ctx).
Model(&User{}).
Where("show_in_home = ? AND tags IS NOT NULL AND tags != ''", true).
// 被处置箱主的标签不应再被推荐(热门标签是个显眼的曝光位)。
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
Pluck("tags", &tagsList).Error; err != nil {
return nil, errors.Wrap(err, "get user tags for hot tags")
}
@@ -45,6 +45,17 @@ func StrictnessFor(raw string) censor.Strictness {
// 处置查询失败时**回退账号自身设置**,而不是"失败即强制 strict":
// DB 抖动时反向兜底会让全站突然加严,正常用户的提交被大面积误拒,
// 影响面远大于「漏放一个受控账号」。
// EffectiveStrictnessFor 是 EffectiveStrictness 的「已持有处置级别」版本。
//
// 调用方在同一请求里已经查过 ActiveLevel(例如发布前的 L2/L3 拦截)时用它,
// 避免为同一份状态重复打库。
func EffectiveStrictnessFor(level db.RestrictionLevel, raw string) censor.Strictness {
if level != "" {
return censor.StrictnessStrict
}
return StrictnessFor(raw)
}
func EffectiveStrictness(ctx context.Context, userID uint, raw string) censor.Strictness {
if userID != 0 {
if level, _, err := db.Restrictions.ActiveLevel(ctx, userID); err == nil && level != "" {
+1 -1
View File
@@ -176,7 +176,7 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
// 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。
ctx.SetError(err, f)
ctx.Success("question/item")
+14
View File
@@ -281,6 +281,20 @@ func ListAPI(ctx context.Context) error {
return ctx.ServerError()
}
// ⚠️ 这个接口不经过 question.Pager 中间件,必须自己补两道检查:
// 1) L4 整页停用:否则「停用」只挡 HTML 页面,API 仍能整页拉取内容;
// 2) 页面访问密码:Pager 用 session 标记校验,API 不校验就等于密码形同虚设。
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err == nil && level == db.RestrictionL4 {
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
}
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
}
}
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: cursorValue,
+16 -14
View File
@@ -185,26 +185,20 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
// 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), answer)
// 已经查过处置级别,直接用,避免 EffectiveStrictness 再打一次同样的库。
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), answer)
if err != nil {
ctx.SetError(err, f)
ctx.Success("question/item")
return
}
// L2 收窄公开:审核通过后再把该题置为私密,然后写入回答。
// L2 收窄公开:审核通过后写回答,并在同一条 UPDATE 里把题目置为私密。
// 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回,
// 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。
// 放在这里仍然满足「任何时刻都不出现公开 + 已作答」:此刻它还是一条未作答的私密题。
// 也不能拆成「先 SetPrivate 再 AnswerByID」:两条语句之间有「公开 + 已作答」窗口,
// 且第二条失败会留下「私密 + 无回答」(内容凭空消失)。见 AnswerByIDPrivate 注释。
forcedPrivate := db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2)
if forcedPrivate {
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to force question private")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
}
if len(f.Images) > 0 {
image := f.Images[0]
@@ -226,8 +220,14 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
}
}
if err := db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer question")
var answerErr error
if forcedPrivate {
answerErr = db.Questions.AnswerByIDPrivate(ctx.Request().Context(), question.ID, f.Answer)
} else {
answerErr = db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer)
}
if answerErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(answerErr).Error("Failed to answer question")
ctx.SetInternalError(f)
ctx.Success("question/item")
return
@@ -241,7 +241,9 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
}
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" && question.Answer == "" { // We only send the email when the question has not been answered.
// L2 下题目刚被强制转为私密:提问者没有访问权限,带 token 的链接点开也是 302。
// 这时发「你的提问有回答」只会让人以为内容丢了,因此跳过通知。
if question.ReceiveReplyEmail != "" && question.Answer == "" && !forcedPrivate { // We only send the email when the question has not been answered.
// Send notification to questioner.
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
+14
View File
@@ -294,6 +294,20 @@ func ShareCard(ctx context.Context) {
return
}
// 站方处置:任何级别都不再生成整箱分享图(与单题分享卡一致)。
// 放在缓存之前 —— 拦截必须即时生效,否则处置后 30 分钟内仍能拿到旧图。
// DB 异常时按已处置处理(fail-closed):这张图会带上箱主信息与二维码,是外传载体。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.Redirect("/")
return
} else if level != "" {
logrus.WithContext(ctx.Request().Context()).WithField("domain", domain).WithField("level", level).
Info("Profile share card blocked by restriction")
ctx.Redirect("/")
return
}
// 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok {