Files
tamabox/.workbuddy/memory/2026-09-15.md
T
2026-09-15 18:20:06 +08:00

422 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-09-15 工作日志
## 落地 P2 推荐批次 #1–3(审核链路一致性 / 文档)
承接 09-11 的全库审阅(无 P0/P1,8 项 P2)。上次征询修复批次未获选择,本次按「继续 +
我标注的建议先做」自主推进**最低风险的一致性/文档类 #1–3**,未动 #4–8(需另行确认)。
### 已改(`internal/security/censor/`)
1. **#1 缓存/送审归一化统一**(censor.go:66-68,76,122,131)
- 原先:缓存按**原文** `text` 查 `GetByText`,实际送审 `RemoveTrustedURL(text)`,落库 `Input:text`。
→ 审计记录与实际送审内容不符;归一化后相同的原文会各占一条缓存、重复调 API。
- 现在:循环外算一次 `normalized := RemoveTrustedURL(text)`,缓存查找 / 送审 / `Input` 三者统一。
- 安全性论证:归一化是纯函数确定性映射;`TrustedHosts` 增减只会让旧条目 miss(重新送审),
不会让「已通过」的旧缓存被错配到新文本上 → 无漏放风险。
2. **#2 阿里云缓存回放分支与七牛对齐**(censor.go:99-110)
- 原先:aliyun 命中缓存**不看 Pass 直接 return**,依赖「aliyun 恒为链尾」这一隐式前提。
- 现在:与 qiniu 一致 —— 仅 `Pass` 才 return,否则 append + continue 交给下一通道复核。
- 等价性:aliyun 现为链尾,continue 后循环结束仍由 `responses[len-1]` 返回**同一份响应**,行为不变;
但消除了顺序耦合,将来若前置 aliyun 也不会短路后续通道。
3. **#3 修正失真注释**(text.go:66)
- 原注释称 `Details`「回放缓存结果时为 nil」**是错的**:
`AliyunTextCensorParser`(aliyun.go:433-461) 与 `QiniuTextCensorParser`(qiniu.go:133-170)
都会从 `RawResponse` 重建 `Details` → 缓存回放同样受严格度策略约束,改档位对历史缓存也生效。
- 已改为准确描述。**结论:审核缓存回放无 fail-open**(09-11 的怀疑已排除)。
### 验证
- `gofmt_keep_crlf.py` 两个文件 exit=0,CRLF 保留(censor 147 / text 141,loneLF=0)。
- `go build ./internal/security/censor/...` **EXIT=0**(定向构建,~秒级;用 goproxy.cn + overlay)。
- `go test ./internal/security/censor/` **ok 2.467s** —— 确认严格度策略与回放行为未被破坏。
### 顺带已完成 #6
- **#6 `User.Email` 改 `json:"-"`**(users.go:89)。
查证:`route/` 与 `internal/` 中**无任何非测试代码读取序列化后的 `.Email`**(模板里的 email 均为表单字段名 /
邮件模板,属 Go template 服务端渲染,不受 json tag 影响;输入走 `CreateUserOptions`/`UpdateUserOptions`,
不经 `User` 模型反序列化)→ 无消费方受损。
验证:gofmt exit=0,`go build ./internal/db/` **EXIT=0**。
### 待确认(P2 #4/#5/#7/#8)
| # | 项 | 我的建议 |
|---|---|---|
| 4 | 投票配额「插入后计数」非原子(route/vote/vote.go:814-822) | 暂缓:需 DB 层条件插入,超额有界且自动结束自愈 |
| 5 | ~~profile Name/Intro/link 无长度校验~~ | **误报,已关闭**:表单层已有校验,见下方更正 |
| 7 | `conf.Database.Schema` 拼 CREATE SEQUENCE(db.go:215-222) | 可选:配置来源非远端输入,纵深防御 |
| 8 | `RemoveTrustedURL` 24 次整串重建(text.go:114-141) | 可跳过:短文本开销可忽略(#1 已顺带把调用从循环内降到 1 次) |
#### ✅ #5 更正:是**误报**,已按「报错式校验」实现后**全部回滚**
- 先按用户选择实现了一版(Name 50 / Intro 500 / link 500 rune,4 语 i18n 词条),随后核对
`internal/form/user.go` 时发现**表单层早已有校验**:
- `Name` → `valid:"required;maxlen:20"`
- `Intro` → `valid:"required;maxlen:100"`
- 各 link → `valid:"maxlen:30"`
- 且确实生效:`internal/form/form.go:16` 引入 `govalid`,`:131` 在 `Bind()` 中 `govalid.Check()`;
handler 顶部 `ctx.HasError()` 承接;报错文案走标签 `msg:` 对应的 i18n 键
(`form.profile.name`="不超过 20 个字符"、`form.profile.intro`=100、`form.profile.linkMax`=30)。
- 结论:我那版是**永不触发的死代码**(超限在 Bind 阶段就被拒),且上限与真实值冲突 → 已完整回滚
(profile.go 的 utf8 导入 / 常量 / 校验块 + 4 份字典词条),`git status` 确认 5 个文件零 diff。
- 教训已写入 MEMORY.md「表单校验(勿重复造轮子)」:查某字段有无校验,**先看 `internal/form/*.go` 的 struct 标签**。
- 附带修正:Postgres 下 `string` 映射为 `text`(无 DB 层长度上限)这一点本身**没错**,
但不构成漏洞 —— 因为表单层已经挡住了。
### 环境
- ~~重包 `./route/user/ ./internal/route/ ./internal/db/` 仍会 30 分钟超时 → 判定为 1C1G 沙箱编译整图慢~~
**❌ 该结论已被推翻,见文末「编译卡死」误判更正。全库 `go build ./...` 实测 EXIT=0。**
---
## 「编译卡死」误判更正(用户指出:1C1G 是正式环境,不是沙箱)
用户明确纠正:**1C1G 是正式生产环境**,我不该拿「沙箱慢」当挡箭牌解释构建超时。重新取证后确认:
**根本没有编译问题,是我把代理挂起误判成了编译慢。**
### 取证过程
| 步骤 | 命令 | 结果 | 结论 |
|---|---|---|---|
| 隔离模块解析 | `go list -deps ./route/user/` | **2.3s EXIT=0** | 模块全在本地缓存,网络不是瓶颈 |
| 编译目标包 | `go build -overlay=... ./route/user/` | **7s EXIT=0** | 之前 240s 超时是伪命题 |
| 关键包逐个 | route/ internal/route/ question/ auth/ security/... db/ mail/ | **全 OK** | |
| 全量 | `go build -overlay=... ./...` | **EXIT=0** | |
| 静态检查 | `go vet -overlay=...` 7 个包 | **EXIT=0** | |
| 测试 | censor/autoban/ipinfo/route/conf/form | **全部 ok**,EXIT=0 | |
**真因**:前几次超时是 `GOPROXY` 未导出 → 卡在 `proxy.golang.org` 连接超时(HTTP=000)。
与机器性能、与 1C1G 都无关。**只要每条 go 命令前 `export GOPROXY=https://goproxy.cn,direct
GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。**
⚠️ 方法论教训:**超时 ≠ 慢**。要用 `go list -deps`(纯元数据、不编译)先把「模块解析」和
「编译」两层拆开,否则会把网络挂起误判成性能问题,进而拿「环境」当借口。
---
## 复核:追问邮件 token + 其他严重错误
### 1. 追问邮件确实没带 token(用户怀疑成立)
- `internal/mail/mail.go:58-65` `SendNewFollowUpMail` 链接 `%s/_/%s/%d`,**无 `?t=`**。
- 收件人是**箱主** `pageUser.Email`(follow_up.go:131),箱主靠登录态查看 → **不是权限漏洞**。
- 但有两处真实问题:
1. **不一致**:同样发给箱主的 `SendNewQuestionMail`(mail.go:25)**带** `?t=`。
2. **具体失效场景**:若该提问被箱主设为私密(`IsPrivate=true`),箱主在**未登录设备**点邮件链接
→ `Questioner` 中间件(question.go:61-66)判定 `IsPrivate && !IsLogged && token 不匹配`
→ **302 回首页,看不到追问**。新提问邮件因带 token 则不会。
- 建议:给 `SendNewFollowUpMail` 补 token 参数,与 `SendNewQuestionMail` 对齐。
- 关键的那封(**发给匿名提问者的回答通知**)`question.ReceiveReplyEmail` 是**带 token 的**:
follow_up.go:185、question.go:214 → `SendNewAnswerMail(..., question.Token)` ✓ 无问题。
### 2. 新发现 P2:提问 token 仅 6 位且爆破无限流
- `internal/db/questions.go:95` `Token: randstr.String(6)`;`randstr` 用 `crypto/rand` +
base62 → 62⁶ ≈ 5.68×10¹⁰,**仅 35.7 bit**(随机源没问题,是长度短)。
- `GET /_/{domain}/{questionID}?t=` **无 rateLimitMiddleware**;猜错由 `Questioner` 返回
**302 → /**,而 `autoban` 只统计「敏感路径」与「404」(404 默认关闭)→ **完全不计入封禁**。
- token 仅在「未回答 或 私密」且非箱主时才需要,攻破即=读私密/未公开提问。
- 可行性:单条定向 ~2.8×10¹⁰ 次不可行;但 questionID 可枚举、只需命中**任意一条**,
受保护提问 N 条时期望 5.68×10¹⁰/N 次 → 量大时理论可行。
- 低成本修复:`randstr.String(6)` → `randstr.String(12)`(≈71 bit)。存量 6 位 token 不受影响
(列是 string,无长度校验)。或对该 GET 路径加限流。
### 3. 已复核无问题(勿再重复查)
- **密码**(`internal/db/password.go`):bcrypt cost 10;旧 HMAC-SHA1 用 `subtle.ConstantTimeCompare`
常量时间比较并透明升级;哈希失败**不降级**为弱哈希(返回空串=锁定);bcrypt 72 字节截断 ✓
- **越权**:`Delete/SetPrivate/SetPublic/TogglePin/SetRead/SetUnread` 全部由 `canDelete`
(question.go:72 `ctx.IsLogged && ctx.User.ID == pageUser.ID`)把关;
`AnswerFollowUp`(follow_up.go:143) 与 `PublishAnswer`(question.go:154,161) 双重校验 ✓
(`ctx.Redirect` 会写响应从而终止后续 handler,早退有效)
- **CSRF**:全局挂载,SessionKey=uid 正确,有回归测试 ✓
- **会话**:登录 RegenerateID、pwd_ver 失效、HTTPOnly + SameSite=Lax ✓
- **密码找回**:验证码 24h TTL + 用后即删 ✓
- **SQL 注入**:全参数化 ✓
- **token/邮箱泄露**:`Question.Token` 与 `User.Email` 均为 `json:"-"` ✓
- **审核**:提问/回答/追问/投票/私聊统一走 `moderation.Check` ✓
---
## 落地 A+B(追问邮件补 token + 提问 token 6→12)
按「继续未完成的任务」指示,执行上一条报告的 A+B 两项变更。已用 gofmt_keep_crlf 保 CRLF。
### A. 追问邮件(发给箱主)补 token,与 `SendNewQuestionMail` 对齐
- `internal/mail/mail.go:58` `SendNewFollowUpMail` 签名加 `token string`,链接改为 `%s/_/%s/%d?t=%s`。
- `route/question/follow_up.go:131` 调用处补传 `question.Token`。
- 目的:箱主在**未登录设备**点开**私密**提问的追问邮件时也能凭 token 查看(否则被 Questioner 重定向首页)。
- 仅一个调用方,构建已验证签名一致。
### B. 提问 token 长度 6 → 12
- `internal/db/questions.go:95` `randstr.String(6)` → `randstr.String(12)`(≈35.7→71 bit)。
- 存量 6 位 token 不受影响(列是 string,无长度校验,旧 token 仍有效)。
### 验证
- `go build ./route/question/ ./internal/mail/ ./internal/db/` → EXIT=0
- `go build ./...`(overlay)→ EXIT=0
- `go vet` 三包 → EXIT=0
- `internal/db` 单元测试 FAIL 为已知环境限制(需真实 PostgreSQL/MySQL,报 `Unknown database type: ""`),
与本次改动无关,可跳过;security/autoban/censor/ipinfo、route 测试均 ok。
### 待定(未做,按需)
- CHANGELOG.json 是否补一条 entry(未做:需确认版本号与条目格式)。
---
## 用本地 Postgres 跑通 db 测试并修真实缺陷(用户提供 127.0.0.1:5432)
### 测试库环境变量(`internal/dbutil/testing.go:NewTestDB`)
- `DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432`
DSN = `postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable`(不带库名,连上后
自动 `CREATE DATABASE tamabox-test-<rand>`,测试结束(未失败时)DROP)。
- 仍需 `GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + webp overlay。
### 修的真实缺陷(生产代码,2 处)
- `internal/db/questions.go:DeleteByID`:级联删追问/上传图 + 回退提问配额时**直接调包级全局单例**
`FollowUps`/`UploadImages`/`FeatureControls`。这些单例只在 `db.Init` 装配 → 测试/部分初始化场景 nil → **panic**。
改为 `NewFollowUpsStore(db.DB)` / `NewUploadImagesStore(db.DB)` / `NewFeatureControlsStore(db.DB)`,
用自身 store 的 `*gorm.DB`。生产行为不变(`Init` 里所有 store 共用同一 `*gorm.DB`),但去掉了隐藏全局依赖。
- `internal/db/conversation.go:MarkDeletedByOwner`:同类问题(`FeatureControls.IncrementQuota`),同样修。
- 同包 `feature_control.go:355` 的 `FeatureControls.Get` **已有** `if FeatureControls == nil` 守卫,故未动。
### 修的测试缺陷(全在 internal/db,非生产 bug)
1. `users_test.go`:5 处 `want.EncodePassword(); require.Equal(t, want, got)` —— bcrypt 随机盐,
哈希**永不相等**。新建 helper `requireUserEqual`:先 `got.Authenticate(明文)` 校验密码,再对齐
`Password` 与 `ChatMaxMessages`(= `conf.Limits.ChatMaxMessages`,默认 200)后比较其余字段。
2. `testUsersDeactivate`:复用已被 `testUsersCreate` 占用的 `[email protected]`/`e99` → 改唯一值。
3. `users_test.go`/`upload_images_test.go` 去掉顶层 `t.Parallel()`:子测试共用一张表、cleanup 会
`TRUNCATE ... RESTART IDENTITY`,并行时多个子测试拿到相同 id/邮箱 → 撞唯一键。
4. `upload_images_test.go`:cleanup 只 truncate `upload_images`,而链接表 `upload_image_questions`
**无 FK 级联**(复合主键 `upload_image_id+question_id`),残留行让下一子测试撞 `_pkey`
→ 补 `cleanup("upload_images", "upload_image_questions")`。
5. `UpdateBlockWords/normal`:期望 `HarassmentSettingNone`("none")但新用户实际 `""`
(模型字段无 `gorm:"default:"` 标签)。`""` 与 `"none"` 生产行为等价(只有 `==register_only` 才限制;
模板 profile.html:851 也只在 `register_only` 时勾选)→ **非生产 bug**。改成先显式
`UpdateHarassmentType(RegisterOnly)` 再断言未被误改(更强的测试,且不把 `""` 奉为期望)。
### 验证
- `go build ./...` **EXIT=0**、`go vet ./...` **EXIT=0**
- `go test ./...`(含 DB)**全绿 EXIT=0**:internal/db 57s、route/vote、internal/route、templates、
i18n、conf、form、cache、imagecheck、mail、shared、template、security/* 均 ok。
- 行尾:`internal/db/*` 与 `internal/mail/mail.go` 原本就是 LF,`route/question/*`、`route/vote/vote.go`、
`internal/route/route.go` 是 CRLF —— **仓库本来就是混合行尾**;本次编辑未改变任何文件原行尾(无 diff 污染)。
### 结论
- 除上述「全局单例 nil 依赖」(健壮性隐患:测试与部分初始化场景必崩)外,**未发现新的生产逻辑 bug**。
- db 测试套件此前的一堆失败**全部是测试自身缺陷**:bcrypt 直接比哈希、测试数据撞车、并行共享表、漏清链接表。
---
## 修复 HarassmentSetting 潜在隐患(schema 默认值 + 启动期归一)
用户确认「影响不大就做」。改动面很小(3 处源码 + 1 处测试期望 + 1 个新回归测试)。
### 线索
- 模型 `HarassmentSetting HarassmentSettingType` **无 `gorm:"default:"` 标签** → 新用户该列为空串;
而规范取值是 none / register_only。全仓库**没有任何代码比较 `== HarassmentSettingNone`**
(production 只有 `== register_only`,模板 profile.html:851 也只看 register_only)→ **运行期无 bug**,
但将来若有人写 `== None` 判断就会漏判。
### 改动
1. `internal/db/users.go` 模型:`HarassmentSetting` 补 `gorm:"default:'none'"`。
⚠️ **字符串默认值必须带单引号**(`default:'none'`),参考既有 `ModerationStrictness` 的 `default:''`;
裸写 `default:none` 会生成非法 SQL。
2. `internal/db/users.go:Create`:显式 `HarassmentSetting: HarassmentSettingNone`
(确定写库,不依赖 DB 默认值回读)。
3. `internal/db/users.go`:新增 `normalizeHarassmentSetting(db)`,把历史空值(''/NULL)归一为 none,幂等。
4. `internal/db/db.go:Init`:在 `normalizeChatQuota` 之后调用,失败仅告警不阻断启动
(与 `backfillFeatureControls`/`reconcileLegacyChatEnabled` 等既有惯例一致)。
### 测试
- `users_test.go` helper `requireUserEqual` 补 `want.HarassmentSetting = HarassmentSettingNone`。
- 新增 `TestHarassmentSettingNormalize`:新建用户即 none + 历史空值经归一后为 none。
### 实测证据
- 临时探针查 `information_schema`:`harassment_setting` 的 `column_default = "'none'::text"` ✅
(AutoMigrate 确实把列默认值改成了 none;探针文件跑完即删)。
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含 DB)**全绿**;gofmt 无 diff;
行尾未变(users.go CRLF、db.go/users_test.go LF)。
### ⚠️ 新踩到的 gofmt 陷阱
在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`),
导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
---
## 账号停用体验修复:独立成功页(/deactivated)
### 问题
停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。
用户会觉得「被丢回登录页」,下意识以为操作没生效。
诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页),
真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。
### 方案(用户选「独立成功页(推荐)」)
1. `route/user/profile.go`
- 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。
- `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。
- 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。
2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。
**必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。
3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。
`templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。
4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome`
(`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。
同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**,
其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。
- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。
### 备注
- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。
---
## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册
用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500:
```
SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0
INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505)
ERRO Failed to create new user error="create user: ERROR: duplicate key ..."
```
### 根因(本地已实测复现,日志形态与线上一致)
`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**:
`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。
而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是:
- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行;
- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。
复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。
**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」——
说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。
同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。
顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`),
数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。
### 改动(`internal/db/users.go`)
1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。
2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。
3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占,
返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的)
4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。
### ⚠️ 过程中自己踩的 GORM 大坑
把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」——
**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**,
SQL 变成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → `字段 conversation_id 不存在 (42703)`。
✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。
这条已写进 MEMORY.md。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。
- 测试环境实测(脚本 `dev/devcheck-e2e.py`):
- ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常;
- ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました);
- ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。
- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理:
`tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位);
用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。
清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。
---
## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」
用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。
经询问确认两点:
1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立);
2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。
→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。
### 最终实现
- `internal/db/users.go`
- `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。
- `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。
- `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。
- `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行
→ 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。
- 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。
- 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。
- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。
- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。
- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted),
提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。
- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`,
清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。
### 🐛 顺带修掉的隐藏问题
**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。
现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。
- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得
ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、
`TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。
- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物):
- `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅
- `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅
- `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅
- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用
`NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。
- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」;
在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。
---
## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿)
**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」,
**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。
**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组:
1. `check_vote_flow(owner, base)` —— 投票全链路
- POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes`
- GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID**
- 匿名 GET `/vote/<token>` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID
- 匿名 POST `/vote/<token>/submit`(`option_id` + 数学验证码 + `_csrf`)→ 302
- GET `/user/votes/<id>/export` 断言 **`总参与,N` 这行的 N == 1**
- 草稿:`action=save` → 302 到 `/user/votes/<id>/edit?saved=1`
- 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302
2. `check_user_assets(owner)` —— 登录态接口/导出
- `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、
`POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言
**踩到的坑(都已在脚本里修掉)**:
- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 →
用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。
**必须单独匹配 `总参与,(\d+)`**。
- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发),
异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底
——中断时仍打印已完成项,而不是只留一段 traceback。
- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时,
变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**,
单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。
**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。
10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。
**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。
本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。
清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。