Files
tamabox/.workbuddy/memory/2026-10-29.md
T
tamakyi 693b1102c2 fix: 封堵 ask-status domain 枚举、私聊写路径 fail-open、压缩兜底重复提交
三处小修 + 两个格式清理,均来自全盘复查:

1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素
   数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。
   改为箱主不存在时也回显 ok:false,四种失败响应完全同形。

2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值,
   与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭
   的巧合侥幸安全,现统一改 Strict。

3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前
   到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。

顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐
(gofmt 干净)。
2026-10-29 18:52:38 +08:00

18 KiB
Raw Blame History

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、图片前端压缩

箱主数据面板 + 图片前端压缩(本轮完成)

把「待办」里的两项落地,其余(AutoMigrate 版本化、备份脚本、label/对比度、Telegram/Webhook、JSON-LD)仍未做。

箱主数据面板(/user/insights)

  • 新表 box_views(internal/db/box_views.go):一行 = 一访客×一天×一箱,唯一索引 (user_id, viewed_on, visitor),views 累加。刻意不内嵌 dbutil.Model—— 软删除 + 唯一索引会让被删行永久占槽(老坑)。
  • 访客标识 = HMAC(日盐, IP) 前 32 hex(internal/insights/recorder.go):同日可去重(UV 准)、 跨日不可关联(隐私)。代价:近 7/30 天 UV = 各日 UV 之和,文案里已写明。
  • 异步记录器:buffered channel 512 + 2s flush,构造即启动 goroutine(避免 Close/Run 竞态), 队列满即丢(统计尽力而为)。过滤:箱主本人、空 UA、爬虫 UA 子串、预取头(Sec-Purpose 等)。
  • 聚合层 internal/db/box_analytics.go:BoxTrend(提问/回答按日)、BoxHours(24h 分布)、 BoxFunnel(已回答/已读未答/未读 + 回答率 + 24h 回复占比)。复用 analyticsDialectFor 的时区处理。
  • 展示层 internal/insights/page.go:30 天 PV/UV 双柱趋势(CSS 高度百分比)、时段分布、 漏斗 + 回答率进度条、自动结论(FormatInsights)。百分比整数值不带 .0(formatPct)。
  • 打点:route/question/page.go 的 Pager 末尾(所有错误分支/密码页已 return 之后), !ctx.IsLogged || ctx.User.ID != pageUser.ID 才记。
  • 挂载:/user/insights 在 reqUserSignIn 组内;pageScope 加 /user/insights → admin。
  • 密钥复用 server.xsrf_key;StartMaintenance 每日清 400 天前的明细。
  • 测试:internal/db/box_views_test.go(4 项,含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许同条 INSERT 二次命中同一行)、internal/insights/page_test.go(pct/窗口切分/visitorID/爬虫过滤)。

图片前端压缩(templates/base/footer.html)

  • 选图即压:canvas → WebP(长边 1600、质量 0.8,与服务端 GeneralEncodeSpec 对齐)。
  • 三条边界:GIF/SVG 跳过(canvas 会把 GIF 变静止首帧)、压完不更小退回原图(<85% 才采用)、 透明 PNG 走 WebP(不支持 WebP 编码时退回 PNG 而非 JPEG,否则透明变黑)。
  • createImageBitmap(..., {imageOrientation:'from-image'}) 摆正 EXIF,老浏览器回退不带选项。
  • submit capture 兜底:选图立刻提交时 preventDefault + Promise.all(压缩) + form.submit(), 避免二次触发防重复。
  • 实测(CDP + 自写 WebSocket 客户端 dev/_cdp.py):4.6MB PNG → 11.7KB WebP(0.3%), 小图原样保留、GIF 跳过,三断言全过。

