422 lines
30 KiB
Markdown
422 lines
30 KiB
Markdown
# 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`(恢复)。
|