- 2026-10-29.md:第三批 7 项改动与验证数据 - MEMORY.md:三条长期结论 —— 「用户主动删除 = 真删」(模型全带 DeletedAt,默认 Delete 是软删)、 「配额必须与内容同事务」(incrementQuotaTx 是唯一实现,且投票口径是票数不是个数)、 「错误页不套模板」(f.NotFound 不经过组级中间件,注入 template.Data 会 panic) - topics/quality.md:标注第三批范围 新踩到的三个坑: 1. gofmt 会把注释列表 `// 1) ...` 改写成 `// 1. ...`,被标为未格式化时先排除这个 2. Select(...) 漏列 = 字段静默为空(摘要永远是空串,只有一项测试红) 3. CDP 验证纯前端:chrome 必须作为独立后台进程启动,跟在别的命令后面 `&` 会随 shell 退出被回收;headless=new 的空标签页 type 常是 other
148 lines
11 KiB
Markdown
148 lines
11 KiB
Markdown
# 2026-10-29
|
||
|
||
## 第二轮综合复查(非安全向)
|
||
|
||
用户要求「再确认还存在什么问题与风险点」+「从用户角度有什么值得改进和增加的功能」。
|
||
前一轮是安全专项(P1/P2/P3 全修完),这轮换到**业务正确性 / 运维 / 前端体验 / 产品能力**四个面。
|
||
|
||
产出:`outputs/review-2026-10-29.md`(分级清单 + 复现方式 + 产品建议)。
|
||
|
||
### 实测复现的两条 P0
|
||
|
||
1. **私聊 50 条截断**:`GetByConversationID` 是 `Order("created_at ASC") + Limit(50)`,
|
||
唯一调用方永远传 nil cursor;会话上限 200 → 第 51 条起**写库成功但永久不可见**。
|
||
本地造 55 条实测:页面只渲染 #1–#50。这不是性能问题,是「数据在库里但用户看不到」。
|
||
2. **访问日志泄露 token**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
|
||
Logger 记 `RequestURI`(含 query)。实测日志出现 `path=/_/tblocal/4?t=SECRETTOKEN123`。
|
||
`?t=` 是私密题钥匙、`?code=` 是改密凭证。**tracing 中间件早就为同一风险避开了 RequestURI
|
||
并写了注释,框架默认 Logger 漏了。**
|
||
|
||
### 代码确认的 P1
|
||
|
||
- **无优雅关闭**:全仓 `signal.Notify`/`Shutdown` 零命中,SIGTERM 直接杀进程 → 每次重启掐断在途 POST。
|
||
- **提问入库后图片失败不回滚**(`page.go:660` → `:680`),叠加 DuplicateGuard 5 分钟 → 用户以为没发出、5 分钟内不能重提。
|
||
- **Redis 两种故障**:session 在 Redis 上宕机即全站 500(flamego session load 直接 panic);
|
||
`cache.Cacher` **没有** `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退到 localhost:6379 → 没配 Redis 时找回密码静默坏掉。
|
||
- **处置不覆盖投票**:提问链路 7 处 `ActiveLevel`,投票链路 0 处。L4 处置后 `/_/domain` 已 302 到 /restricted,
|
||
但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
|
||
|
||
### 产品缺口(重要,别忘)
|
||
|
||
- **完全没有访问量数据**:`SiteAnalytics` 是全站聚合,无 per-user 维度,也没有 page view 表。
|
||
箱主无法知道自己被看了多少次。
|
||
- **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联;全仓 `localStorage` 零命中。
|
||
→ 建议前端用 localStorage 记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
|
||
- `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
|
||
- 模板零 `application/ld+json`(SEO 富摘要缺失),无 RSS。
|
||
- 图片原图直传(无前端压缩),配合 ReadTimeout 30s → 手机端 8MB 图必超时。
|
||
|
||
### 顺带确认的「不用动」
|
||
|
||
四语言字典 917 条完全对齐(`TestDictionaryComplete` PASS)、深色模式正文对比度 4.8–6.8:1 达标、
|
||
gzip 实现质量高、后台移动端短标签方案做得细。
|
||
|
||
### 已修(同日,六项全部端到端验证)
|
||
|
||
| # | 改动 | 验证 |
|
||
|---|---|---|
|
||
| 1 | `GetByConversationID` 改 `id DESC` 取最新 + 内存反转,limit 取 `conf.Limits.ChatMaxMessages`;游标配 DESC | 55 条会话实测渲染 55 条(改前 50);单测 3 项 + 变异(改回 ASC 变红) |
|
||
| 2 | 弃 `flamego.Classic()`,`flamego.New()` + 自写 `accessLogMiddleware`(只记 `URL.Path`;静态成功不记、4xx/5xx 记) | 日志 `msg=access path=/_/tblocal/4`,token 计数 0 |
|
||
| 3 | `signal.NotifyContext` + `srv.Shutdown(15s)` + 关连接池 | 8 个并发分享图请求中 SIGTERM → 8/8 成功 |
|
||
| 4 | 图片失败分支 `Questions.DeleteByID` 回滚 | 6MB 图 → 提问增量 0 且 `deleted_at` 非空;无图对照增量 1 |
|
||
| 5 | `cache.Cacher` 加 `if conf.Redis.Addr != ""`(否则 MemoryIniter);启动 `PingRedis` fail-fast | 停 Redis 后启动立即失败并指向 Redis |
|
||
| 6 | 投票 Page/Submit 加 L4、VotePublish 加 L2、探索页用 `ActiveLevels` 批量过滤 | L4 → `/vote` 302 到 /restricted;探索页该投票 0→2(撤销后);L2 下发布停 draft,撤销后 published |
|
||
|
||
新增 `vote.err.restrictedPublish` 四语言 key。回归:4/4、19/19、6/6、3/3,全量单测 + `go build ./...` 通过。
|
||
|
||
**踩到的两个坑**(值得记):
|
||
- 消息加载的兜底写成 `limit < 50 → 50`,会把箱主显式配置的小上限抬大,测试直接测出这个语义错误。
|
||
兜底只该处理**零值**(未初始化),不该替用户改配置。
|
||
- Python 的 **bytes 字面量不支持 `\uXXXX`**(只有 str 支持),写字面量 `b'...\uff08'` 会原样写入
|
||
`\uff08` 六个字符。CRLF 文件的字节级替换要用 `\xef\xbc\x88` 或 `.encode()`。
|
||
|
||
### 第二批(P2 + 高价值 P3,Docker 按用户要求跳过)
|
||
|
||
| # | 改动 | 验证 |
|
||
|---|---|---|
|
||
| 7 | ReadTimeout 30s→120s(8MB 图需 ≥68KB/s;ReadHeaderTimeout 仍 10s 防 slowloris) | 参数修正 |
|
||
| 8 | 投票结果改 stale-while-revalidate:过期先返旧值 + 后台按投票去重重算;Submit 从「删缓存」改「标记过期」(新增 `cache.Expire` / `GetWithExpiry`) | 2 万条:冷启 0.211s → 过期首读 0.023s(9.2x) |
|
||
| 9 | 三处幂等:`MarkDeletedByOwner` 条件更新+RowsAffected、`SetPinnedIfCurrent`、追问「上一条已回复」移进事务 | DB 测试 3 项全过 |
|
||
| 10 | 私聊首条三写入合并为一个事务(`CreateWithFirstMessage`) | message_count 与实际消息数一致 |
|
||
| 11 | `/healthz` + `/readyz`(DB ping 失败 503) | 停 PG 后 healthz 200 / readyz 503 |
|
||
| 12 | 验证码失败改同请求渲染,不再丢正文 | 200 + 正文回填 + 不建提问 |
|
||
| 13 | `tbBeginSubmit()` 抽出,reCAPTCHA 回调补防重复 | 静态验证(7 个模板受益) |
|
||
| 14 | waline 按页加载、`loading="lazy"`、safe-area、三处硬编码接字典(+4 key×4 语言) | /login 无 waline;en 页面零中文残留 |
|
||
| 15 | 5 处裸类型断言改带 ok | 编译 + 单测 |
|
||
|
||
新增 `verify_round3.py`(6 项)。第二批回归:6/6、4/4、19/19、6/6、3/3,全量单测 + build 通过。
|
||
|
||
**判据随实现更新**:`verify_p1_fixes.py` 的 p1-2b 原来用「是否 302」判断验证码先于密码,
|
||
第 12 项改成 200 后判据失效 → 改为「200 且页面报『验证码错误』」。语义不变,判据跟着渲染方式走。
|
||
|
||
### 这次踩的第三个坑(重要,我自己造成的)
|
||
|
||
**Windows 上 `Path.write_text()` 默认把 `\n` 转成 `\r\n`**,用它改 LF 文件会让整个文件行尾翻车。
|
||
- 症状:`gofmt -l` 突然把整文件标为未格式化。
|
||
- 后果:`git diff --stat` 会显示整文件重写。
|
||
- 正确写法:`path.write_text(s, encoding="utf-8", newline="\n")`,或用 bytes。
|
||
- **排查手段**:`git diff --stat` 是判断行尾污染最快的信号 —— 真实改动应该只有几十行,
|
||
整文件重写一定是行尾变了。恢复方式:`git show HEAD:<file>` 取其行尾,再统一替换回来。
|
||
|
||
另:本项目 `core.autocrlf=true`,仓库里存 LF、工作区 checkout 成 CRLF,
|
||
所以「工作区看到 CRLF」不等于「仓库里是 CRLF」,判断基准要看 `git show`。
|
||
|
||
---
|
||
|
||
## 第三批(数据安全 + 产品缺口)
|
||
|
||
1. **删除投票 / 删除提问改成一个事务 + 真删(`Unscoped`)**。
|
||
原来四步独立写入、前几步失败只 Warn → 中途失败留下「选项没了票还在」这类
|
||
界面修不好的状态,重试时投票已不存在、只能报 not exist。
|
||
更关键的是:所有模型都带 `dbutil.Model` 的软删除,原来全是软删 ——
|
||
提交记录里的参与者文本、`voter_ip`、`voter_token`、提问里的 `FromIP`
|
||
只是「界面上看不见」,库里永久留着。用户点删除的预期是删掉。
|
||
2. **配额与内容同事务**:抽出 `incrementQuotaTx(tx, userID, feature, delta)`,
|
||
提问 `Questions.Create`、私聊 `CreateWithFirstMessage`、投票提交
|
||
`VoteSubmissions.Create`(新增 `QuotaUserID` 字段)都在自己的事务里 +1。
|
||
原来 handler 里独立写入且失败只 Warn,`quota_count` 会永久偏低。
|
||
3. **追问改回答不再重复发邮件**:`UpdateAnswerByID` 返回 `wasFirst`(事务内读旧值判定),
|
||
handler 只在首次回答时发。原来改三遍措辞就发三封。
|
||
4. `isQuestioner` 的 token 比较改 `subtle.ConstantTimeCompare`(与 Pager 中间件同口径);
|
||
CSV 导出的**表头与选项列**补 `sanitizeCSVCell`(原来只有数据列走它)。
|
||
5. 写路径功能开关改 **fail-closed**:新增 `db.FeatureStateOfStrict`,
|
||
读配置失败时拒绝而不是回退默认「开启」。
|
||
6. **真 404 + HTML 500**:新增 `internal/route/errorpage.go`,`f.NotFound` 不再 302 到首页,
|
||
改 `writeErrorPage(ctx, 404, ...)`;`flamego.Recovery()` 换成 `recoveryMiddleware()`。
|
||
7. **提问者找回自己的提问**:成功页写 localStorage,箱主页显示提示条,
|
||
新增 `POST /api/v1/ask-status`(批量问「哪些已被回答」)。
|
||
|
||
### 这批踩到 / 学到的
|
||
|
||
- **flamego 的 `f.NotFound` 拿不到组级中间件**:`template.Templater` 与 `Contexter`
|
||
(含 session、CSRF)都挂在 `f.Group("", ...)` 上,NotFound 不经过它们。
|
||
所以在 NotFound 里注入 `template.Data` 会直接 panic:
|
||
`unable to invoke the 8th handler: value not found for type template.Data`。
|
||
错误页最终用**内联 HTML + i18n.T**(`pickErrorPageLang` 从 Accept-Language 猜语言),
|
||
不依赖模板系统 —— 错误页本来是最后一道防线,不该依赖可能出错的链路。
|
||
- **`gofmt` 会把注释里的列表规范化**:`// 1) ...` 会被改写成 `// 1. ...`。
|
||
自己写的中文注释列表如果用了 `1) 2) 3)`,`gofmt -l` 会标为未格式化 —— 不是行尾问题。
|
||
- **`Select(...)` 漏列 = 字段静默为空**:`ListByIDsAndUser` 一开始只选了
|
||
id/token/answer/created_at,摘要永远是空串,接口 12 项里就这一项红。
|
||
- **配额口径要按功能分别确认**:投票的定量收集是「累计收到的票数」(在提交成功时 +1),
|
||
不是「创建了几个投票」。所以我一度在删除投票里加了 -1,那是错的 —— 删投票不代表票没发生过。
|
||
- **CDP 验证纯前端功能的套路**(`verify_ask_history_ui.mjs`):
|
||
`chrome --headless=new --remote-debugging-port=9222`(必须作为独立后台进程跑,
|
||
跟在别的命令后面 `&` 会随 shell 退出被回收)→ node 用内置 `WebSocket` 连
|
||
`/json/list` 里任一 target → `Runtime.evaluate` 注入 localStorage → 重新 navigate →
|
||
断言 DOM → `Page.captureScreenshot`。注意 headless=new 的空标签页 `type` 常是 `other`,
|
||
不能只找 `type === "page"`。
|
||
- **控制台错误要区分来源**:页面里 R2 图片的 CORS 报错是既有问题,
|
||
把它算进「本次改动失败」只会得到假阳性。判据要按 URL/关键字过滤。
|
||
|
||
### 待办(仍未做)
|
||
|
||
- AutoMigrate 无版本化(改列名会静默丢数据)
|
||
- 无备份脚本(图片全在对象存储,只备份 DB 恢复后全是裂图)
|
||
- 32 个 `<label>` 无 `for=`、语义标签对比度 1.9:1
|
||
- 箱主数据面板(全站无 page view 数据)、Telegram/Webhook 通知、JSON-LD + RSS、图片前端压缩
|