diff --git a/.workbuddy/memory/2026-10-19.md b/.workbuddy/memory/2026-10-19.md
new file mode 100644
index 0000000..963e911
--- /dev/null
+++ b/.workbuddy/memory/2026-10-19.md
@@ -0,0 +1,47 @@
+# 2026-10-19
+
+## 部署后复测(强制严格档那批)
+
+- 规则页返回首页已居中:线上 `
` ✔
+- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
+- 箱主面板 `/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 无清理(重启即失效)。
diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md
index a6bc24c..bc11ec8 100644
--- a/.workbuddy/memory/MEMORY.md
+++ b/.workbuddy/memory/MEMORY.md
@@ -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-` 并 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 `。
-- `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。
-- ⚠️ **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 直接报错拒绝)。
- 要在本机类型检查依赖 cgo 的真实代码(如 `internal/storage/s3.go`),唯一可行方案:
- **`tar` 复制项目到临时目录 → 在副本里 `go mod edit -replace =../本地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 AND ` → 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 = ":<答案>"`;表单还需页面里的 `_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=":<答案>"`,并带页面 `_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 `)
+## CLI(`TamaBox `)
-- ⚠️ **构建入口有两个,必须同步维护**:
- - 根目录 `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 ''`。
-- 现有:`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=` 指定;`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 ''`。
+- 现有:`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=`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
diff --git a/docs/cli.md b/docs/cli.md
new file mode 100644
index 0000000..4a39e34
--- /dev/null
+++ b/docs/cli.md
@@ -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 ''`。
+
+确认手头二进制包含全部命令:
+
+```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 > 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 user@example.com --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 --level L1 --days 7 --reason "组织挂人投稿"
+./tamabox restrict --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
+./tamabox unrestrict --email
+```
+
+- 把该用户**所有生效中**的处置写 `revoked_at`(保留记录,不删除,审计可追溯)。
+- 幂等:没有生效中处置时输出「没有生效中的处置,无需操作」。
+- 撤销后:页面/发布/分享卡立即恢复;`hide-public` 已经转为私密的内容**不会**自动变回公开,
+ 需要箱主自己逐条「设为公开」。
+
+---
+
+## 5. `hide-public` —— 清理存量内容
+
+```bash
+# 最推荐:只隐藏「有未处理举报」的公开回答
+./tamabox hide-public --domain --only-reported
+
+# 全部公开回答(激进的兜底)
+./tamabox hide-public --domain
+
+# 只处理最近 30 天
+./tamabox hide-public --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 user@example.com --yes
+./tamabox ban --domain xxx --yes
+
+# 恢复「自助停用」的账号(软删除行),可同时重置密码
+./tamabox restore --email user@example.com
+./tamabox restore --email user@example.com --password 'NewPassw0rd' --yes
+```
+
+两者的区别(涉及唯一索引,容易踩):
+
+- **停用(可恢复)**:内容物理删除 + **用户行软删除**。邮箱/域名继续被占用是**有意为之**,
+ 恢复靠 `restore`。
+- **封禁(不可恢复)**:内容物理删除 + **用户行物理删除**,邮箱立即可重新注册。
+- `restore --password` 必须在恢复之后执行,否则改不到(普通作用域查不到软删除行)。
+
+---
+
+## 7. 典型处置流程
+
+1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
+2. **取证**:`reports --domain ` 看条目、占比、历史处置。
+3. **冷静期**:举报后 24 小时内不作判定(避免被集中举报推着走,也避免情绪化处置)。
+4. **止血**:`restrict --level L1 --days 7 --reason "..."`(静默生效,不单独通知)。
+5. **清存量**:`hide-public --domain --only-reported`。
+6. **观察**:7 天内复发 → 升 L2/L3;双方互挂 → **两边同级别处理,不选边**。
+7. **收尾**:到期自动解除;需要提前解封用 `unrestrict`。
+8. **留痕**:`reports --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 容易留下多条同时生效的记录(读取端会取最高级别,但不便于审计)。
diff --git a/internal/cmd/hide_public.go b/internal/cmd/hide_public.go
index e08b629..4c623f3 100644
--- a/internal/cmd/hide_public.go
+++ b/internal/cmd/hide_public.go
@@ -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 {
diff --git a/internal/cmd/restrict.go b/internal/cmd/restrict.go
index 6f00fbd..50c76a1 100644
--- a/internal/cmd/restrict.go
+++ b/internal/cmd/restrict.go
@@ -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("当前处置: 无")
}
@@ -208,10 +213,11 @@ func runUnrestrict(ctx *cli.Context) error {
// reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。
type reportedQuestionRow struct {
- ID uint
- Content string
- CreatedAt time.Time
- ReportCount int64
+ ID uint
+ 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("被举报最多的箱主:")
diff --git a/internal/db/questions.go b/internal/db/questions.go
index 8a42b22..bb78cf5 100644
--- a/internal/db/questions.go
+++ b/internal/db/questions.go
@@ -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 {
diff --git a/internal/db/questions_test.go b/internal/db/questions_test.go
index 779d7e6..8bfb9d6 100644
--- a/internal/db/questions_test.go
+++ b/internal/db/questions_test.go
@@ -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{
diff --git a/internal/db/restriction.go b/internal/db/restriction.go
index f531f03..4aac22a 100644
--- a/internal/db/restriction.go
+++ b/internal/db/restriction.go
@@ -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
- }
- return "", nil, errors.Wrap(err, "find active restriction")
+ Find(&rows).Error; err != nil {
+ return "", nil, errors.Wrap(err, "list active restrictions")
}
- return level, &restriction, nil
+
+ // 正常情况最多一条(Create 是替换语义),这里取最高级别以防历史脏数据叠加。
+ var best *UserRestriction
+ bestRank := 0
+ for _, row := range rows {
+ if rank, ok := restrictionLevelRank[RestrictionLevel(row.Level)]; ok && rank > bestRank {
+ best, bestRank = row, rank
+ }
+ }
+ 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) {
diff --git a/internal/db/users.go b/internal/db/users.go
index 5c9c2a6..3c6bb16 100644
--- a/internal/db/users.go
+++ b/internal/db/users.go
@@ -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")
}
diff --git a/internal/security/moderation/moderation.go b/internal/security/moderation/moderation.go
index 6a93bf6..d6987b0 100644
--- a/internal/security/moderation/moderation.go
+++ b/internal/security/moderation/moderation.go
@@ -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 != "" {
diff --git a/route/question/follow_up.go b/route/question/follow_up.go
index 494378b..dacd3b0 100644
--- a/route/question/follow_up.go
+++ b/route/question/follow_up.go
@@ -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")
diff --git a/route/question/page.go b/route/question/page.go
index df90e3c..9e6948a 100644
--- a/route/question/page.go
+++ b/route/question/page.go
@@ -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,
diff --git a/route/question/question.go b/route/question/question.go
index eb1fbf7..0a86251 100644
--- a/route/question/question.go
+++ b/route/question/question.go
@@ -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")
diff --git a/route/question/share.go b/route/question/share.go
index d8b8a3b..af9f989 100644
--- a/route/question/share.go
+++ b/route/question/share.go
@@ -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 {