踩坑(本轮新)

  • 同步脚本把 WSL 的 conf/app.ini 删了:Windows 侧用 tar | wsl rsync --delete 同步源码, 目标机那份含 DB 密码/对象存储密钥的 app.ini 不在源目录里,被当多余文件删掉 → 服务重启起不来。 修:dev/sync-wsl.sh 里 rsync --exclude "conf/app.ini",模板放 dev/wsl-app.ini(已 gitignore)。
  • 配置丢了怎么找回:进程内存 dump(/proc/PID/mem 扫描 key = "value" 模式)能捞出启动时 加载的全部 ini 键值(salt/xsrf_key/上传密钥/DB DSN 都在),配回 conf/app.ini 即可。
  • headless 里 canvas 编码大图超时是假象:Windows Chrome 的 --dump-dom + --virtual-time-budget 会把 3000×2000 噪声图压得解码超时,#out 永远停在 running。换 WSL 内 apt install chromium 走 CDP 轮询(Runtime.evaluate 非 await 反复读结果)才拿到真值。
  • 自写 CDP 的 WebSocket 客户端必须处理 continuation 分片:CDP 大事件(console/DOM 属性)会 把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 _cdp.py。
  • awaitPromise 长轮询会挂死自写 WS 客户端:验证异步结果改用「navigate + 非 await 轮询读 DOM」。

全盘复查(数据面板/图片压缩之后的第二轮)

用户要求「再做一次全盘复查」。两个并行子代理扫了本轮新增代码(数据面板、图片压缩、 删除事务化、错误页、找回提问),交叉核对后确认的问题与处置:

已修(本轮)

  1. P1 提问真删不彻底(questions.go DeleteByID 对主表 Unscoped,但追问/图片仍软删):

    • follow_ups.go DeleteByQuestionID、upload_images.go DeleteByQuestionID 改 Unscoped 真删。
    • 关联表 UploadImageQuestion 无 DeletedAt,无需 Unscoped(已注释)。
    • 新增 internal/db/question_delete_hard_test.go:用 Unscoped 计数断言删除后追问/图片/关联行 物理上=0。变异验证:把追问改回软删,测试失败(expected 0 actual 1)。
  2. P1 BoxTrend 每日回答数按 created_at 分组(应为 answered_at):box_analytics.go 加 answerDay 表达式(PG 用 to_char(COALESCE(answered_at,updated_at) AT TIME ZONE ...),MySQL 用 CONVERT_TZ), WHERE/GROUP/SELECT 三者口径一致。新增 internal/db/box_analytics_test.go:构造「40 天前创建、 昨天回答」的提问,断言计入昨天。变异验证:改回 d.day 后测试失败。

  3. P2 ask-status domain 存在性可枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素数组, 凭空/非空可枚举 domain。改为箱主不存在时也回显 ok:false(与「存在但 token 错」完全同形)。

  4. P2 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):改 FeatureStateOfStrict,读配置失败即拒。 (此前靠「chat 默认关闭」巧合侥幸安全,现在按写路径约定统一。)

  5. P2 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前到 preventDefault 的同步栈,物理阻止压缩窗口内双击重复提交。

判定不修(子代理误报,或有意设计)

  • 投票删除不回退配额:子代理当 bug,但这是有意为之——投票配额口径是「累计收到的票数」, 删投票不代表票没发生过(记忆里早记过:一度加 -1 是错的)。注释已写清。
  • recomputeHeights 里 d.UV > maxPV 死代码:UV≤PV 恒成立,该行永不触发,但当前「PV/UV 同刻度」 恰好是设计意图,不算错。
  • TopDays 死代码:BoxViewsStore.TopDays 无调用方,best-day 只回看 30 天。属口径/注释自相矛盾, 但不改数据安全,留待后续。

踩坑(本轮新)

  • gofmt -l 把「缺末尾换行」也标为未格式化:Edit 工具写文件时可能丢末尾换行符,gofmt -l 会报 \ No newline at end of file。与行尾问题不同,补一个 \n 即可。
  • struct 字段对齐:手写 struct 时字段没对齐(Questions int64 少一个空格),gofmt 会标。 用 dev/gofmt_keep_crlf.py 统一修。
  • CSRF token 登录后会轮换:flamego/csrf 在登录成功后轮换 token(防固定),所以登录前 GET /login 拿到的 token 在登录后失效,POST 接口测试要登录后再从带表单的页面重新取。这导致 ask-status 的 真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
  • git show HEAD:file 输出写到 /tmp 后 Windows Python 读不到:/tmp 是 Git Bash 的虚拟路径, Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。