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 + 🔴/🟠;给修复建议前先做根因取证与实测复现,不要只静态推断。 - 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
- **改代码前先出「方案/变更清单」获批准,再分批落地**。用户常授权自主推进(「你先继续,后面我发现问题再说」),但重大问题仍要显式指出。 - 改代码前先出变更清单获批准;用户常授权自主推进,但重大问题仍要显式指出。
- 工作场景用 `@scene#N:"名称"` 标记。 - 用户是运营者也是圈内人:处置功能必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试 ## 构建与测试
- **本机无 libwebp/GCC**,`chai2010/webp`(cgo) 会挡住 `go build ./...`;必须用 overlay 把 - **无 libwebp/GCC**,cgo 的 `internal/storage/s3.go` 挡编译 → 用 overlay 换 `dev/s3stub/s3_stub.go`:
`internal/storage/s3.go` 换成 `dev/s3stub/s3_stub.go`: `-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`。
`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`(`dev/overlay.json` 里的短名路径可能失效)。 但 overlay 会把它当桩,**检查 s3.go 本身要 tar 复制项目 + `go mod edit -replace`**(overlay 不能替换 GOMODCACHE 文件)。
- **「编译卡死」真因 = `proxy.golang.org` 不可达**(本沙箱 curl 超时;`goproxy.cn` 正常)。 - **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前:
每条 go 命令前必须:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。 `export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。`go env -w` 在本沙箱失败。
代理正确时 `go build ./...` EXIT=0、`./route/user/` 约 7s。**超时 ≠ 慢**:先用 `go list -deps` 拆开「模块解析/编译」两层。 1C1G 是正式生产环境,**别用「沙箱慢」解释超时**。
`go env -w` 在本沙箱失败(无 `%AppData%`),无法持久化。**1C1G 是用户正式生产环境,禁止用「沙箱慢」解释超时。** - DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
- `internal/db` 测试需真实 DB。本地 PG:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm (DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 目前全绿。
DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名,`NewTestDB` 自建 `tamabox-test-<rand>` 并 DROP)。 - store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 例外,有守卫)。
2026-09-15 起「带 DB 的 `go test ./...`」**全绿**;此前的失败**全是测试自身缺陷**,已修。 - ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定走 redis,6379 未开,启动即 panic)→ HTTP 端到端只能部署后做。
- ⚠️ `internal/db` 包级 store 单例只在 `db.Init` 装配;store 方法内**不要引用包级全局**,用 `NewXStore(db.DB)`, 探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
否则测试下 nil panic。唯一例外:`feature_control.go` 的 `FeatureStateOf`(有 nil 守卫)。 - ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常),曾误判「接口卡死」。线上验证优先 curl;
- **行尾是混合的,不能按规律推断**:`internal/db/*`、`internal/mail/mail.go`、`route/user/profile.go` 是 LF; python 别用 `/tmp`(Windows 解析成 `C:\tmp`,与 MSYS `/tmp` 不通)。
`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`,两者不通)。
## 图片上传与编码(`internal/storage/s3.go`) ## 行尾 / 模板
- 三档编码规格 `EncodeSpec{MaxSide, Quality}`:头像 512/q78、背景 1920/q80、通用 1600/q80。 - 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
头像依据:模板最大展示 120px(`.tb-frame-avatar`),前端 cropper 输出上限 512。 `users.go`、`i18n/dict_*.go`、`route/question/*.go`、`question-list.html` 是 CRLF)。改前用 Python 数 `b'\r\n'`;
⚠️ 档位只作用于**新上传**,线上已有图片不会被重压。 `gofmt -l` 会把 CRLF 误报为未格式化。格式化走 `dev/gofmt_keep_crlf.py`。
- 所有转换分支都有 `len(webp) >= len(原图)` 回退——线框图/小图标上有损 WebP 反而更大 - 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 会改成 `”`)。
(实测 200x200 线框图 PNG 652B vs WebP 908B),不加兜底会越转越大。 - ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed` 那行,否则运行时
- **EXIF 方向必须在转码前归一**(`exif.go` 手工解析 APP1,无外部依赖)。 `html/template: "xxx" is undefined` → 500,编译期无提示(`checktpl` 也只查语法)。子目录已被目录项覆盖。
`image.Decode` 与 Pillow 默认都不读 EXIF;**转成 WebP 后 EXIF 被丢弃、方向永久丢失**。 - `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
顺序铁律:**先归一方向 → 再限边缩放**(归一会交换宽高)。
⚠️ **比例校验必须用同一套方向**(`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`。
## 审核
## 内容审核 - 线上通道 = 七牛(`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 空。 ## 提示(Flash)
- 通道可配:`[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。⚠️ 推论:显式选定的通道一旦坏掉,
整站发布+私信全被拒且现象隐蔽(只在日志)。
### 阿里云 green(`/green/text/scan`) - ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,要下一次请求才读 →
- 实现与上游/官方示例一致;`SetContentType(requests.Json)` 仅为接口契约对齐,**已证伪与故障无关**。 提示静默丢失(「点了没反应」)。2026-10-18 已清理 9 处;`Set*Flash + Redirect` 是正常模式。
- 根因(2026-09-10 实证):`code=596 "You have not opened Yundun Content Moderation Service"`, 扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(` → 前者即缺陷。
阿里云**请求被拒也回 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(区分「审核失败」与「真违规」)。
## 提示与 Flash(route 层) ## 处置与举报
- ⚠️ **同请求渲染必须用 `ctx.SetError` / `ctx.SetSuccess`,不能用 `*Flash`**:`ctx.Success("...")` 直接出页面时, - L1 停止分发(禁分享卡 + 首页/探索/标签/随机位摘除)/L2 收窄公开(新回答强制私密、禁止 set-public)/
`SetErrorFlash` 写的是 session、要等**下一次请求**才会被读取,提示会静默丢失(表现为「点了没反应」)。 L3 冻结发布(仍可收信)/L4 整页停用(`/restricted`)。到期靠 `expires_at` 自动失效,**无 cron**;
`*Flash` 只适用于 `Redirect` 之后再渲染的页面。 `restrict` 是**替换语义**;级别比较用 `RestrictionAtLeast`(显式权重)。
2026-10-18 实测:L3 拒绝发布时箱主完全看不到提示,改 `SetError` 后正常显示。 - ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
- 项目里仍有若干 `SetErrorFlash + Success("question/item")` 的既有组合(`follow_up.go` 的 errNotFound、 拆成「先 SetPrivate 再 Answer」会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
`question.go` 的上传图片过大等),同属该缺陷,遇到时顺手改成 `SetError`。 - ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验
- ✅ **2026-10-18 已完成全量清理**:扫描 `route/**/*.go` 中 `Set*Flash` / `SetInternalErrorFlash` (已在 2026-10-19 补;否则「整页停用」和访问密码都能被 API 绕过)。
后 8 行内跟 `ctx.Success(...)`(而非 `Redirect`)的位置,共 **9 处**:`follow_up.go` 2 处、 - ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**(拦截必须在缓存查询之前,否则 30 分钟内还能拿到旧图)。
`page.go` 1 处、`question.go` 1 处、`profile.go` 5 处,全部改为 `SetError(err[, f])` / `SetInternalError()`。 - 举报 `POST /_/{domain}/{qid}/report`:公开免登录 + 验证码 + 限流;理由固定枚举;同 IP 同题唯一索引 →
`Set*Flash + Redirect` 112 处属正常模式,未动。`vote.go:702/704` 是扫描器误报(其后是 Redirect)。 **幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
扫描脚本思路可复用:逐行找 flash 调用 → 向下找第一个 `Success(`/`Redirect(` → 前者即缺陷。 - 闭环:箱主删除/设私密或 `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), - 默认值必须带引号;`default:` 会把零值替换,空串有语义的字段别加。
handler 顶部用 `ctx.HasError()` 承接,文案经 `msg:` 指向 i18n 键。 - ⚠️ 事务里复用同一 `tx` 会串条件,每句 `tx.WithContext(ctx).<链式>`。
- ⚠️ **不要再在 handler 手写长度校验**:那是永不触发的死代码,且易与真实上限冲突 - ⚠️ 软删除 + 唯一索引 = 槽位永久占用:停用(可恢复)= 物理删内容 + 软删用户行,注册侧 `Unscoped` 给
(profile 实际 Name≤20 / Intro≤100 / link≤30)。2026-09-15 曾据此误判 P2 并已回滚。 `ErrDeactivatedEmail/Domain`;封禁 = 物理删用户行,邮箱释放。级联删除一律 `Unscoped()`。
- 判断字段有无校验:**先看 `internal/form/*.go` 的 struct 标签**。 - ⚠️ GORM 链式对象 Count/Find 后带状态,统计/预览/更新必须各自重建 query。
## GORM 约定 ## 其它约定
- 字符串列默认值必须带引号:`gorm:"default:'none'"`。 - 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- `default:` 会让 GORM 在 Create 时把零值替换为默认值(曾致「取消勾选保存后库里仍 true」)。 - 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
「空串有语义」的字段不要加 `default:`。`Updates(map)` 不受影响。 - 只有箱主 `/user` 面板,**无管理员角色、无站方后台,且用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- 启动期「历史数据归一」惯例:`db.Init` 的 AutoMigrate 之后追加幂等函数(`backfillFeatureControls` 等), 要开管理页先问:能不能用 CLI 表达?
失败仅告警不阻断启动。 - git 仓库已损坏,**不要动 git**;备份用 tar.gz 排除 `.git`/`.workbuddy`,放项目同级。
- ⚠️ **事务里复用同一个 `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`。
## 测试环境 box-dev-api.337845818.best ## 测试环境 box-dev-api.337845818.best
- **跑的就是本工作区的构建**(实测本地新模板/路由/文案能在线访问),但**部署有延迟** → - 跑本工作区构建,但**部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→
改完代码要提醒用户重新部署,不要假定已生效。 先抓线上 HTML 比对标记,别先怀疑缓存。
- 站点默认语言 **ja**(`shared.App.Lang()`),匿名断言别写中文,用语言无关标记。 - 默认语言 **ja**;四语言文案要一起加(`TestDictionaryComplete` 查齐全)。验证码是数学题:
- 验证码是**数学题**(`CaptchaType == "math"`):`GET /api/captcha/generate` 取 `{data:{id,question}}`, `GET /api/captcha/generate` → 算 `a op b` → `g-recaptcha-response="<id>:<答案>"`,并带页面 `_csrf`。
算 `a op b`,提交 `g-recaptcha-response = "<id>:<答案>"`;表单还需页面里的 `_csrf` 隐藏域。 - 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 可复用端到端巡检脚本:`dev/devcheck-e2e.py`(28 项)——注册/登录/投票**全链路**(建票发布/公开页/匿名提交/ - 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(**基线非 200 就别往下走**;`RUN=1` 才真跑;
导出计数/草稿/表单型)/登录态接口与导出/匿名提问/停用/重注册被拒/公开页/四语言。 `trap` 保证撤销)。
单轮会留下一个已停用的身份占位,清理见「管理 CLI」。 - 图片:编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 后会丢),
⚠️ 投票单选导出的 CSV **末行是 `总参与,N`**,与选项票数行同形,解析票数时必须单独匹配该行。 比例校验用同一套方向(头像 1:1 测不出,只有背景 16:9 触发)。
- 表单字段名易错:提问 `content`;回答 `answer`。`GET /user/votes` **故意** 302 到 `/user/questions#votes`;
匿名无邮箱提问成功**直接 200 渲染成功页**(不重定向)——都不是 bug。
## 管理 CLI(`TamaBox <cmd>`) ## CLI(`TamaBox <cmd>`)
- ⚠️ **构建入口有两个,必须同步维护**: - ⚠️ **两个构建入口必须同步**:根 `go build .`(根 `tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
- 根目录 `go build .` → 用根 `tamabox.go` 的 `app.Commands`; 否则 `No help topic for '<cmd>'`。
- `Dockerfile` 里是 `go build ... -o NekoBox ./cmd/` → 用 `cmd/tamabox.go` 的 `app.Commands`。 - 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
两处命令列表必须一致,否则「本机构建能用、Docker 部署没有该命令」(或反之)。`cmd/tamabox.go` - `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
曾是只注册到 `ban` 的过期副本,连 `restore` 都漏了;2026-10-18 已把两处对齐到 8 个命令。 `reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`(Top 被举报箱主 + Top 举报 IP);
新增命令只改其中一处时,实测报 `No help topic for '<cmd>'`。 `hide-public [--only-reported] [--since N]`。
- 现有:`web` / `censor` / `uid` / `ban` / `restore` / `reports` / `restrict` / `unrestrict`。 - 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- `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` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。
+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 { if result.Error != nil {
return errors.Wrap(result.Error, "hide public questions") return errors.Wrap(result.Error, "hide public questions")
} }
@@ -129,7 +141,7 @@ func runHidePublic(ctx *cli.Context) error {
// 否则这些举报会继续计入告警阈值(详见 internal/db/report.go 的说明)。 // 否则这些举报会继续计入告警阈值(详见 internal/db/report.go 的说明)。
accepted := database.WithContext(ctx.Context). accepted := database.WithContext(ctx.Context).
Model(&db.ContentReport{}). Model(&db.ContentReport{}).
Where("question_id IN (?)", build().Select("id")). Where("question_id IN ?", targetIDs).
Where("status = ?", db.ReportStatusPending). Where("status = ?", db.ReportStatusPending).
Update("status", db.ReportStatusAccepted) Update("status", db.ReportStatusAccepted)
if accepted.Error != nil { if accepted.Error != nil {
+39 -19
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) fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
if activeLevel != "" { if activeLevel != "" {
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n", // active 理论上不会为 nil(有级别就有记录),但脏数据/并发下宁可少打一行也不要 panic。
activeLevel, 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", activeLevel, until, reason)
} else { } else {
fmt.Println("当前处置: 无") fmt.Println("当前处置: 无")
} }
@@ -208,10 +213,11 @@ func runUnrestrict(ctx *cli.Context) error {
// reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。 // reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。
type reportedQuestionRow struct { type reportedQuestionRow struct {
ID uint ID uint
Content string Content string
CreatedAt time.Time CreatedAt time.Time
ReportCount int64 ReportCount int64
PendingCount int64 // 其中仍为 pending 的条数(已处理的举报仍保留在证据里)
} }
// pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。 // pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。
@@ -261,8 +267,12 @@ func printUserReport(database *gorm.DB, ctx *cli.Context) error {
if level == "" { if level == "" {
fmt.Println("当前处置: 无") fmt.Println("当前处置: 无")
} else { } else {
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n", until, reason := "-", "-"
level, active.ExpiresAt.Format("2006-01-02 15:04"), active.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 var rows []*reportedQuestionRow
if err := database.WithContext(ctx.Context). if err := database.WithContext(ctx.Context).
Raw(`SELECT q.id AS id, q.content AS content, q.created_at AS created_at, 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 FROM questions q
JOIN content_reports r ON r.question_id = q.id 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 GROUP BY q.id, q.content, q.created_at
ORDER BY report_count DESC, q.created_at DESC 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") return errors.Wrap(err, "query reported questions")
} }
var totalReports int64 var totalReports, totalPending int64
for _, row := range rows { for _, row := range rows {
totalReports += row.ReportCount 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 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 { if len(rows) == 0 {
fmt.Println("没有举报记录") fmt.Println("没有举报记录")
} else { } else {
fmt.Println("被举报条目(按举报数降序):") fmt.Println("被举报条目(按举报数降序):")
for _, row := range rows { 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"), 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 FROM content_reports r
JOIN questions q ON q.id = r.question_id JOIN questions q ON q.id = r.question_id
JOIN users u ON u.id = q.user_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 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 { LIMIT ?`, db.ReportStatusPending, limit).Scan(&rows).Error; err != nil {
return errors.Wrap(err, "query pending reports") 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") 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(strings.Repeat("-", 60))
fmt.Println("被举报最多的箱主:") fmt.Println("被举报最多的箱主:")
+23
View File
@@ -30,6 +30,12 @@ type QuestionsStore interface {
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error) GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error) GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) 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 DeleteByID(ctx context.Context, id uint) error
UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error
Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, 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 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 { func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) error {
var question Question var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil { 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}, {"GetByUserID", testQuestionsGetByUserID},
{"GetByAskUserID", testQuestionsGetByAskUserID}, {"GetByAskUserID", testQuestionsGetByAskUserID},
{"AnswerByID", testQuestionsAnswerByID}, {"AnswerByID", testQuestionsAnswerByID},
{"AnswerByIDPrivate", testQuestionsAnswerByIDPrivate},
{"DeleteByID", testQuestionsDeleteByID}, {"DeleteByID", testQuestionsDeleteByID},
{"UpdateCensor", testQuestionsUpdateCensor}, {"UpdateCensor", testQuestionsUpdateCensor},
{"Count", testQuestionsCount}, {"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) { func testQuestionsDeleteByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) { t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{ _, err := db.Create(ctx, CreateQuestionOptions{
+18 -18
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) { func (s *restrictions) ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) {
levels, err := s.ActiveLevels(ctx, []uint{userID}) // 只查一次:这个方法在每次内容提交时都会被调用(发布拦截 + 审核严格度),
if err != nil { // 早先「先 ActiveLevels 再 First」是两次往返,1C1G 上没必要。
return "", nil, err var rows []*UserRestriction
}
level, ok := levels[userID]
if !ok {
return "", nil, nil
}
var restriction UserRestriction
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("user_id = ?", userID). Where("user_id = ?", userID).
Where("level = ?", string(level)).
Where("revoked_at IS NULL"). Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now()). Where("expires_at > ?", time.Now()).
Order("created_at DESC"). Find(&rows).Error; err != nil {
First(&restriction).Error; err != nil { return "", nil, errors.Wrap(err, "list active restrictions")
if errors.Is(err, gorm.ErrRecordNotFound) {
return level, nil, nil
}
return "", nil, errors.Wrap(err, "find active restriction")
} }
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) { 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 users []*User
var count int64 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") 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). if err := db.WithContext(ctx).
Model(&User{}). Model(&User{}).
Where("show_in_home = ? AND tags IS NOT NULL AND tags != ''", true). 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 { Pluck("tags", &tagsList).Error; err != nil {
return nil, errors.Wrap(err, "get user tags for hot tags") return nil, errors.Wrap(err, "get user tags for hot tags")
} }
@@ -45,6 +45,17 @@ func StrictnessFor(raw string) censor.Strictness {
// 处置查询失败时**回退账号自身设置**,而不是"失败即强制 strict": // 处置查询失败时**回退账号自身设置**,而不是"失败即强制 strict":
// DB 抖动时反向兜底会让全站突然加严,正常用户的提交被大面积误拒, // 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 { func EffectiveStrictness(ctx context.Context, userID uint, raw string) censor.Strictness {
if userID != 0 { if userID != 0 {
if level, _, err := db.Restrictions.ActiveLevel(ctx, userID); err == nil && level != "" { 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")) 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 在这里不会显示出来(既有缺陷)。 // 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。
ctx.SetError(err, f) ctx.SetError(err, f)
ctx.Success("question/item") ctx.Success("question/item")
+14
View File
@@ -281,6 +281,20 @@ func ListAPI(ctx context.Context) error {
return ctx.ServerError() 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{ pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{ Cursor: &dbutil.Cursor{
Value: cursorValue, Value: cursorValue,
+16 -14
View File
@@ -185,26 +185,20 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
// 🚨 Content security check. // 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed, // 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。 // 拒绝发布而非免审放行)。返回的 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 { if err != nil {
ctx.SetError(err, f) ctx.SetError(err, f)
ctx.Success("question/item") ctx.Success("question/item")
return return
} }
// L2 收窄公开:审核通过后再把该题置为私密,然后写入回答。 // L2 收窄公开:审核通过后写回答,并在同一条 UPDATE 里把题目置为私密。
// 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回, // 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回,
// 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。 // 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。
// 放在这里仍然满足「任何时刻都不出现公开 + 已作答」:此刻它还是一条未作答的私密题。 // 也不能拆成「先 SetPrivate 再 AnswerByID」:两条语句之间有「公开 + 已作答」窗口,
// 且第二条失败会留下「私密 + 无回答」(内容凭空消失)。见 AnswerByIDPrivate 注释。
forcedPrivate := db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) 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 { if len(f.Images) > 0 {
image := 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 { var answerErr error
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer question") 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.SetInternalError(f)
ctx.Success("question/item") ctx.Success("question/item")
return return
@@ -241,7 +241,9 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
} }
shared.SafeGo(func() { 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. // Send notification to questioner.
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil { 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") 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 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) cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok { if cached, ok := shareCardCache.Get(cacheKey); ok {