# 2026-09-05 工作日志 ## 提问箱公开主页「加载更多」翻页失效(内容重复)排查 - 用户报告:提问列表加载完后「加载更多」按钮仍在,点击后加载相同内容。示例链接 `https://box.tama.guru/_/aoi/3105?t=Kmd0mu`(item 页底部嵌有该箱「历史提问」列表 + history-template 加载更多)。 - 代码链路:`templates/question/history-template.html`(Alpine fetch) → `route/question/page.go ListAPI`(`/api/v1/user/{domain}/questions`) → `internal/db/questions.go GetByUserID`(PinFirst 复合键集游标 `isPinned.CreatedAtUnixNano.ID`)。 - 线上实测(box.tama.guru, aoi 箱共 38 条已答公开问题): - 首页 20 条 id 3109→3089;`next_cursor=0.1788566476678885000.3089`。 - 携带任意真实 next_cursor 再次请求 → 仍返回首页同样 20 条(has_more 恒 true)→ 内容重复、无法翻页(已稳定复现)。 - 部分异常游标返回空集/6 条等与源码 keyset 预期不符的怪异结果 → 指向线上二进制或 DB 环境与工作区源码假设不一致(时区/列精度/部署版本),单凭源码无法闭合解释。 - 前端模板与工作区 0904 版逐字符一致(x-on:click.debounce、payload.next_cursor、注释),说明线上模板=新版;但服务端行为异常。 - 历史:outputs/re-audit-2026-09-01.md P0-2 曾记录同款症状(前端回传旧格式游标→parsePinCursor 失败→永远第一页),当时修复为服务端下发 next_cursor;但线上当前仍复现,说明修复未生效或有残余缺陷。 - 后续建议:生产库执行 keyset SQL 直查对照 + SHOW CREATE TABLE 确认列精度;代码侧做健壮化(游标未前进/解析失败 → 返回空 + has_more=false;前端按已见 id 集合防重)。 ## 落地修复(用户选定:防重复兜底) 1. `internal/db/questions.go`: - `GetByUserID`:携带游标但 PinFirst 复合游标/纯 id 游标解析失败时,由「忽略游标静默回退首页」改为直接 `return []*Question{}, nil`(空列表 → 上层 has_more=false → 按钮收敛)。 - `getBy`(GetByAskUserID 旧路径):同步改为解析失败返回空。 2. `templates/question/history-template.html`: - SSR range 项与 JS append 项均加 `data-qid`;点击时先扫描 `#question-list [data-qid]` 收集已见 id,过滤重复项;无新增条目时 `more=false`(按钮隐藏/显示无更多)。 - 注释说明重复加载 bug 的历史形态。 3. 验证:`go build ./internal/db/...`、`go vet ./internal/db/` 通过;模板测试 `TestTemplatesParse/TestTemplateCrossReferences/TestRenderPublicQuestionList/TestRenderPublicQuestionItem` 全 PASS。全量 `go build ./...` 因第三方 `chai2010/webp`(CGO,本地缺 C 库)预存失败,与本次改动无关。 4. 待办(未做):生产侧核对部署二进制是否含 9/1 P0-2 修复、DB 列精度/时区一致性诊断;线上复现的 keyset 怪异行为(同 nano 不同 id 返回不同页等)仍未完全由源码闭合解释。 - 为用户产出 PG 根因诊断脚本 outputs/pg-cursor-pagination-diagnose.sql:PART A 环境/表结构/集合核对(含 created_at 列精度、索引、answered_public_all 应=38 与线上 API 对齐);PART B 用 DO+临时表纯 SQL 复刻 ListAPI 复合键集翻页循环,逐页打印 ids、带死循环/重复页检测;PART C UnixNano 指纹核对(1788566476678885000 类)用于区分部署版本旧 vs DB 环境问题。ListAPI 等价条件已核对: FilterAnswered(True)+PrivateStatus=1+PinFirst+page_size=20+1 探测。待用户执行回贴结果判读。 - 生产库真实结构确认(用户探查): schema=nekobox(search_path 默认 public 导致首版脚本找不到表);nekobox.questions: id numeric(seq nekobox.questions_id_seq), created_at/updated_at/deleted_at = timestamp(6) WITHOUT TIME ZONE, user_id bigint, answer text, is_private/is_pinned/is_read boolean NOT NULL;nekobox.users 含 domain。线上库与源码(GORM timestamptz 假设)不一致 -> 无时区列+Go UTC 解释=线上 keyset 错位头号嫌疑。产出 v2 脚本 outputs/pg-cursor-diagnose-v2-nekobox.sql:SET search_path=nekobox; PART A 结构/集合核对(应38)+DB墙钟vs线上Z文本目视(差8h即偏移实锤); PART B DO+临时表纯墙钟 keyset 复现(锚点 timestamp 变量避免时区换算, 重复页/死循环检测); PART C nano 指纹反推命中检测(match_3089)+Z文本墙钟命中。待用户执行回贴。 - 【根因闭合】v2 脚本线上结果: aoi_user_id=329, answered_public_all=38(与线上API一致), 无置顶行(pinned=0)。DB 墙钟==线上 JSON Z 文本(无时区偏移,会话 Asia/Shanghai 不影响无时区列直比)。PART B 纯墙钟 keyset 翻页: 第1页20条(3109..3089)=线上首页, 第2页18条(3088,2883..2411), 无重复无跳行 -> **DB 层完全自洽**。PART C: nano=1788566476678885000 反推墙钟 00:01:16.678885 精确命中 3089 (match_3089=t), Z 文本命中 3109/3089 -> 服务端 encode 正确、无时区/精度错位。结论: DB/SQL 层彻底排除,线上"游标被无视返回首页"只能用【线上部署二进制与 0904 源码不一致(解析路径差异)】解释。修复=部署含防重兜底的最新源码; 部署后需 curl 复测翻页收敛。 - 【真根因确认+已修复】部署 0904 后线上仍复现 -> 读 parsePinCursor 源码定位: `time.Unix(0, nano)` 返回 Local 时区,进程 TZ=Asia/Shanghai 时墙钟 +8h(00:01->08:01)。生产 questions.created_at 是 timestamp WITHOUT time zone,驱动按 time.Location 墙钟直发无时区列 -> `created_at < '08:01'` 对库内全部 00:xx 行恒真 -> keyset 退化为全量 -> 永远返回首页 has_more=true -> 无限循环。本地实验 tz_proof.go 证明 08:01 vs 00:01 偏移。已修复 internal/db/questions.go parsePinCursor: time.Unix(0,nano) -> time.Unix(0,nano).UTC()(含时区约定注释)。build/vet PASS。其余 UnixNano 用途(随机/文件名)无 keyset 风险。待用户重新部署后 curl 验收翻页收敛(38条唯一)。 - 【修复验收通过】用户重新部署后线上验证:第1页20条(新增20) has_more=true -> 第2页18条(新增18) has_more=false -> 总计38条唯一,完全收敛 ✓。非法游标(abc)/旧纯id游标(3089)均返回 {"data":[],"has_more":false} -> 解析失败收敛兜底同时生效。根因(parsePinCursor 时区偏移)与今日两处防重兜底均已上线。问题闭环。 - 用户拟将生产库克隆到本地实验环境做 timestamptz 迁移测试。本地已建独立 PG 测试实例: 127.0.0.1:5433, user=postgres, trust 认证, data 目录 $HOME/AppData/Local/Temp/tamabox-pg-clone-test/data (PostgreSQL 18 at C:/OtherFiles/PostgreSQL/18)。等待用户 pg_dump 生产库(Fc, --schema=nekobox) 后导入跑迁移验证(epoch 快照/转换/验收/翻页复现)。注意本地另有用户自己的 5432 实例(scram 认证, 无密码勿扰)。 - 【timestamptz 迁移已在克隆库验证通过】用户 dump(nekobox_clone_20260905.dump, 资料库 drive 节点 6oAmSRkxq7DVFAnqRt8QEv) 下载后导入本地 5433 测试实例 nekobox_clone。结果: 16 表, aoi=38 与线上一致。实际无时区时间列仅 4 表 12 列: questions/users/censor_logs/upload_images(其余表本为 timestamptz)。Asia/Shanghai 会话下迁移 ALTER TYPE timestamptz USING AT TIME ZONE 'UTC': 1.7s 完成, epoch 前后差异 0 行, 残余 without=0; 关键行指纹 3109=1788569378648502000/3089=1788566476678885000/2411=1781735419615754000 与线上一致; keyset 翻页带 +00 参数精确翻出线上第2页同款 18 条; 索引完整; 回滚路径双向验证无损。产出 outputs/timestamptz-migration-online.sql(PART0 核对/1 快照/2 迁移/3 验收4项/4 注释回滚)。克隆测试实例与 dump 暂存本地 Temp, 待用户线上执行确认后清理。 - 【线上 timestamptz 迁移执行成功】用户线上跑 timestamptz-migration-online.sql: PART0=4表12列, PART1快照12列, PART2迁移DO完成, PART3验收全绿: 3.1 epoch差异0行, 3.2 remain_without=0, 3.3 指纹 2411=1781735419615754000/3089=1788566476678885000/3109=1788569378648502000 与克隆库/线上游标一致, 3.4 keyset第2页18条(3088,2883..2411)同款。但端到端验收受阻: box.tama.guru 持续 HTTP 521(源站不可达, 页面+API均打不开, 观察约2.5min) -> 疑似用户正在重启/部署应用或服务宕机, 待服务恢复后重跑翻页验收脚本(38条唯一收敛)确认整体闭环; 本地5433克隆实例与dump暂留待最终验收后清理。 - 【端到端验收通过·最终闭环】服务启动后重跑线上翻页: 第1页20条 has_more=true -> 第2页18条 has_more=false -> 38条唯一收敛 ✓。注意: 迁移后 JSON created_at 文本从 "...Z"(UTC) 变为 "...+08:00"(session TZ=Asia/Shanghai) —— 绝对时刻一致。前端消费核对: history-template SSR {{Date .CreatedAt}} 迁移前显示 UTC 墙钟(少8h) 而 JS dayjs() 追加项本地化 -> 首屏与追加时间曾不一致(隐藏显示bug), 迁移后 SSR 读 timestamptz 转会话时区 -> 两者一致且显示上海本地时间(对用户更正确); item.html/list.html 同理顺带修正。无需改代码, 属显示语义修正观察项。遗留: 用户清理 diag_test 报依赖错误 -> 解答用 DROP SCHEMA diag_test CASCADE (schema 内仅 snap_before/snap_after 两张自建快照表)。本地 5433 克隆实例+dump 待清理。 - 【发现迁移基准错误+已产修复脚本】用户反馈: 真实本地时间 00:01 的提问页面显示 08:01 -> 存量墙钟实为"上海本地时间"(驱动按进程本地墙钟写无时区列, 读出按UTC标记, 文本一致造成 v2 诊断"对称"假象), 先前迁移用 AT TIME ZONE 'UTC' 把上海墙钟误标 UTC -> 存量绝对时刻整体 +8h。克隆库验证反向修正(USING c AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Shanghai', 净-8h): 3089 08:01->00:01:16+08 (epoch 1788566476678885000->1788537676678885000), 3109->00:49:38+08, 2411->06-17 22:30+08; keyset 第2页仍18条。产出 outputs/timestamptz-migration-fix-8h.sql (PART0 聚焦4表12列/1快照/2修正/3验收含3.1显示+epoch/3.2翻页/3.3计数/3.4残余/4注释回滚=反向AT TIME ZONE 'Asia/Shanghai' AT TIME ZONE 'UTC')。克隆库全流程演练通过。待用户线上执行贴回 + 我重跑 API 验收(next_cursor nano 应从 0.1788566476678885000 变 0.1788537676678885000)。 - 【线上反向修正验收通过】用户线上执行 timestamptz-migration-fix-8h.sql(PART2 修正完成, 后续输出被 psql 分页截断不影响), 我直接 API 验证: next_cursor nano 1788566476678885000->1788537676678885000 ✓, created_at 文本回到本地墙钟 00:49:38.648502+08:00 / 末条 00:01:16.678885+08:00 ✓, 翻页 38 唯一收敛 ✓。存量时间语义修正为"上海墙钟"。注意: 该脚本不可重跑(会再-8h)。遗留: 用户如需补看验收可跑只读 SQL; diag_test 清理可用 DROP SCHEMA diag_test CASCADE; 本地 5433 克隆实例+dump 待清理(询问用户后执行)。 - 【留档完成】事故复盘文档 outputs/TamaBox-v4-keyset-pagination-timezone-incident-2026-09-05.md:完整记录两层缺陷(①parsePinCursor time.Unix Local +8h 致 keyset 失效②timestamptz 迁移基准误用 UTC 致存量+8h 已反向修正)、时间线、4文件代码改动、SQL 脚本产物清单(标注 v1/v2 已淘汰、online.sql 基准错误勿复用、fix-8h.sql 不可重跑)、经验教训预防清单(无时区列+带时区代码混用是土壤/迁移前须用现实锚点确认数据语义/文本一致≠语义正确)、遗留建议(页面抽查/可选 UTC 输出/清理克隆实例与dump/生产 DROP SCHEMA diag_test CASCADE)。 ## UIkit CSS 迁移公共 CDN(速度优化落地) - 背景:生产速度测试发现 R2 自托管 `uikit/3.3.3/css/modify-black-font/uikit.min.css`(263432B)加载 ~1100ms 是大头。 - Diff 结论:自建版与官方 uikit@3.3.3(263444B,sha256 6630e078...)**仅 3 处颜色差异**——把有色容器(.uk-light/.uk-card-primary/.uk-card-secondary/.uk-overlay-primary/.uk-section-*/.uk-tile-*/.uk-offcanvas-bar)内默认白字/白标题改黑:容器文字与 table th `rgba(255,255,255,.7)→rgba(0,0,0,.7)`,h1-h6/.uk-h*/.uk-heading-* `#fff→#000`。 - 模板实际仅用 `.uk-light`(用户背景封面,question/list.html、question-not-found.html;内部 h3/p 由 #usernamecolor/#introcolor 内联 JS 覆盖,优先级最高),其余有色容器全仓无 class 使用 → 补丁可只投影 .uk-light(477B 代替 9KB)。 - 测速(本地国内网络,全量 263KB 3 次):cdnjs.snrat.com ~0.25s(热)最快 > jsdelivr ~0.47s > unpkg ~1.3s > cdnjs.cloudflare ~1.0-1.4s > R2 自建 1.4-2.0s。check-host.net 全球 12 节点:snrat 与 jsdelivr 均 12/12 = 200 OK(snrat 根路径 301 不影响带资源路径请求)。snrat 缓存头 max-age=31104000(360 天)。 - 落地修改(仅 templates/base/header.html): 1. css link → `https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css` 2. js(uikit.min.js/uikit-icons.min.js)→ `https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/...`(CDNJS 路径与 cdnjs 官方同构,已验证文件存在 Content-Type 正确) 3. link 后新增补丁 `