# TODOS > 由 /plan-eng-review(2026-08-04,watch-folder 方案评审)首次生成。格式:What / Why / Pros / Cons / Context / Depends。 ## 1. LibraryShell.tsx 拆分(存量债) - **What:** 把 1240+ 行的 `next-app/src/app/library/_components/LibraryShell.tsx` 拆到 <800 行——导入编排、溢出菜单、可用性展示各自成模块。 - **Why:** 全局编码规则红线是 800 行,当前已超 55%;每个新功能都在往里堆(watch-folder PR-1 又 +~10 行)。现在拆比半年后拆便宜得多。 - **Pros:** 后续 library 迭代全部受益;接线层可测性改善;审查负担下降。 - **Cons:** 纯重构 PR,无用户可见价值;碰面大,需要完整回归(e2e library 两条 spec + 手测导入/播放链路)。 - **Context:** 2026-08 watch-folder 评审中发现的存量债,与该 feature 无阻塞关系。拆分时天然的切缝:handleAddFolder 导入编排、HudOverflowMenu items 构造、availability/probe 展示逻辑、ImportDrawer/pill 状态机。遵守"结构性与行为性改动分离"原则,单独 PR。 - **Depends on / blocked by:** 建议在 watch-folder PR-1/PR-2 合并后动手,避免同期冲突。 ## 2. library 逻辑层测试债回填 - **What:** 给 `enumerator.js` / `fileHandleStore.js` / `useFileHandles.js` / `importPipeline.js` 全链路补单测。PR-1 只补被改到的部分(reuse 分支回归 + touchSeries + useImport 默认行为)。 - **Why:** 代码里预留的 DI seam(`useImport` 的 `{db, dandan, hashPool}`)从未被使用;全局规则要求 80% 覆盖率。 > 事实更正(2026-08-22 评审):本条原写「整个 library 逻辑层只有 `buildLibraryMatchResult.test.js` > 一个测试文件」——已不成立。`_services/` 下现有 7 个测试文件(`rescanController`/`rematchPayload`/ > `loadSeriesRows`/`rescanService`/`folderWatcher`/`resolveSeriesBinding`/`resolveMergedIds`)。 > 仍缺的是 `enumerator.js` / `fileHandleStore.js` / `useFileHandles.js` / `importPipeline.js` 这条链。 - **Pros:** PR-2(改管线核心)/PR-3(observer)都有安全网;PR-1 会建好 fake DirectoryHandle(async-iterator 夹具)和 fake repo 层,回填时直接复用,边际成本低。 - **Cons:** 纯测试 PR,管线分支多(matchCache 命中/过期、reuse/new/ambiguous、跨文件夹 merge),工作量不小。 - **Context:** 测试跑法是 `bun test` 纯逻辑测试(无 RTL/jest/fake-indexeddb);仓库惯例是逻辑抽纯函数单测(authForm/registerFlow 模式)。importPipeline 可经 fake repos 测 processCluster,不需要真 Dexie。 - **Depends on / blocked by:** watch-folder PR-1 落地(复用其测试夹具)。 ## 3. Dexie schema v6:fileRefs 加 [libraryId+relPath] 索引 - **What:** `next-app/src/lib/library/db/db.js` 追加 version(6),fileRefs 索引串在现有基础上追加 `[libraryId+relPath]`。 - **Why:** watch-folder 用全局 (relPath,size) 基线 + toArray 全表扫,当前规模(数百行)毫秒级完全够用;索引只在库上万行、或实现"relPath 消失条目清理"功能时才有收益。 - **Pros:** 加的时候只需一次 version bump,索引-only 变更无需 upgrade 函数;为失效条目清理铺路。 - **Cons:** 现在加是无收益 schema 变更,白承迁移风险。 - **Context:** ⚠️ 届时注意:Dexie 的 `version(N).stores({fileRefs})` 是整表替换不是合并——必须重声明**完整**索引串:`'id, episodeId, hash16M, matchStatus, [libraryId+matchStatus], *libraryIds, [libraryId+relPath]'`(2026-08 评审验证时确认,db.js 当前无裸 libraryId 索引)。 - **Depends on / blocked by:** 触发条件:库规模上万行,或"文件消失处置"产品决策落地为清理功能。 ## backfill 队列的暂停 / 恢复控制 **What** — 给 `/admin` 的「中文简介」区块加一对暂停/恢复按钮,作用于 `description_backfill` 队列。 **Why** — 首次回填要连续跑约 31 小时。这期间如果 bgm.tv 出问题、或者发现写入有误想紧急刹车,目前唯一的手段是 SSH 上 prod 改数据库、或者重启容器——而重启会把在线富化一起停掉。独立队列的价值有一半就在「能单独停」,现在这半个价值还没接出来。 **Context** — P3 已经把 sweep 放进了独立队列(`internal/queue/control.go` 的 `DescriptionBackfillQueueName`),river 的 `QueuePause` 机制也已经在生产跑(V3 heal-CN 在用)。所以这件事是接线不是造轮子: - 后端:`queue.PauseV3` / `ResumeV3` 旁边加一对同形函数,改队列名即可 - 前端:照抄 `_actions/enrichment-queue.ts` 里的 `pauseHealCn` / `resumeHealCn` - 位置:`EnrichmentBar` 的「中文简介」区块已经留好了地方 **Depends on / blocked by** — 无。dashboard 只读部分(本次 PR)落地后即可做。 **为什么这次没做** — 它属于运维能力,不属于「看见进度」这个需求。分开做两边都更干净。 **2026-09-08 更新:后端已落地,只剩前端按钮。** river 注册表那轮把这条的后端做完了,而且比这里设想的更宽:不是「加一对同形函数」,是一组通用的 `queue.PauseQueue / ResumeQueue / QueuePaused / StatusAll`,队列名按注册表白名单校验,八条专用队列全部可停可看。HTTP 面是 `POST /api/admin/queues/{name}/pause`、`/resume` 和 `GET /api/admin/queues`。 白名单显式排除 river 的 `default`——那条队列同时装着 V1、V2、warm_season、orphan_scan,停它就等于停掉页面在等的富化;river 的 `"*"` 通配同样被拒。 未知名字返回 400 而不是 500(操作者要了个不存在的东西,和 river 挂了是两回事)。 顺带修掉一个:`PauseHeal` / `ResumeHeal` 在 `QueueCtrl` 为 nil 时会返回 `200 {"paused":true}` 却什么也没暂停。现在返回 503。 **还剩什么** — 只有前端:`EnrichmentBar` 的「中文简介」区块加按钮,照抄 `_actions/enrichment-queue.ts` 里的 `pauseHealCn` / `resumeHealCn`, 把路径换成 `/api/admin/queues/description_backfill/pause`。`GET /api/admin/queues` 已经把可停队列名和各自的暂停态一起返回,前端不需要硬编码队列列表。 ## anime-relations:字幕组连番号的精确重定向表 - **What:** 接入 [`erengy/anime-relations`](https://github.com/erengy/anime-relations),把字幕组连续编号的 续季集号精确重定向到该季的 1..N,补上启发式归一覆盖不到的场景。 - **Why:** 本地库的显示层归一(`normalizeEpisodeNumbers`)是个保守推断——只在「本地最小集号 已越过本季集数」且「构成连续段」时才平移。文件没下全、一季拆两个 cour、跨季混在同一 文件夹这三种它会主动不动手,结果仍是网格前半截是死格子。 - **Pros:** 规则表是 **public domain**(比 `go-api/data/hant/` 那份 CC BY-SA 还干净,不存在 AGPL 污染顾虑);2026-08 仍在维护;**规则里第三个 id 直接就是 AniList id**,正好是 `anime_cache` 的主键,不需要中间跳转;约 1,000 条规则,压缩后几十 KB。 - **Cons:** 引入一个需要定期同步的外部数据集——和 `refresh-bgm-map` 同一个形状,而那个 workflow 曾静默失败一个月无人察觉(见 issue #61/#62 的修复)。同步失败必须有告警, 否则规则表会悄悄过期。 - **Context:** 规则语法 `MAL|Kitsu|AniList:范围 -> MAL|Kitsu|AniList:范围`,`!` 后缀表示 同时生成一条自指规则,`~` 重复源 id,`?` 未知。两种接入方式:(a) 数据烤进构建产物, 照 `go-api/data/hant/` 的模式——独立目录 + 独立 license 文件;(b) go-api 加一个 `/api/anime/relations` 端点定期拉取并按 anilistId 索引,省客户端体积但多一次往返。 备选数据集 `eliasbenb/PlexAniBridge-Mappings`(MIT,每日自动生成,AniList 主键)覆盖更全, 但它解决的是 TVDB 季结构对齐而不是字幕组连番号,对这个场景是间接的,**不作首选**。 - **Depends on / blocked by:** 触发条件 = 显示层归一上线一个月后,统计仍有可观比例的 卡片集号不对。在那之前不要动——先拿覆盖率数字。 ## 导入抽屉:整个哈希阶段只显示一个静止的「准备中…」 - **What:** 拆掉 `useImport.js` 哈希阶段的 `Promise.all` 屏障,改成边算边吐行(下游本来就有 `running` 状态);并在枚举结束时就把文件总数交给进度条,不必等哈希做完。 - **Why:** 现在第一行要等**所有**文件都哈希完才出现,而抽屉的空状态条件就是 `rows.length === 0`, 所以整个哈希阶段界面是一个静止的「准备中…」,进度显示 `0 / —` ——**分母其实枚举完就知道了**。 用户无法区分「在算」和「卡死」,唯一的判断办法是去看后端日志确认还没进匹配阶段。 实测有人因此以为导入挂了。这是纯 UI/编排问题,管线本身是对的。 - **Pros:** 首行出现时间从「全部哈希完」降到「第一个文件哈希完」;进度条真的会动; 不需要改哈希本身,也不需要动匹配逻辑。 - **Cons:** 边吐边算意味着行的顺序不再是枚举顺序,分组渲染要能接受乱序插入; 失败重试的语义要重新想一遍(现在是屏障之后统一处理)。 - **Context:** 屏障在 `next-app/src/app/[lang]/library/_hooks/useImport.js` 的 `await Promise.all(items.map(...pool.hash(item.file)))`;空状态在 `_components/ImportDrawer.tsx` 的 `rows.length === 0` 分支。 哈希池并发被 `lib/library/hashPool.js` 的 `MAX_POOL_SIZE = 4` 卡死,跟机器核数无关, 所以耗时大致是 `文件数 ÷ 4 × (读前 16MB + MD5)`,走 File System Access API + Worker。 **注意 `MAX_POOL_SIZE` 是刻意的上限,不要顺手调大** —— 先确认它当初为什么被压到 4。 - **Depends on / blocked by:** 无。与集数显示那条线无关,可独立做。 ## go-api 的 Dockerfile 健康检查打的是一个不存在的路由 - **What:** `go-api/Dockerfile` 的 `HEALTHCHECK` 打 `/healthz`,但服务只注册了 `/health` (`cmd/server/main.go`)。改 Dockerfile 里的路径即可。 - **Why:** 那条 HEALTHCHECK **从来没成功过**,一直在 404。compose 起的容器之所以显示 healthy, 是因为 `docker-compose.yml` 用 `/health` 覆盖了它。裸 `docker run` 这个镜像会永远 unhealthy, 任何依赖镜像自带健康状态的编排(k8s、swarm、别人 pull 下来跑)都会拿到错误答案。 副作用是每 2 秒往日志里写一条 404,把真实请求淹掉——排查时很误导。 - **Pros:** 一行改动;顺带让日志干净。 - **Cons:** 无。唯一要小心的是别反过来把路由改成 `/healthz` —— compose、 部署脚本和文档都已经用 `/health`,改路由要同步改一圈,改 Dockerfile 只动一处。 - **Context:** 2026-08-22 本地起全栈时发现。`grep -rn 'healthz' go-api/` 能看到全部引用。 - **Depends on / blocked by:** 无。 ## 匿名访客的访问量,后台完全看不到 - **What:** 从 nginx access log(或 Cloudflare Analytics / Logpush)离线统计未登录访客的 页面访问量,写进一张日聚合表,在 `/admin` 的活跃度面板里单独开一块显示。 - **Why:** 活跃度面板每一个数字都按 `user_id` 统计 —— DAU/WAU/MAU、访问天数、留存, 全部只覆盖已登录访问。而本站是 SEO 驱动的目录站,**流量大头是没有登录的**, 所以后台看到的是一小块,不是全景。读到「今天 41 人活跃」时很容易忘掉这件事, 尤其是半年以后。 - **Pros:** 请求路径上零成本 —— 日志本来就在产生,统计是离线的;Cloudflare 那一侧 连边缘缓存命中的请求也能看见,而源站日志永远看不到那部分(命中时请求到不了源站)。 - **Cons:** 需要一个日志解析作业(river 定时);区分不了爬虫和真人,得自己维护 UA 过滤, 而这份过滤会持续腐烂;CF Logpush 属于付费套餐,要先确认账号情况。 - **Context:** 这个缺口本来打算用一个浏览器 beacon(`POST /api/activity/beacon`)来填, 在 2026-08-28 的评审里被砍掉了 —— 原因是 `/anime/*` 走 CF 边缘缓存,命中时请求根本 到不了源站,而 beacon 是 POST(CF 从不缓存 POST),等于把「零源站负担」变成 「每次真人浏览一个必然打到源站的请求」;它还会占用 `isPublicReadExempt` 只放行 GET 的那个 per-IP 限流额度(1 rps / burst 60),可能把翻页快的读者 429 掉。 日志路线是同一个需求的零请求成本实现。迁移 `0025_user_activity.up.sql` 的头注释里 也写了同一句结论,两处可以互相印证。 - **Depends on / blocked by:** 无。与活跃度面板已有的部分完全独立,可以随时单独做。 ## bangumi 绑定决策无法从存储状态重建 - **What:** 在 `anime_cache` 加一个 text 列(暂名 `bgm_match_name`),让 `UpdateBangumiV1` 在绑定成功时把当时选中的 Bangumi 侧原名 `best.Name` 一并写下来。 然后基于它做一次存量绑定审计。 - **Why:** 现在绑定成功只写回 `bgm_id` / `title_chinese` / `bgm_match_source` 三个字段, **Bangumi 侧的原名从来没被存下来**,全库也没有这个列。后果是一条已经存在的绑定, 事后没办法回答「当时是拿哪个标题匹配上的」—— 也就没办法判断它对不对。 `title_chinese` 只是部分代理:它在 `NameCN` 为空或等于 `Name` 时是 NULL, 而且后续 v2/v3 富化可能已经改写过它。这是「bgm 绑定 41% 无来源」那笔债的 结构性原因之一 —— 不是没记来源,是就算记了也无法复盘。 - **Pros:** 一个列 + 一个 UPDATE 字段,约 10 行;从此每条新绑定都可复盘, 存量审计从「不可能」变成「一条 SQL」。 - **Cons:** 需要一条 migration(本仓已到 0026);按项目惯例写 migration 前要先走 `database-migrations`;而且价值要到真的做存量 sweep 那一期才兑现,单独做收益为零。 - **Context:** 2026-08-28 的 `/plan-eng-review` 里发现(季号信号方案的 D3)。当时刻意 没有并进那个 PR —— 那个改动是单文件纯逻辑、无 schema 变更、前向 only,加一条 migration 会改变它的风险类别,而本仓 `description_cn` 那一轮用了 0014→0015→0016 三次才定下来。 另一个必须一起想的点:`bangumi_v1` 只处理 `bangumi_version = 0` 的行,成功后置 1 永不回头,所以存量行不会自己重新评估,审计和纠正都得单独写 sweep。 - **Depends on / blocked by:** 无技术前置。建议与存量绑定 sweep 同期做,否则加了列也没人读。 ## 分割放送会 50% 概率绑错,而且标成高置信 - **What:** 给 `internal/bangumi` 的候选打分补上季号信号。**要先解一个包依赖环。** - **Why:** `NormalizeTitle` 的 `seasonSuffixReplacer` 把 `season2..9` / `第2期..第9期` / `ii,iii,iv` / `part2..5` / `ova,movie` 全删掉,之后季号和格式**不再作为任何分量出现** (`ScoreCandidate` 只有 titleSim / yearScore / epsScore 三项)。分割放送下 S1 和 S2 同年 同集数时两个候选得分**完全相同**,argmax 退化成「列表里谁在前面」,而档位规则 1 (`ts≥0.95 && ys≠0`)立即命中 → 权威绑定。同一个陷阱对「剧场版 vs TV 版」也成立。 - **Pros:** 直接消掉一整类静默错绑;`titlematch` 已有 `ExtractMarker`/`MarkerFor`/`SameEntry` 且在 live data 上被打磨过(`season.go:70-79` 记录了 anilist 213097 ↔ bgm 659686 的假阴性修复)。 - **Cons / 已知陷阱(2026-08-28 评审 + codex outside voice 找出来的,重写时别重踩):** 1. **`internal/titlematch/titlematch.go:35` 已经 import 了 `internal/bangumi`**, 所以 `bangumi/match.go` 不能反向 import —— 编译成环。 ✅ **2026-08-28 已实测出解法,比预想便宜得多(0.5 人天,不是「跨包重构」)**: 环的承重面只有 **一行** —— `titlematch.go:53` 的 `bangumi.TitleSimilarity` (全包 5 处提到 bangumi,其中 4 处是注释;`SearchResult` 一次都没出现, 我最初的猜测是错的)。而 `season.go` 恰好**零仓库依赖** (只 import `regexp`/`strconv`/`strings`/`x/text/unicode/norm`)。 → 把 `season.go` 原样搬成 `internal/titleseason`,在 `titlematch` 留一个 `type Marker = titleseason.Marker` 的别名 shim + 三个 wrapper 函数。 **实测:调用方 0 改动、测试 0 改动**,`titlematch` 的 30 个测试一字未改全绿, `dandanplay/seasonmatch.go` 和 `queue/bangumi_episodes.go` 也不用动。 ⚠️ `Marker` 必须用**类型别名 `=`**,不能 `type Marker titleseason.Marker` ——后者不继承方法集,`SameEntry`/`Merge`/`Normalized` 会全没。 ⚠️ shim 用 wrapper **函数**不要用 `var ExtractMarker = ...`(可变包级状态且不可内联)。 2. 不要用字典序排序(confirmed > neutral > implicit > conflict)——那等于给季号 **无限权重**,一个相似度很低的候选会无条件压过接近满分的候选。Seanime 的 `−8`/`−3` 是**有界惩罚**,其它证据能翻盘,这才是对的形状。 2b. ★**季号 gate 写在哪里决定测试是 0 改还是 1 挂**(两种写法都实跑过): 写在 `ScoreCandidate` 里(markers 不一致就 `return 0`)会挂 `match_test.go` 的 `TestPickBest_TierLow_YearContradicted`,而且**会把 TierLow 塌成 TierNone —— 等于吃掉 needs-review 通道**,把「存疑」变成「无匹配」。 写在 `PickBest` 里(marker 只作 argmax tiebreak + TierHigh 准入条件)现有测试全绿。 **选后者。** 3. 「一侧声明、另一侧沉默」不能只降排序不设闸门:候选表里没有正确的 S2、 只有裸标题 S1 时它正是这一档,仍会被权威绑定 —— 洞没堵上。 4. 不要把 Season 和 Part 判定「取更差者」:双方都声明 S2 但都没写 Part 时会退化成 中性,让「双方确认」几乎不可达。 5. 现有的 `TestPickBest_TierLow_YearContradicted` **分不出**这几种设计——它的年份差 4 年,`ys=0.0` 已经把它压到 TierLow 了。必须补一个「候选声明了不同季号 **且** 年份一致」的新用例才有判别力。 - **Context:** 参照实现是 `5rahim/seanime` 的 `internal/library/scanner/matcher.go` (1596 行,GPL-3.0):带负分的累加、门槛 6.0、季号一致 +5 / 明确不符 −8 / 隐含 −3、 格式相符 +5 / 不符 −5、年份差>1年 −10。它的 `getIgnoredSynonyms` 也值得看 ——被多个候选共享的别名只保留给主标题最短的那个,且只作用于非主标题。 ★ **注意别重复一个已经犯过的归因错误**:这条路径产出的是 `anime_cache.bgm_id`, **不是** `bgm_id_map`(后者是从 `data/anilist_bgm_map.json` 灌进来的输入表, `bangumi_v1.go:128` 命中即绑定并 return,根本不走 scorer)。所以这个改动改善的是 fuzzy 匹配质量,**不是**「无来源绑定」那笔债的修复,优先级要按前者判。 - **Depends on / blocked by:** ~~被包依赖环阻塞~~ —— **已不再阻塞**,解法见 Cons 第 1 条 (0.5 人天,零调用方改动)。缺陷本身也已用可运行原型复现,不是推演: 基线下 `picked id=1 / tier=high`(绑错季且高置信),加上修复后 `picked id=2 / tier=high`; 候选表里只有错季条目时,基线 `tier=high` 无条件绑定,修复后 `tier=low` 转 needs-review。 ## 若任一路由的服务端渲染慢过约 300ms,把段内 `loading.tsx` 加回去 - **What:** 2026-08-28 把 `app/[lang]/loading.tsx` 从根部挪进了 `(home)/`,代价是 除首页、`/search`、`/welcome` 之外的路由在冷渲染时不再有骨架屏。若将来某个路由的 服务端渲染变慢,给它单独加一个段内 `loading.tsx` —— 但**前提是那个路由不调 `notFound()`**,否则 `routeBoundaries.test.ts` 会(正确地)变红。 - **Why:** 当时同意删掉的理由挂在一个会变的数上:源站直连实测首页 0.124s 渲染完, 在「感知为瞬时」的 100ms 阈值附近,先闪 91ms 骨架屏再换内容更像多余的状态切换。 这个论证在页面变慢时自动失效,而那时没人会记得当初是按哪个数判的。 - **Pros:** 阈值写下来了,重新判断时不用从头量;`routeBoundaries.test.ts` 已经保证 加回去的位置不会重新制造 soft-404。 - **Cons:** 可能很久都不会触发;渲染耗时受机器和网络影响,需要用同样的方法复测 (源站直连 TTFB vs total,差值就是流式窗口)而不是随手 curl 一下线上。 - **Context:** 那个根部文件第一行注释写着 "HomePage skeleton" —— 它本来就是首页的 骨架屏,只是放高了一层,顺带把 Suspense 边界盖到 24 个路由段上,让 5 个调 `notFound()` 的路由发不出 404。挪回 `(home)/` 之后首页的骨架屏原样保留。 当时测到的流式窗口:`/search` 0.287s、`/welcome` 0.263s(两者保留了自己的 `loading.tsx`)、`/` 0.091s、`/seasonal` 0.043s、`/calendar` 0.038s, 而 `/anime/[id]` 是 0.001s —— 它是 ISR,整页缓存后一次发出,那 167 行骨架屏 从来没有渲染给任何人看过。 - **Depends on / blocked by:** 无。 ## 把 `resolveLocale` 拆成会抛和不会抛两个变体 - **What:** `lib/i18n/route.ts` 的 `resolveLocale()` 在 `[lang]` 不是合法 locale 时 直接 `notFound()`。它被 20 个页面和全部 4 个 layout 引用。拆成一个给 layout 用的 会抛变体,和一个给「边界之下的页面」用的不会抛变体。 - **Why:** `routeBoundaries.test.ts` 现在有三条 allowlist(`(home)` / `search` / `welcome`),理由都一样:这些页面重复调了一次 `resolveLocale`,而 `[lang]/layout.tsx` 已经用同样的 params 在边界**之上**检查过了,所以页面里那一次永远不可能是决定 404 的 那一次。拆开之后这三条 allowlist 自动清空,规则变成无条件成立。 - **Pros:** 把「靠 allowlist 说明为什么破例」变成「结构上不可能破例」;也省掉每个页面 一次多余的 locale 解析。 - **Cons:** 触及 20 个页面文件,是个大范围但机械的改动;两个变体的命名要足够清楚, 否则下一个人会挑错那个。 - **Context:** 这是「为什么 allowlist 存在」的正解,不是绕过。allowlist 每条都带了 理由并且会做陈旧性检查(条目描述的违规若已消失,测试同样变红),所以现状是安全的, 只是不够干净。 - **Depends on / blocked by:** 无,但做完之后要把 `routeBoundaries.test.ts` 的 `ALLOWED` 清空,那三条陈旧性检查会提醒你。 ## 清掉「客户端语言来自 `lang` cookie」这个说法 —— 全仓 14 个文件,而那个 cookie 不存在 - **What:** `grep -rlE "lang.{0,12}cookie|cookie.{0,12}lang" next-app/src` 命中 14 个 文件。其中源头 `lib/lang-client.tsx` 已在 2026-08-28 改正,其余需要逐个过一遍: 留下真正是历史陈述的(「cookie 被移除之后……」是准确的),删掉仍在描述当前行为的。 - **Why:** 全仓**没有任何 `lang` cookie**——不写也不读。`cookies()` 只用于鉴权 (admin / library / login / register),`?lang=` 是 `proxy.ts` 里的遗留**查询参数**, 不是 cookie。而这些注释描述的是一套完整的机制:hydration 后有个 effect 把语言换成 cookie 值,所以客户端语言和服务端渲染「故意不一致」。这套机制不存在。 - **Pros:** 这不是措辞问题,它已经在改变判断。旧版季度页 (`seasonal/[season]/[year]/page.tsx`)曾用它当作一个设计决定的依据(那段注释随旧的 筛选药丸一起删了);`not-found.tsx` 曾用它论证 404 页的语言不可信 (实测可信,现由 `e2e/specs/sandbox/not-found-status.spec.ts` 三语在浏览器里钉住)。 - **Cons:** 纯注释改动,没有测试能防止它再次发生;14 个文件要一个个读,不能批量替换, 因为其中确实有几处是准确的历史陈述。 - **Context:** `lang-client.tsx` 自己的 `LanguageProvider` 注释一直是对的 ("There is no reconciliation step and no local state"),与同文件头部的说法直接 矛盾了很久没人发现 —— 一个文件里两段注释互相打架,是这类腐烂的典型信号。 - **Depends on / blocked by:** 无。 ## ✅ 已修:`proxy.ts` 把任何以点后缀结尾的路径当成静态资源,裸 URL 因此匹配不到路由 > 2026-09-15 随 hub 页一起修:`NON_PAGE_PATH` 改成真实扩展名白名单。触发点是 `/studio/J.C.STAFF`——那家有几百部番。`proxy.locale.test.ts` 钉住白名单和四个带点的页面路径;e2e 的 KNOWN DEFECT 用例翻成正向断言。下面是原始记录。 - **What:** `proxy.ts:94` 的 `NON_PAGE_PATH = /^\/api\/|^\/_next\/|\.[a-z0-9]+$/i`。 第三个分支意思是「任何以 `.` + 字母数字结尾的路径」。这类路径会跳过 locale 改写, 而**裸 URL 正是靠那次改写才能匹配到 `/[lang]/...`** —— 于是它谁也匹配不上,落到 Next 内置的 404 页。带 `/en`、`/zh-Hant` 前缀的不受影响(`en` 本身就填进了 `[lang]`)。 - **Why:** 实测:`/u/{contact-shaped handle}` 裸路径 404,`/en/u/{同一个}` 正常 308 重定向到掩码 handle。凡是邮箱形状的别名都中招,因为常见邮箱域名都以点后缀结尾。 - **Pros:** 修法很清楚:把「任何点后缀」换成真实的扩展名白名单。 - **Cons:** 那条守卫是**承重**的 —— 它防止 `/sitemap.xml`、`/robots.txt`、jassub 的 `.wasm` 被改写到 locale 段下面,而 sitemap 是这个站最不能出事的 URL。白名单漏一个 就等于弄坏一个资源,所以这是一次需要自己的测试和自己的评审的改动。 - **Context:** 影响面有限:受害的只有**别名**形态,而站内所有链接指向的是规范形态 `/u/user-xxxx`(无点,裸路径正常)。状态码层面 404 也是个说得过去的答案,丢掉的是 那次重定向。已由 `not-found-status.spec.ts` 的 "KNOWN DEFECT" 用例钉住现状。 - **Depends on / blocked by:** 无。 ## `/u/` 的三处重定向会丢掉 locale 前缀 - **What:** `/en/u/{alias}` 重定向到**裸** `/u/user-xxxx`,`/en` 没了。英文读者被静默 送回中文站。三个 `/u/` 重定向调用点都是这样。 - **Why:** `permanentRedirect()` 收到的是硬编码的 `/u/${...}`,没有经过 `localizePath()`。 以前这个缺陷是隐形的:重定向根本发不出状态码(流式响应把状态冻在 200),走的是客户端 导航。2026-08-28 拿掉 `loading.tsx` 之后它变成真 308,缺陷才暴露在 HTTP 层。 - **Pros:** 改动很小 —— 那三处套一层 `localizePath(path, locale)` 即可,页面本来就有 locale。 - **Cons:** 需要同时更新 `not-found-status.spec.ts` 里那条 "KNOWN DEFECT" 用例,它目前 断言的是「前缀确实丢了」,修好之后会(正确地)变红。 - **Context:** 这是那类「只有把上游修对了才看得见」的缺陷。 - **Depends on / blocked by:** 无。 ## 404 的 `Cache-Control` 和真实番剧页一模一样 - **What:** 生产构建下 `/anime/{不存在的 id}` 返回 `s-maxage=60, stale-while-revalidate=31535940`,与 `/anime/21` 逐字节相同。 于是一个 404 会进 Cloudflare 边缘缓存,在该 id 变成真页面之后仍可能被发出一次。 - **Why:** ISR 的 `revalidate` 是**整条路由**的属性,Next 没有在页面层面按状态码区分 缓存时长的声明式写法。要区分得走 nginx 或中间件。 - **Pros:** 实际敞口很小:SWR 语义是「发陈旧的同时后台重验」,所以最多是新鲜窗口过后 的第一个访客拿到一次旧的 404,下一个就是新的。 - **Cons:** 这条**没有测试**,而且故意没有:沙箱套件跑在 `next dev` 上,那里这个头是 `no-cache, must-revalidate`,断言会永远为真 —— 一条不可能失败的绿灯比没有断言更糟。 要测只能放线上那个 project,且只能在部署之后跑。 - **Context:** 与 2026-08-28 的状态码修复无关,线上今天就是这个头,变的只是被缓存的 东西从 200 变成了 404。 - **Depends on / blocked by:** 无。 ## 未匹配任何路由的 URL 拿到的是 Next 内置 404,不是我们设计的那张 - **What:** `/en/nope-nothing-here` 这类打不中任何路由的地址,渲染的是 Next 自带的 `404: This page could not be found.`,没有 Navbar、没有品牌、没有去处。我们设计的那张 只在页面主动调 `notFound()` 时出现。 - **Why:** 我们的 `not-found.tsx` 在 `app/[lang]/` 下,不是 app 根。根级未匹配 URL 要 `app/not-found.tsx` 或 `app/global-not-found.js` 才接得住,而我们**不能**建 `app/not-found.tsx` 之外的根级静态段(会遮蔽 `[lang]`)。 - **Pros:** Next 文档正好点名了我们这个形状:「根布局用顶层动态段(如 `app/[country]/layout.tsx`),会让统一的 404 页更难拼」,并把 `global-not-found.js` 列为对应答案(`node_modules/next/dist/docs/01-app/03-api-reference/ 03-file-conventions/not-found.md`)。 - **Cons:** 该特性 v15.4.0 起仍是 experimental,需要开 `experimental.globalNotFound`, 且它绕过正常渲染 —— 得自带 ``、字体和全局 CSS。 - **Context:** SEO 上无损(状态码就是 404,Next 还自动注入 noindex),纯粹是体验和品牌 问题:打错一个字的访客拿到的是一张空白页而不是回站内的入口。 - **Depends on / blocked by:** 无。 ## 台湾/大陆译名不同 —— 简繁折叠做完之后剩下的那 59% - **What:** 2026-08-28 给 `/api/anime/search` 加了查询词简繁折叠(用现有的 `opencc-s2twp.txt`,转出的繁体额外去匹配 `title_hant`,不写库、不改显示)。 **这条 TODO 的本体已完成。** 留下的是它够不到的部分:**同一部番在台湾和大陆 用不同译名**,任何字符映射都跨不过去。 - **Why:** 实测而非估计。拿 **5,160 行**同时有简体标题和**权威**繁体标题 (`title_hant_source <> 'opencc'`)的记录做测量:折叠后的简体标题能成为库里繁体 标题的子串(正是搜索用的判据)的比例是 **40.7%**,不折叠是 **7.4%** —— 提升 约 5.5 倍,但不是"解决"。 - **Pros:** 失配的成因已经查清,不用再猜:**2,237 条失配长度就不同**,是彻头彻尾 的另一个译名 —— 海賊王 / 航海王、三眼小子 / 三眼神童、鋼鐵奇兵 / 金屬對決、 勇敢魔女 / 無畏魔女。剩下 822 条等长的也多是译名差异(兄弟鬥爭 / 兄弟戰爭) 和繁体行文惯例(你 / 妳),不是字形转换错误。 - **Cons:** 要再往前走只有一条路:**别名表**。可以从 AniList 的 synonyms 里挖 (那本来就是多译名的来源),也可以人工维护热门番的别名。这是独立的一件事, 且收益递减 —— 40.7% 已经覆盖了最常见的情况。 - **Context:** 另有 **57 行「有简无繁」**(用户敲繁体、库里只有简体)仍未覆盖, 需要 t2s 表而仓库只 vendor 了 s2twp。57 行太少,是否值得再 vendor 一份表留待再判。 ★还有个坑值得记:**s2twp 不是纯字形转换**,`wp` 是台湾词汇替换 ——「折叠」会变成 「摺疊」而不是「折疊」。写测试探针时用了这类词会让测试红而功能其实是好的, 我为此排查了两轮。 - **Depends on / blocked by:** 无。 ## 不存在的番剧 id 在 AniList 被限流时返回 500 而不是 404 - **What:** `/anime/{不存在的 id}` 走的是「本地缓存未命中 → 实时问 AniList」。AniList 一旦限流,go-api 返回 `AniList rate limited`,next-app 抛 `ApiError`,页面变成 **500**。实测:沙箱套件用 `--repeat-each=3` 并发压一下就稳定复现,dev 日志逐字写着 `Error [ApiError]: AniList rate limited` 后跟 `GET /anime/999999999 500`。 - **Why:** 一个不存在的 id 本该答 404,在限流窗口里答的是 500。缺陷本身与 CI 无关、 独立可复现(见上面的 `--repeat-each=3`),代价写在下面的 Context 里。 > ★**归因更正(2026-08-29)**:本条原写「这大概率就是沙箱 CI 里 > `not-found-status.spec.ts` 那条 `/en answers 404` 偶尔变红、重试即过的真正原因」—— > **那句是错的,已删**。那个 flake 的真身是 Turbopack dev 下**整类嵌套动态路由变 > 404**(见下一条「Turbopack dev 下……」)。三条判据各自独立地否掉了本条: > 症状是 **404 不是 500**;受害路由包括 `/reset-password/[token]`,它**一次 AniList > 都不调**;而失败那次里 `/anime/*` 的 404 只花了 42ms(`next.js: 5ms`), > 根本没进过第三方调用。两件事只是恰好都落在同一个套件上。 > ⚠️ **同一句错误归因也逐字写在 `e2e/specs/sandbox/not-found-status.spec.ts:59-60` > 的注释里**("almost certainly the cause of the `/en answers 404 (retry #1)` > flake"),修这条缺陷时一并改掉,否则它会继续把下一个人引到 AniList 上。 - **Pros:** 修法和 2026-08-28 的搜索改动是同一个思路:**目录里没有的 id,本地就能判定 不存在,不必问第三方**。`anime_cache` 有 17,689 行,一个 id 不在里面且不是新番的 概率极高。至少可以做到「AniList 不可达时退回本地判定」而不是直接 500。 - **Cons:** 有真实的取舍:一个刚上映、我们还没收录的番,本地判定会给出 404,而现在 它能通过 AniList 找到并顺带入库。要保留这个自愈能力,就不能无条件本地化。 - **Context:** 对 SEO 的影响是实的 —— Googlebot 撞上限流窗口时,一个本该是 404 的 URL 会返回 500,而 500 会让爬虫认为是临时故障并反复重试,比 404 更糟。 `not-found-status.spec.ts` 里那几条状态断言故意保持严格(只接受 404),**不要**为了 让 CI 变绿而放宽成「404 或 500」—— 那会把这个缺陷永久藏起来。 - **Depends on / blocked by:** 无。与本地搜索独立,但共用同一个判断。 - **进展(2026-09-23,分支 `fix/anilist-budget-cold-id`):** 「反复打 AniList」那一半已修 —— AniList 答「不存在」的 id 在进程内记 24h(`detail.go` `absent`,只在冷 id 分支写,429/超时/ 其它上游错误**不记**),同一冷 id 的并发请求经 singleflight 只打一次上游。**「限流窗口里冷 id 仍 5xx」这一半保留**:2026-09-20 eng-review(D3)否决了「AniList 不可用时本地判定 404」, 因为那会对刚上 AniList、我们还没收录的新番答 404,而且会被 CF 缓存(60s + SWR);503 是 诚实的「暂时不知道」,爬虫和 CF 都不会把它缓存成结论。 ## Turbopack dev 下,嵌套在 `[lang]` 里的动态路由会整类 404 - **What:** 沙箱 e2e 时红时绿、重跑即过,一直被当成不明 flake。真身不在测试里: `next dev` 用的是 Turbopack(Next 16 起是默认),而某些进程实例里**凡是 `[lang]` 下面还有一层动态段的路由,一整轮全部 404** —— `[lang]/anime/[id]`、 `[lang]/reset-password/[token]`、`[lang]/u/[username]`、 `[lang]/seasonal/[season]/[year]` 无一幸免。止血办法是让沙箱的 dev server 也走 webpack(`next dev --webpack`,该 flag 确认存在),与 `next build --webpack` 对齐。 - **Why:** 形状本身就排除了产品代码:两个 PR(#127 改 CSS/SVG filter、#128 改弹幕速度 设置)**diff 毫无交集**,却在 attempt 1 挂掉**同样 9 条用例、同样的行号** (散落在 6 个 spec 文件里),且都在**零代码改动**的重跑里全绿。 再看范围:`[lang]` 是唯一动态段的路由(`[lang]/faq`、`[lang]/search`、 `[lang]/privacy`)自始至终正常,`[lang]` **之外**的动态路由 `/sitemaps/anime/sitemap/[id].xml` 同一轮里也正常 200 —— 坏的恰好是 「`[lang]` + 再嵌一层动态段」这一类,三段路径 **0 成功 / 19 次失败**。 - **Pros:** 两条判据都能直接从 CI 日志读出来,不必复现: 1. **`generate-params` 阶段在不在。** next-dev.log 里每条 200 都带 `generate-params: …`,每条 404 **一个都没有**。这一段缺失=该段从没被编译。 2. **404 花了多久。** 健康时 `/anime/0` 是 `404 in 942ms (next.js: 870ms)` —— 段编译过了、页面在 `anilistId <= 0` 上主动 `notFound()`,这是**产品的** 404。 故障时是 `404 in 42ms (next.js: 5ms)` —— 从没编译过,404 来自路由层。 ★**状态码一模一样,来源完全不同** —— 只看状态码永远分不出这两件事。 - **Cons:** `--webpack` 是**止血不是修复**,而且 2026-08-29 的对照组实测证明**现在还不能上**。 同一个 commit 推 6 个分支各跑一次(不同 ref 才能绕开 `cancel-in-progress`,同 ref 连发只会 互相取消): | | Turbopack 基线 | webpack 6 次 | |---|---|---| | 嵌套动态段 404 指纹 | 14 次里出现 3 次 | **0/6,确实消失了** | | job 时长 | ~4 分钟 | **8m08s–9m23s,约 2.2 倍** | | 套件结果 | 间歇性红 | **6/6 全红** | ★**它换来了一个更糟的缺陷**:7 条用例在 6 次里每次都挂(`auth` 3 条、`locale-routing` 2 条、`stale-tab` 2 条),确定性的,不是抖动。机理在第一条里写着 —— 期望 「邮箱或密码错误」,实际拿到「邮箱格式不正确」,即**打字进了 DOM 没进 React state**, 正是 #129 修 `/search` 时记下的水合竞态。#129 只修了那一个 spec,其余会打字的 spec 仍然不等 `__reactFiber$`,而 webpack 更慢的编译把那个窗口从偶发撑成了必现。 **结论:套件本来就依赖 dev server 足够快,Turbopack 的速度一直在掩盖这件事。** 换打包器之前必须先把这些 spec 的水合等待补上,否则是拿一个间歇性红换一个确定性红。 另外两块代价不变:沙箱从此不再覆盖生产之外的第二个打包器;**上游那半还没查** —— 这是不是已知的 Next issue、有没有在更晚的 16.x 里修掉,目前是空白 (查的时候 web search 额度已耗尽,不是查过没有)。在补上这一步之前, 只能说「webpack 下没复现 404」,**不能**说「根因是 Turbopack 的某某」。 - **Context:** 全仓只有沙箱在跑 Turbopack —— `next-app/package.json` 的 build 是 `next build --webpack`,只有 `dev` 走默认。当前钉的是 `next@16.2.6`。 ★**最值钱的是早期指纹**:warm-up 那一行 `warmed /reset-password/warmup -> 404`。 那个页面不调任何 API、也没有任何 `notFound()` 分支,**健康栈里它不可能是 404**。 翻了 14 次历史运行它出现过 3 次,**3 次对应的 attempt 全部失败**。而 warm-up 的守卫 只对 `5xx|000` 判失败,所以它两次都把指纹打印出来又原样放行了。把这一行收紧成 「`/reset-password/warmup` 不是 200 就直接失败」成本一行,收益是把一次半小时的 e2e 排查压成一条报错。 ★**取证顺序**:重跑会**覆盖** job 日志,但 `gh api repos/OWNER/REPO/actions/runs//attempts/1/logs` 仍取得到失败那次; 而 `Dump service logs on failure` 是 `if: failure()`,所以 next-dev.log 和容器日志 **只有失败那次才存在**,重跑变绿之后就永远没有了。**先取证,再重跑。** - **Depends on / blocked by:** 无。 ★★**2026-08-29 补:换打包器不是唯一出路,自愈已实测可行。** 自建最小复现(290 个 page + 2.2MB 共享模块 —— **空 app 复现不出来,0/20**),把 dev server 打进故障态 41 次分三组: **control 0/13 修好、`touch`(纯 mtime,零字节写入)11/11、内容写入 11/11**。 macOS 与 Linux 容器两边都跑了(FSEvents 与 inotify 对 `utimensat` 的处理没理由假设同构)。 机理不是只看状态码翻转:touch 前 `404 in 43ms (next.js: 5ms)`(没编译), touch 后 `200 in 818ms (generate-params: 688ms)`(段第一次被编译)。 三条硬约束,都是实测不是推的: ① **touch 必须打在 `app/` 底下** —— 打在被 import 但位于 app/ 外的模块上 **0/2**; ② **可以 touch 一个惰性文件**(app/ 下的空文件、零 import)**2/2**, 所以自愈脚本不需要编辑任何 page 源码; ③ 检测到故障再 touch,落点远在 ready 之后,不会踩上游说的「ready 后约 1.5s 事件被吞」窗口。 ★**一条诚实的保留**:复现里死的是**动态段下面的静态子段**,而动态叶子全部活着 —— **和我们 CI 里 404 的形状正好相反**。复现的确定是上游那个缺陷; 我们 CI 那次是不是同一个,靠指纹吻合推断,**这次实验没有直接证明**。 所以判据只能用指纹(瞬时 404 + 缺 `generate-params` + 整类全灭 + 持续整场),不能用路由形状。 ★方法论坑,值得记:对照组差点出假阳性 —— 一台试验中的 server 被误杀,post 探测全是连接错误 (status 0),而分类器只把 **404** 当「死路由」,于是记成「自愈成功」。 **「不是失败信号」不等于「成功信号」**;探测器要加存活断言。 ★**退出条件 —— 什么情况下可以把 Turbopack 换回来**,三条都满足才动: ① 找到对应的上游 issue,并确认它在某个具体的 16.x 里标为已修; ② 升到那个版本; ③ **用同一个指纹复验**:连跑若干次沙箱,`warmed /reset-password/warmup` 全是 200, 且 next-dev.log 里嵌套动态路由的 200 全都带 `generate-params`。 「跑了一次没红」**不算数** —— 这个缺陷本来就是间歇的,n=1 在本仓已经骗过人不止一次。 ## 断言「不存在」的用例,在整体故障下会以错误的理由变绿 - **What:** 扫一遍沙箱套件,把「断言 404 / 不可见 / 空结果」却**没有同路由族正例配对**的 用例挑出来,逐条补上正例。**这条 TODO 的本体是那次扫描**,不是某一个文件 —— 下面的样本只是发现它的入口,不是全部。 ★**已配对两处,其余未扫**(别把这两处当成扫描做完了): ① `/seasonal//` —— 原本唯一的正例在 `chromium-prod`, 那个 project 打的是**已经部署的线上站**,所以「PR 把每一季都改成 404」照样全绿。 正例已加进 `not-found-status.spec.ts` 并**排在文件第一位**:该文件是 `mode:"serial"`,前面任何一条红掉,后面的全部 **skip**,只有第 1 位不会被静音。 ② `stale-tab.spec.ts` 那 3 条「通知不出现」—— 它们此前在**功能彻底不工作**时 也全绿(实测:让监听器永不注册,旧代码那 3 条一条不红)。现在它们先等 `/version.json` 的往返真的发生,再问通知有没有出现。 ★这两处给出的通用配方:**负向断言要先等一个「被测能力确实运行过」的可观测量**, 不能只等一段时间然后断言什么都没发生 —— 后者与「能力从没启动」完全同形。 - **Why:** 2026-08-29 那次故障给了一个教科书级的样本:**全站每一条嵌套动态路由都在 404**,而 `not-found-status.spec.ts` 里断言 404 的 **10 条用例全绿** —— `/anime/0`、`/anime/abc`、`/anime/-1`、`/anime/999999999`、 `/seasonal/notaseason/2026`、`/u/{不存在}` 及其 `followers`/`following`, 再加三个 locale 前缀各一条。它们要的答案是 404,而路由层正在无差别地发 404, 于是**每一条都以完全错误的理由通过了**。一条断言「这个不存在」的用例, 在「什么都不存在」的时候没有任何判别力。 - **Pros:** 配对的效力不是推演,**同一个文件里就有现成的对照组**: `not-found-status.spec.ts:207` 的 "a real anime still answers 200 in every locale form"。那一轮 11 条状态断言里**唯一变红的就是它**,而它的注释当初就把理由写清楚了 —— "a change that 404s the whole catalogue passes every assertion above"。 作者预见到了这个形状并挡住了;缺的只是把同一条规则推广到其余用例。 - **Cons:** 配对不是免费的:正例需要一条真实存在的夹具数据,而夹具生命周期在 `fullyParallel` 下本身就是出过 bug 的地方(#123 修过一次共享夹具的并发缺陷, #118 那轮的三条红也全是我自己 spec 的并发缺陷)。补正例时别顺手引入新的共享可变状态, 否则治好一个盲区、换来一个 flake。 - **Context:** 沙箱 20 个 spec 里有 12 个含形如 `toBe(404)` / `not.toBeVisible` / `toHaveCount(0)` / `toBeHidden` 的负向断言,共 **35 处** —— 这是扫描的**分母**, **不是**「35 处都有问题」的结论,也**不是**「已经扫过了」。逐条只问一个问题: **「如果被测的这个能力彻底坏掉,这条断言会不会照样绿?」** 会,就补正例。 ★配套的一条判据见上一条:状态码相同不代表路径相同,用例分辨不了的, 服务端日志能分辨(`generate-params` 在不在 / 404 花了几毫秒)。 - **Depends on / blocked by:** 无。与 Turbopack 那条彼此独立 —— 打包器换回去之后, 这个盲区照样在。 ||||||| parent of b94900e (docs: record the dialog primitive migration as a TODO) ## 把剩余 10 个手搓对话框迁到共享 Dialog primitive - **What:** 下一个新增的对话框会顺带抽出一个共享 Dialog primitive(backdrop、Escape、焦点进入、 焦点归还),并让它与 `components/anime/PlayButton.tsx` 同时使用。本条是把其余 10 个也迁过去: `library/_components/` 的 MergeDialog / SplitDialog / RematchDialog / OpsLogDrawer / SeriesDetailSheet、 `components/anime/TorrentModal.tsx`、`components/profile/PhotoCropModal.tsx`、 `components/safety/ReportDialog.tsx`,以及 HudOverflowMenu 与 LanguageMenu 中的同形部分。 - **Why:** 全仓 11 个组件各自手写 `e.key === "Escape"`,**没有任何共享实现**;其中只有 3 个 (HudOverflowMenu / LanguageMenu / PlayButton)做了关闭后的焦点归还。下一次无障碍修复 (焦点囚禁、`inert`、滚动锁定)要手改 11 处,而没有任何机制告诉你漏了哪几处。 - **Pros:** 每迁一个,重复的图案数量减一;顺带把 8 个缺失的焦点归还一起补上;以后无障碍修复只改一处。 - **Cons:** 跨 library / player / anime / profile / safety 五个区域,各自有转场动画、背景点击、 滚动锁定的差异,不是机械替换;纯重构 PR,无用户可见价值,需要逐个回归。 - **Context:** 2026-08-29 清点全仓对话框实现时发现。刻意没有一次性全迁 —— 让 primitive 上线 当天就有两个真实使用者(新对话框 + PlayButton),同时把重复图案数保持在 11 而不是变成 12, 且不让一次全站重构阻塞新功能。primitive 从 `PlayButton.tsx:36,49` 已验证的 rAF 焦点模式 提炼(`triggerRef.current?.focus()` / `closeRef.current?.focus()`)。 **这条 TODO 最重要的作用不是催重构,而是让下一个要写弹窗的人知道 primitive 已经存在** —— 否则抽象会被手搓的第 12 份白白作废。建议按区域分批,一次一个 PR,每个配一条 e2e 键盘用例。 - **Depends on / blocked by:** 阻塞于 primitive 先落地。 ## 网站 /library 的非 Chrome 兜底 - **What:** 给 `next-app/src/app/[lang]/library/_components/DropZone.tsx` 补上 `webkitdirectory` / `webkitGetAsEntry` 兜底路径,让 Safari / Firefox 用户能建库。`/player` 那个同名文件已经有一份现成实现(`flattenDropFiles` + ``),直接搬。 - **Why:** 实测 `library/_components/DropZone.tsx:262` 是 `if (onPick && isFsaSupported) onPick();` —— 非 Chromium 浏览器上点「添加文件夹」**什么都不会发生,没有任何兜底分支**。这些用户不是体验差,是整个本地库功能对他们不存在。 - **Pros:** `/player` 代码现成可搬;非 Chrome 用户从「完全用不了」变成「能用」;纯前端,不阻塞任何其他工作。 - **Cons:** 没有持久句柄 —— `File` 对象活不过刷新,每次开站要重选一次文件夹;也没有自动重扫(`FileSystemObserver` 同样 Chromium-only)。 - **Context:** 2026-08-29 `/plan-eng-review`(agent 方案评审)中发现。⚠️ **降级模式必须在 UI 上明说**,否则「每次要重选文件夹」会被用户读成「功能坏了」—— 这个仓库在静默降级上栓过多次。注意两个 `DropZone.tsx` 是不同文件,别改错。 **为什么不等一个"装个东西就好了"的方案:** 任何要求用户先安装本地程序的路线都只救**装了的人**。不装的 Safari / Firefox 用户在那之后依然是今天这个处境,所以这一项不被它取代。 - **Depends on / blocked by:** 无。纯前端,与其他方向零交集,任何时候都能做。 ## deploy.sh 的 smoke 检查从不因为 5xx 失败 - **What:** 让 `scripts/deploy.sh` 末尾那三条 smoke curl 在非 2xx/3xx 时真的让部署红掉,而不是只打印一行状态码。 - **Why:** 现在是 `curl -sk -o /dev/null -w "HTTP %{http_code} from /"` —— 把状态码**印出来但不判断**,三条都一样。脚本自己最后一行写着「If a smoke line shows 5xx, check `docker compose logs`」,也就是它明确依赖**有人去看那行输出**。一个全站 500 的部署,退出码是 0。这不是假想:2026-08-30 本地把 bgm 字段灌进 `anime_cache` 之后,`/anime/116674` 连续返 500,而整条流程照常走完。 - **Pros:** 改动极小(三行加 `-f`,或显式比较状态码);把仓库自己那条「验部署要 curl 不能看退出码」的教训真正写进脚本;严重度高于大多数前端缺陷 —— 500 是硬故障,不会自愈。 - **Cons:** smoke 跑在 `$COMPOSE up -d` **之后**,所以「失败」时新版已经在线且没有回滚。要么接受「大声报错但不回滚」(仍比静默好),要么顺带接上回滚 —— 后者就不是小改动了。`scripts/p9-rollback.sh` 已存在,可以先看它能不能直接接。 - **Context:** 2026-08-30 的 `/plan-eng-review` 中发现:外部声音指出 smoke 在 `up -d` 之后且不 gate,由读脚本确认。⚠️ 注意别顺手把它改成 `set -e` 直接退出 —— 那会在「新版已上线」的状态下留下一个半完成的部署,比现在更难收拾;先决定失败语义再动手。 - **Depends on / blocked by:** 无。纯 shell,与任何代码改动零交集。 ## `bangumi_version` 是单向棘轮,version≥2 的行没有任何重跑路径 **What** — 给富化流水线加一条「让一行重新过一遍 V1→V2→V3」的入队路径,不依赖把 `bangumi_version` 手动改回 0。 **Why** — 现在所有 V1 生产者(orphan scan、`/search` 与 `/schedule` 的 upsert 后置、warm_season、admin ReEnrich(0))都过滤 `bangumi_version = 0`,而 `UpsertAnimeCache` 在冲突时**刻意不覆写**版本号(`go-api/internal/db/queries/anime_cache.sql:253-258`)。V2 一跑完把版本推到 2,这一行就永久掉出所有扫描;V2 本身又只由 V1 绑定成功后的 `chainV2`(`bangumi_v1.go:264`)链式触发,没有任何周期任务生产 V2 作业。结果是**那一次跑空了就永远是空的**——`bangumi_v2.go:344` 把 episodes fetch 错误记个 warn 就 `return nil`,作业标记完成,river 不重试,而版本已经推进。 **Context** — 2026-09-02 分集标题方案评审中查出。当时的处理是**绕过**而不是修:存量用 `cmd/bgmbackfill` 的一次性 CLI 补,而不是让流水线自己重跑。但同一个棘轮对 V2/V3 写的**每一个**字段都成立——characters、bangumi_score、title_chinese、分集标题——所以下一个「某个富化字段大面积缺失」的问题会再撞一次,而且每次都得再写一个一次性 CLI。 实测规模:1,784 行有 bgm_id 却零分集标题,其中 1,522 行任何自动任务都够不到(772 行卡在 v1、3 行 v2、747 行 v3)。 ⚠️ 动它之前先确认真实的 `MaxAttempts`:三个 worker 的头注释都写「river 默认 3 次」是**错的**,v0.37.1 的 `MaxAttemptsDefault` 是 **25**,配 attempt^4 退避约 20 天。 已有的绕行补丁(都没修棘轮本身):id-map 绑定扫描(`bgm_bind_idmap.go`)、分集标题扫描、简介回填,以及 2026-10-18 加的「在播/未开播且未绑定的番每周重搜 Bangumi」(`bgm_rebind_search.go`,只管 V1 没绑上的那部分,绑定之后的 V2/V3 字段照旧只跑一次)。 **Depends on / blocked by** — 无。但与 `## 分割放送会 50% 概率绑错` 那条同属富化流水线,建议一起看。 ## id-map 一致并不排除「范围不符」,而它挡着分集网格取 max **What** — 在绑定校验里加第三个信号:上游正片集数与 `anime_cache.episodes` 的量级比对。现有两个信号(标题相似度、季号标记)和 id-map 都答不了这个问题。 **Why** — id-map 回答的是「这个 anilist 条目对应哪个 bgm subject」,**不是**「这个 subject 的集列表就是这个条目的集列表」。一个 3 集的 ONA 可以合法地映射到整部正片的 subject 上,于是我们把正片几百集的标题写进了这个小条目。实测 `anime_episode_titles.episode > anime_cache.episodes` 的有 776 部:`+6..24` 那档 **335 部**抽样全是 ONA/SP 绑到正片 subject(`我的幸福婚姻` ONA 写着 3 集库里 12 行;`机动战士SD高达` SPECIAL 3 集 17 行);溢出 >100 集的 18 部里有 **9 部是 id-map 确认的绑定**——也就是说权威映射表点头了,数据仍然是错的。 **Context** — 2026-09-02 评审实测。⚠️ **这条阻塞分集标题方案里的 T9**(`next-app/src/components/anime/episodeGridSkeleton.ts:101` 的 authoritative 分支取 max):不先解决范围不符,取 max 会把这批陈旧溢出重新渲染出来,`与莫芬一起` 那种 5 集 ONA 会被画出 3,528 个格子。所需信号很便宜——上游集数本来就在同一个响应里,不额外花请求。 ⚠️ 阈值要小心:实测溢出量是 +1 到 +820 的**连续谱**,`+1` 那档 164 部多是 AniList 少算一集的真实情况(`格斗美神·武龙` 25 集 / 上游 26 集,行是密的),不能一刀切。 **2026-09-03 更新:阈值已从分布定出,产出方已加守卫。** 按**比值**(写入的最高集号 ÷ 目录集数)重新分档,比按绝对溢出量清楚得多: | 比值 | 番剧 | 越界行 | id-map 确认 | 平均目录集数 | |---|---|---|---|---| | ≤1.10x | 122 | 183 | 89% | 29.1 | | 1.10–1.50x | 95 | 423 | 75% | 17.1 | | 1.50–2.00x | 198 | 1,829 | 63% | 10.9 | | **>2.00x** | **475** | **17,880** | 41% | **4.2** | >2.00x 一档占全部损坏的 **88%**,平均目录集数 4.2(都是 ONA/SP/短片),最坏一部 3,528 个集位。≤1.10x 一档 89% 由独立映射确认、平均 29 集、总共才 183 行——那是目录少算一两集,**标题是对的,不能碰**。 ★★**这不是绑错,是范围不符**:>2.00x 那 475 部里有 195 部绑定被 id-map 确认。Bangumi 合法地用一个 subject 覆盖 AniList 拆成正片+短片的内容,所以**解绑是错的修法**。 已做:`episodetitles.ScopeExceeded` 以 2.00x 为界,CLI 记 `SCOPE_MISMATCH`、sweep 记 rejected,**整条拒绝而不是截断**(列表属于整个系列时,它的第 1 集是系列的第 1 集不是本条目的,头和尾一样不可信)。目录集数未知的 2.9% 放行——没有分母就没有判据。**未做:存量那 20,315 行的清理**,以及 T9 本身。 **Depends on / blocked by** — 无。做完它 T9 才能安全上线。 ## dandanplay client 把所有 4xx 压成「没有数据」 **What** — 让 `go-api/internal/dandanplay/client.go:386` 区分 401/403(凭据或授权问题)、404(该资源确实不存在)、其他 4xx,而不是统一返回 `(nil, nil)`。 **Why** — 现在调用方拿到的「没有数据」同时可能意味着「这个 subject 上游确实没有」「AppId/AppSecret 失效了」「请求被拒了」。前者是正常的数据稀疏,后两者是需要人介入的故障,而它们在日志和返回值上完全一样。 **2026-09-04 更新** — 同一个函数上**另一半**已经修了:dandanplay 把应用层错误放在 **HTTP 200 的 body 里**(`{"success":false,"errorCode":429}`),而 `do()` 只看状态码、全仓从没解析过 `success`/`errorCode`,所以**配额耗尽和「这个条目没数据」完全同形**。现已加 `apiStatus` + `checkStatus`,返回 `ErrQuotaExceeded` / `ErrUpstreamRejected`。本条剩下的 4xx 部分**仍未做**:401/403/404 和其他 4xx 依然一律返回 `(nil, nil)`。两者是同一处症结的两面,但状态码那面还在。 **Context** — 2026-09-02 评审中由外部声音(Codex)指出,读代码确认。**严重度不高**:分集标题回填走的是人工跑的 CLI,凭据失效会让整份报告 100% EMPTY,跑的人一眼就能看出来;真正无人值守的只有 RELEASING 那个小 sweep(实测 TV 52 部),它静默空转的代价是「缺口不修但没人知道」。弹幕匹配路径(`match.go` / `handlers.go`)同样分不出这三种,改一次两边都受益。 **Depends on / blocked by** — 无。但它动的是共享 client,回归面包含现有弹幕功能。 ## 绑定覆盖率:811 部 R18 绑定(已终态化)+ 5,222 部压根没有 bgm_id **What** — 提高无绑定行的匹配覆盖率。(原本还包含「清理指向已不存在 subject 的绑定」,这半条已被证伪并解决,见下方 09-03 更新二。) **Why** — 这是分集标题缺口里最大的一块,但它是**绑定问题不是富化问题**。5,222 部没有 bgm_id,对应 32,471 个集位完全没有上游可取;另有 811 部卡在 `bangumi_version = 1`,抽样 12 个 bgm_id **12/12 上游返回 404**(`/v0/subjects/207567` 明说 resource has been removed),811 行里只有 1 行在 `bgm_id_map` 里有任何条目——是 `fuzzy_high` 绑到了不存在的 subject id。这批绑定不但补不回一条标题,还在每轮富化里静默消耗上游配额。 **Context** — 2026-09-02 评审实测。这 811 部创建集中在 2026-05-31~06-08,格式以 OVA/ONA 为主。解决后分集标题、中文简介、评分三个缺口一起受益。注意 `bgm_id_map` 是从 `data/anilist_bgm_map.json` 灌进来的**输入**表不是匹配器产物,所以「改进匹配器」和「扩充 map」是两条不同的路。 **2026-09-03 更新** — 这批里有一个子集已经有自动路径了。未绑定行中 826 部在 `bgm_id_map` 里**本来就有答案**,只是够不着:V1 的 tier 0 早就查这张表,但 `UpdateBangumiV1` 带 `AND bangumi_version = 0`,而这些行停在 version 3(migration 0004 记录的 Express 时代批量态),所以答案有了也没人回头用。新的 `bgm_bind_idmap` sweep 补上这个入口,可绑 **791** 部(826 减去 33 部 subject 已被别的行占用、再减去 2 部两行争一个 subject)。剩下的约 4,400 部 map 里没有条目,仍然要靠改进匹配器或扩充 map,本条其余部分不变。 **2026-09-04 收尾** — 已部署(migration 0031,prod `767326bf3522`)并排干完毕:**811 行全部离开 version 1,804 行标为上游不可读,7 行实际读到了并完成 V2 富化,0 行丢失绑定**。`bangumi_version = 1` 这个状态现在在目录里已经不存在,后台「Re-enrich v1」显示 0 并自动置灰。那 7 行(0.86%)正是 migration 拒绝在迁移里批量回填的理由——从 59 个样本推广会把它们一起标错。 **2026-09-03 更新二(推翻了上面 811 那半条的诊断)** — 那 811 部**不是死绑定,绑定全是对的**。真因是 V1 和 V2 用了两个可见范围不同的接口:V1 走老搜索 `/search/subject/{关键词}`,它对匿名调用者返回 R18 条目;V2 走 `/v0/subjects/{id}`,它把 R18 藏起来答 404。我们两边都不带 token,所以 R18 作品必然「绑得上、读不到」。 上面写的「`/v0/subjects/207567` 明说 resource has been removed」是误读:那是 Bangumi 的固定 404 文案,原文 *can't be found in the database **or** has been removed*,并不声称条目被删。拿 207567 去老搜索接口查,`僧侶と交わる色欲の夜に…` 好好地在结果里;另抽的 63029、46533、109596 同样在。抽 59 个卡住的 bgm_id 全部 404,抽 3 个已完成 V2 的绑定全部 200 —— 差异在接口不在条目。判据还有一条:这 811 行 `bangumi_score` 全为 NULL,而只有 V2 写这个字段。 **所以修法方向和原条目相反:必须保留绑定**(有 token 就立刻可用,清掉还会把 subject 当无主交回 id-map sweep)。migration 0031 加了 `bangumi_subject_unreadable_at`,V2 在 subject 404 时写终态(`GREATEST(bangumi_version,3)` + 打戳 + 钉住绑定),后台 v3 图例多一行「其中 N 上游不可读」。**排干动作**:部署后在后台点一次 `Re-enrich v1`,811 行各花一次真实请求离开 version 1;不在 migration 里回填,因为样本给的成功率上限约 5%、不是零,从样本推广会把本该能富化的行标错。约 2,433 次 bgm 请求(subject/characters/episodes 三路并发),按 800ms 桶约 30 分钟,期间分集标题和简介回填要排在后面。 唯一还留在这一条里的,是那 5,222 部没有 bgm_id 的(其中约 4,400 部 map 里也没有条目)。 **Depends on / blocked by** — 无。与 `## 分割放送会 50% 概率绑错` 同属绑定质量,建议合并考虑。 ## `anime_cache.bgm_id` 没有唯一索引,541 个 subject 已经被多行同时持有 **What** — 给 `anime_cache.bgm_id` 加唯一约束;加之前必须先裁定那 1,161 行。 **Why** — 「一个 bgm subject 对应一部番」这个假设全仓到处在用,但数据库不保证它,而且**已经被破坏了 541 次**(541 个 bgm_id 被 2 行以上持有,共 1,161 行)。直接后果:`GetAnimeByBgmID` 是 `:one` 查询,对这 1,161 行返回的是任意一行——`resolveSiteAnime` 第 2 腿和 `findSiteAnime` 第 3 腿都走它,所以 `/match` 的 siteAnime 结果对这批番是不确定的。任何「按 bgm_id 拉数据写回」的作业也会把同一份中文名和简介写到多部番上。顺带一提这一列**连普通索引都没有**,每次按 bgm_id 查都是全表扫(实测 18,009 行)。 **Context** — 2026-09-03 写 `bgm_bind_idmap` sweep 时量到。⚠️ **不是所有重复都是错的**:Bangumi 有时用一个 subject 覆盖 AniList 拆成两季的内容,那种重复是数据模型差异不是绑定错误。所以不能一刀切去重,得先按「同一 subject 的两行是不是同一部作品的不同季」分类。新 sweep 自己不会让这个数字变大——它拒绝任何已被占用的 subject,也拒绝两行争同一个 subject 的情况——但它也修不了存量。 **2026-09-08 补** — river 注册表那轮的跨模型评审从另一头撞上了同一件事,结论是这条 TODO 的措辞比那边的假设更准,记一下差在哪: 评审提出的是「`bgm_bind` 队列的 `MaxWorkers: 1` 陈述了一个正确性不变量,而 `cmd/bgmbackfill` / `cmd/hantbackfill` 是进程外的第二个写入方,所以那个保证不成立」。核实为真,但只对了一半——**这条不变量不是「离假只有一个 `--apply` 标志的距离」,它已经假了 541 次**。单槽位买到的是「这个 sweep 不会让这个数字变大」,不是「这个数字是 0」。 已经做了的是措辞那一半:`internal/queue/registry.go` 的 `FixedConcurrency` 文档现在明说 MaxWorkers 是**进程内**保证、理由必须按「这个槽位买到了什么」来写;`registry_default.go` 里 `bgm_bind` 那条按本 TODO 的实测重写了。机制那一半就是本条,前提仍然是先裁定那 1,161 行——唯一索引加不上去不是因为没想到,是因为数据现在过不了。 **Depends on / blocked by** — 无。与 `## 绑定覆盖率` 和 `## 分割放送会 50% 概率绑错` 同属绑定质量。 ## dandanplay 的额度是按功能组按天算的,而回填和线上弹幕共用同一个组 **What** — 给共享额度加一层显式预算与观测:回填运行前后记录各组用量,并让 `/match` 在番剧详情组耗尽时给出可区分的错误,而不是和「这个条目上游确实没有」返回同一个 404。 **Why** — 额度结构是:每个功能组**每天 5,000 次**、每月 120,000 次。`/api/v2/bangumi/bgmtv/:id` 和 `/api/v2/bangumi/:animeId` 都归在**番剧详情**组,而这个组同时是 `/match` 取分集列表用的组(Phase 1 和 Phase 2 都走它)。所以一次不设上限的回填不只是自己跑到停——**它会把线上弹幕功能一起带下线到当天结束**。这是实测的:2026-09-03 一次分集标题回填把该组跑到 5,000/5,000,之后线上 `/api/dandanplay/episodes?bgmId=291171` 对**同一轮跑批两小时前刚成功读过分集标题的 subject** 返回 `Anime not found on dandanplay`。 **Context** — 已经做了的一半:`--limit` 默认从 0(无上限)改成 4,500,留出一成给线上;同时修了缓存命中被计进请求数导致这个预算失准的问题。没做的一半是观测——现在要靠人去看控制台才知道用了多少,代码里没有任何一处知道自己离上限还有多远。另外**月额度完全不是瓶颈**(当月只用到 8%),唯一的稀缺资源是「天」,所以长回填的正确形态是跨天分批而不是一次跑完。 **Depends on / blocked by** — 无。与 `## dandanplay client 把所有 4xx 压成「没有数据」` 是同一处症结的两面:那一条让调用方分不清故障与稀疏,这一条说明分不清的代价有多大。 ## 分集标题:从没被问过的存量已清零,剩下 4,138 行是绑定问题不是进度问题 **What** — 不再有「再跑一轮」这个动作。要么给「问过」单独记一列(见下),要么分别处理剩下三类的绑定质量问题。 **Why** — 2026-09-05 那轮把最后 2,709 行**从没被问过**的候选一次跑完(`SKIPPED` 0,在预算内收尾)。此后剩余的 4,138 个候选**全部至少被问过一次**,构成约为:`NO_TITLES` ~2,489(dandanplay 索引里没有这个 subject)、`UNDECIDED` ~1,021(没有任何独立来源为绑定背书)、`DDP_REBIND` ~575(dandanplay 说这个 subject 属于另一个 anilist 条目)、`SCOPE_MISMATCH` ~42。再跑一轮 = 花 4,138 次共享额度把上一轮的答案重听一遍,而这三类每一类都要靠改绑定、不是靠再问一次。 **Context** — 2026-09-05 实测。★★ **`--resume` 跳过的是「写成功」不是「问过了」**:`episode_titles_at` 只在写标题的事务里打戳,所以答不出来的行永远留在候选列表头部。今天第一次启动就撞上这个——200 行只写 1 行、196 次请求,照这个走法 4,000 次预算的 68% 会花在昨天已经判过的行上。★ 修法是新加的 `--start-after-id N`(PR #155),**预算工具不是正确性工具**:跳过的行仍是候选、仍没打戳。断点 `141932` 是反推的——重建 09-04 那轮的候选集(6,910 行),它最后一次写入排在第 4,132 位,正好是 4,000 次预算耗尽处。★ 那一轮成果:WRITTEN 1,296 / NO_TITLES 969 / UNDECIDED 265 / DDP_REBIND 159 / SCOPE_MISMATCH 20,2,628 次请求;库里 +1,040 条新标题、+2,327 条中文名、+68 部番剧首次有标题、打戳 8,092 → 9,390。★★ **报告里的 `titles written` 数的是被 upsert 碰到的行不是新增行**(`UpsertEpisodeTitleSourced` 的 `DO UPDATE` 没有 WHERE),所以 15,441 与库里 +1,040 并不矛盾——同一个口径问题对 3.26.0 记的 17,646 同样成立。 **Depends on / blocked by** — 无。 ## 把「问过」和「写过」分开记,`--resume` 才名副其实 **What** — 给 `anime_cache` 加一列(比如 `episode_titles_asked_at`)在**每次真的走到网络之后**打戳,与只在写入事务里打的 `episode_titles_at` 并存;`--resume` 读前者,`--start-after-id` 就可以退休。 **Why** — 现在这两件事共用一个戳,而它只记录后者。后果是每一轮回填的头部都是上一轮答不出来的行:2026-09-05 那轮开跑时,5,436 个候选里 2,730 个属于这一类,占一天预算的 68%,而它们的判决(`NO_TITLES` / `UNDECIDED` / `DDP_REBIND`)不会隔夜改变。`--start-after-id` 用一个人工传入的 id 绕开了它,代价是这个 id 得靠反推、而且只在「上一轮是被预算截断的」这个前提下成立。 **Context** — 2026-09-05 加 `--start-after-id` 时记下。⚠️ 新列必须**只在真的发出了请求之后**打戳(`asked_upstream`),缓存命中不算——否则同一轮里共享 subject 的第二行会被误标为问过。另外要想清楚重问周期:`NO_TITLES` 有朝一日可能变成有(dandanplay 补录),所以这个戳应该是「多久之后可以再问一次」而不是「永不再问」,形状和 `ListReleasingEpisodeTitleCandidates` 的 `stale_after` 一样。 **Depends on / blocked by** — 无。 ## dandanplay 交叉链接补 bgm_id:**探针已跑完,结论是做 sweep** **What** — 实现搜索驱动的 sweep:每行一次 `SearchAnime`,对前 2 个候选取 `FetchEpisodesByDandanAnimeID`,用 `onlineDatabases` 的 AniList 交叉链接**等值**作精度闸门,subject 未被占用才写。(原 What 是「先跑探针决定形态」,探针已于 2026-09-04 跑完。) **Why** — id-map sweep 已经把它能答的 791 行全绑完了,剩下约 4,463 行 map 完全没有条目,dandanplay 是唯一够得着的来源。两个方案**证据结构完全相同**(搜索只负责召回,`onlineDatabases` 的 AniList 交叉链接**等值**才是精度闸门),差别只在覆盖面和成本:机会捕获零请求但只碰得到用户恰好播放的那一小撮;sweep 覆盖全部但约 8,900 次请求 ≈ 两天的日额度。没有数据就在这两者间选,是在拿论证代替测量。 **Context** — 探针是只读的,报告里带**命中位置直方图**——那个数就是 sweep 的每行请求成本(若命中全在第 1 位,候选上限可以降到 1,每行两次请求)。★ 已查清的两件事:**`description_cn_eligible` 不用改**(它要求 `bgm_id_map` 同意,而这条路只在 map 沉默处动手,所以这类绑定天然拿不到中文简介资格——爆炸半径最大的公开字段已被现有闸门挡住);**dandanplay 没有配额查询接口**(开放文档无此端点、`dev.dandanplay.com` 那页是 SPA 不渲染、swagger 404),所以额度只能靠人看控制台。★★ 搜索走的是**搜索组**(独立额度,长期闲置),只有详情腿和分集标题回填抢同一个组——排期时这两件事要错开。 **2026-09-04 探针结果(n=600,全目录每 7 行取 1,非头部样本)** | 分类 | 数量 | 占比 | |---|---|---| | BINDABLE | 41 | 6.8% | | SUBJECT_TAKEN | 3 | 0.5% | | NO_BGM_LINK | 36 | 6.0% | | ANILIST_MISMATCH | 37 | 6.2% | | NO_ANILIST_LINK | 25 | 4.2% | | **NO_SEARCH_HIT** | **455** | **75.8%** | | FETCH_FAIL | 3 | 0.5% | 绑定率 6.8%(95% CI 4.8%–8.9%),全量 4,465 行外推 **约 305 行可绑**,成本约 5,983 次请求。 **结论:做。** 成本落在两个不同的额度组上——约 4,465 次搜索走**搜索组**(该组常年闲置,月用量 363/120,000),约 1,518 次详情走**番剧详情组**(一天配额的 30%)。也就是一天跑完、约 305 个新绑定,而且是一次性的,这批不会快速再生。相比之下机会捕获对这批**几乎必然为零**:75.8% 的行 dandanplay 索引里根本没有,用户也不会去播这些冷门条目。 ★ **候选上限从 3 降到 2 零代价**:命中位置直方图是 `#1 → 76, #2 → 4, #3 → 0`,第 3 位一次都没命中过。降到 1 会丢 5% 的命中(4/80),不划算。 ★★ **75.8% 的 NO_SEARCH_HIT 是这条路的天花板,和匹配算法无关** —— dandanplay 索引里没有这些条目,再好的匹配器也够不到四分之三。所以「补 bgm_id」这件事的上界是 24.2%,不是 100%;剩下那部分只能靠扩充 `bgm_id_map` 或别的来源。 ⚠️ **第一次跑的探针结果(0 BINDABLE)是假的,别拿来引用。** 那次番剧详情组额度已耗尽,而当时的客户端把「200 + success:false」读成「这个条目没数据」,于是 103 次详情调用全是配额拒绝却被记成了 `NO_ANILIST_LINK`。坐实它的证据是 dandanplay 后台的月度计数只 +1。客户端已修(见下一条)。 **Depends on / blocked by** — 番剧详情组的当日额度;与上一条抢同一份预算(搜索腿不抢)。 --- ## 目录里够不着的那批条目的预告片 **What** — 给 `anime_cache` 里 `season` 或 `season_year` 为 NULL 的行补一条按 id 批量拉取的回填路径:新增一个 `Page(media: id_in: [...])` 的 AniList 查询常量 + 客户端方法 + 一个 river job,每页 50 个 id,按 `trailer_checked_at IS NULL` 取批。 **Why** — 迁移 0032 之后 trailer 只有两个写入口:`warm_season`(按 season+year 向 AniList 取整季)和详情页回源。前者按季度查,够不着没有季度的条目;后者要等有人打开那一页。2026-09-06 实测:全目录 18,048 行里 **4,500 行**没有季度(剧场版 / OVA / 特典),其中 **167 行**会出现在 `/api/anime/completed-gems` 的候选池里(该池共 1,610 行,占 10.4%)。也就是说 gems 列表大约每十张卡就有一张永远拿不到预告片,直到它自己的详情页被访问过一次。 **Pros** — 覆盖补齐到 100%;这条「按 id 批量补一个 AniList 字段」的能力是通用的,下一个要给存量行补的字段可以直接复用,不用再想一遍。 **Cons** — 新 job kind + 查询常量 + 取批 SQL + 配套测试约 250 行,而它在真实消费面(列表接口)上换来的增量只有 167 行。剩下的 4,333 行只能从详情页进,而详情页第一次访问就自己填了。 **Context** — 这条是 2026-09-06 `/plan-eng-review` 评审 PR #167 时的 D3 决策的另一半:当时选了「跑现成的 warm-all」而不是新建回填 job,理由就是上面这个 167 : 250 行的比值。⚠️ **warm-all 必须带 `?startYear=1940`**(`internal/admin/warm_all.go:47` 的默认值是 2014,而目录里最早的 `season_year` 是 1940,`season_year < 2014` 的有 7,202 行,其中 511 行落在 gems 池里——不带这个参数会把 gems 池的覆盖率从 89.6% 打到 58%)。触发条件:Nagare 那边反馈 Discover 里空白卡片明显,或者要把预告片用在别的以 gems 为数据源的位置。 **Depends on / blocked by** — 无。与 warm-all 互补而非互斥。 > **前提已变(2026-09-08,迁移 0033)** — 这条的 Cons 算的是「新 job kind + 查询常量 + 取批 SQL + 配套测试约 250 行」。其中的**查询常量和客户端方法现在已经存在**:`anilist.MediaRatingsQuery` 就是 `Page(media: id_in: [...])`,`anilist.Client.Ratings` 是它的调用方,`anilist.MaxRatingIDs` 是那个 50 的上限(AniList 超了是**静默截断**不是报错,所以上限在 `Ratings` 里挡,不能只靠调用方自觉)。剩下要写的只有取批 SQL 和 job 本身,而 `queue/ratings_refresh.go` 里那条 AniList sweep 的分批 + 「没返回的 id 也要盖戳」逻辑可以照抄。167 : 250 的比值应当重算。 > > ~~⚠️ 但**先确认 AniList 恢复了再动**:2026-09-07 起 `graphql.anilist.co` 对所有查询返回 403。~~ **2026-09-08 更正**:AniList 没有停摆,是我们的客户端没发 `Referer` 头,而它把这个拒绝说成了「API 已临时停用」。已修(`internal/anilist/client.go` 的 `requestReferer`)。这条不再有阻塞。 --- ## sitemap 的 lastmod 说的不是「内容什么时候变的」 **What** — 让 `anime_cache.updated_at` 只在内容真的发生变化时才推进(`ON CONFLICT` 的 `updated_at` 加一个 `IS DISTINCT FROM` 判断),或者给 `ListSitemapShard` 换一个内容级的 lastmod 来源。 **Why** — `ListSitemapShard` 自己的注释写着「updated_at, not now(): Google discards lastmod it can prove wrong, and "every URL changed this second, on every fetch" is provably wrong」。但 `UpsertAnimeCache` 的 `ON CONFLICT` 无条件 `updated_at = now()`,而五个写入方里有三个是流量驱动的(详情回源、search 命中、seasonal 冷启动)。于是注释想避免的那件事,正在以周为单位发生。 **Pros** — sitemap 的 lastmod 变成真信号,而 SEO 是这个项目的主线;顺带让「这行最后一次真的变了是什么时候」这个问题第一次有答案。 **Cons** — 改的是五个写入方共用的那条 upsert,爆炸半径远大于任何单个特性;而且 SEO 侧的收益无法在上线前验证,只能上线后看 GSC。 **Context** — 2026-09-06 实测 prod `anime_cache`:24 小时内 `updated_at` 被改过的有 **5,827 行(32%)**,7 天内 **17,692 行(98%)**,30 天内 **100%**。即全站 lastmod 每周整体翻新一遍,而内容并没有变。这条最初由 PR #167 评审的外部声音提出(担心 warm-all 会造成 lastmod 风暴),拿上面这组数字验证后结论反转:warm-all 的边际影响很小,因为基线本来就是全量翻新——问题不在 warm-all,在 upsert 语句本身。 **Depends on / blocked by** — 无。但改动应当单独走一个 PR,并且和一次 GSC 观察窗口配对。 --- ## 对外的 trailer 类型仍然是上游的解码结构体 **What** — 在 `internal/anime` 定义 `DetailTrailer{ID, Site}`,`assembleDetail` 转换一次,不再把 `anilist.Trailer` 直接当成公开 API 类型。 **Why** — `internal/anilist/types.go` 自己写着「field tags 跟 AniList GraphQL 字段名一致,不要改名,encoding/json 的 tag 就是契约」。同一个结构体现在同时背着两份契约:AniList 的解码格式,和我们对下游消费方的承诺。上游改名时,改 tag 会静默改变对外响应,不改 tag 会静默打断解码。 **Pros** — 两份契约解耦,各自独立演进;四行代码。 **Cons** — 与 `/search` 响应直接暴露 `anilist.PageInfo` 的现有做法不一致,要么两个一起改要么就是仓里有两套做法。 **Context** — 2026-09-06 查过下游消费方的实际代码后,这条的优先级被判定为**低**:消费方用自己的匿名结构体解码,而它的前端把 trailer id 直接拼进 `youtube-nocookie.com/embed/${id}`、**根本不读 site 字段**。所以 `site` 改名的实际影响为零;只有 `id` 改名会出事,而那种情况下我们自己的解码同样会静默失效——拆不拆类型都挡不住。同一轮还确认了 `trailer,omitempty` 不需要改:消费方是 Go 的 `encoding/json` 解到指针,「缺键」和「null」结果完全一样。 **Depends on / blocked by** — 无。真要做的话和 `anilist.PageInfo` 那处一起改,否则只是把不一致换个地方。 --- ## AniList 的评分人数已经入库,但还没有人读得到 **What** — 把 `anime_cache.anilist_score_votes` 接到详情响应上(`internal/anime/detail.go` 的 `BangumiVotes` 旁边加一个同形状的字段),然后在详情页 AniList 那一格里渲染,与 Bangumi 那格对称。 **Why** — 迁移 0033 把这个数存进了库,但**没有任何 SELECT 读它**,所以目前它是纯粹的写入。而这个不对称在页面上是肉眼可见的:`next-app/src/app/[lang]/anime/[id]/page.tsx` 里 Bangumi 那一格已经渲染 `{detail.bangumiVotes.toLocaleString()} {dict.detail.votes}`(第 816 行附近),AniList 那一格只有一个 `84 / 100` 和一根进度条,没有任何东西说明这个 84 背后是四十个人还是四万个人。存这一列的全部理由就是让这两个数能放在一起看。 **Pros** — 三处小改动(sqlc 的 SELECT 列、`detail.go` 的响应结构体、页面上一个已经存在的 `scoreVotes` 样式类),文案 `dict.detail.votes` 三个语言都已经有了。收益是详情页第一次能说清两个来源各自的样本量。 **Cons** — 改的是公开响应的形状,而这个响应有跨仓下游(`/Users/lawrence_li/nagare`)。加字段本身对它无害(Go 的 `encoding/json` 解到自己的匿名结构体,多出来的键会被忽略),但按仓库既有规矩,改响应形状要去那边看一眼再动。另外**在 AniList 恢复之前这一列全是 NULL**,上线了页面上也什么都不会显示——所以这条的自然时机是那条 sweep 真的跑过一轮之后。 **Context** — 2026-09-08 随迁移 0033 一起识别,当时刻意留在范围外:入库和呈现是两步,而第二步要碰公开契约。数据来源是 `stats.scoreDistribution` 各档 `amount` 之和(`anilist.Media.ScoreVotes`),不是 `popularity`——后者数的是把作品加进列表的人,大部分没打分。 **Depends on / blocked by** — 无。(本条原写「被 AniList 的 403 挡着」,那个 403 是我们自己少发 `Referer` 造成的,2026-09-08 已修。)实际前提只剩一个:等那条 sweep 真的跑过一轮、列里有数了再接页面,否则呈现一个全是 NULL 的列没有意义。 --- ## Bangumi 的票数列没有 CHECK **What** — 给 `anime_cache.bangumi_votes` 加一条 `NOT VALID` 的非负 CHECK,然后在确认存量行干净之后 `VALIDATE`。 **Why** — 迁移 0033 给 `anilist_score_votes` 加了 `anime_anilist_rating_pair`,理由是 sweep 会吞掉单行写入失败,一个被约束挡下的值在生产上唯一的症状是那一行悄悄不再刷新——所以得由列来拒绝。`bangumi_votes` 承受同样的写入方(现在多了 `UpdateBangumiRating`)却没有任何约束,纯粹因为它比 0033 早了 32 个迁移。 **Pros** — 两列的保护面拉齐;`NOT VALID` 加约束不锁表。 **Cons** — 要先确认存量行没有违例,而这需要跑一次全表查询并对结果负责;这件事属于愿意做 `VALIDATE` 的那个人,不属于只是新增一列的那个迁移。 **Context** — 2026-09-08 写迁移 0033 时明确留下的一条,迁移注释里也写了同样的话。 **Depends on / blocked by** — 无。 --- ## sweep 和用户请求共用同一个 AniList 额度,没有优先级 **What** — 让后台 sweep 从一个**低优先级**的额度里取令牌,或者至少在检测到用户侧请求排队时让路:给 `anilist.Client` 加一个 `Priority` 维度,或者给 sweep 一条独立的、总和仍在 30/min 之内的更慢通道。 **Why** — 额度是 30/min(`x-ratelimit-limit`,2026-09-08 实测),全站共享一个 token bucket。今天这不成问题,因为两条 sweep 都是串行发请求,任一时刻队列里最多一个后台请求,用户最坏多等一个 `minInterval`(2.1s)。**但这个结论完全依赖「串行」这个实现细节,而那个细节没有任何东西守着**——谁把批次改成并发拉取(一个很自然的「加速回填」想法),用户侧的排队就会从 2.1 秒变成整批的长度。 **Pros** — 后台工作再也不能挤占前台;也让「加速 sweep」这件事变成可以安全考虑的选项。 **Cons** — `golang.org/x/time/rate` 没有原生优先级,要么两个 limiter 手工分配额度(分错就是浪费或超额),要么在 `do()` 里加一层等待队列。而今天的实际收益是零,因为串行已经够用。 **Context** — 2026-09-08 修 `minInterval`(700ms → 2.1s)时识别。触发条件:任何把 sweep 改成并发的改动,或者额度被进一步下调。 2026-10-08:`anime_credits` sweep(`internal/queue/anime_credits.go`)是第一条会让路的 sweep——全部请求走 no-wait(令牌空闲才取,有人排队时 `Allow()` 直接拒绝),每次请求前隔 6 秒,忙就重试三次后结束本轮。facts / ratings 两条仍是排队模式;要统一的话照它改,不需要动 limiter。 **Depends on / blocked by** — 无。 --- ## 24h 读时刷新应该离开请求路径,改成 river sweep **What** — 新建一个每小时跑的 `detail_refresh` sweep(参照 `internal/queue/anime_facts.go` 的 500 行/小时节奏),按 `cached_at` 最旧优先重拉 AniList 详情;落地后把 `detail.go` `isStale` 里 `cached_at ≥ staleCacheTTL` 那一条去掉,只保留「从没拉过详情 / 角色 role 空 / 关联封面空」这类一次性修复条件。 **Why** — 2026-09-20 实测:按 `anime/detail: stale, re-fetching from AniList` 与详情 200 的计数比,**一半的详情读会在请求路径上内联打一次 AniList**(+600ms);目录里近一半的行 `cached_at` 超过 24h,爬虫全目录走一遍等于全目录刷新一遍。它是 30/min 额度的九成以上的消耗方(爬虫枚举不存在 id 的 404 不到一成)。「stale 重拉改非阻塞 + 404 负缓存」那个 PR 只是把它从队列里请出去,读路径仍然打 AniList,而且刷新变成尽力而为之后陈旧上界不再有保证。让刷新离开请求路径是 2026-08 eng-review(「SWR 不做,若做走 river job」)和下面「未缓存的详情页…」那条 Context 里「不要在请求路径上冷启动取数」两次指向的方向。 **Pros** — 详情读彻底不碰 AniList;刷新节奏可控(每小时 500 行,与 facts sweep 同一节奏);`anime_cache.updated_at` 不再被流量驱动翻新,sitemap lastmod 的基线随之变干净。 **Cons** — 多一个 sweep 抢同一个 30/min(每小时 10 次,与 facts/ratings 同量);当季新番的集数/状态变化到达会慢一点(`warm_season` 已覆盖当季,实际影响小);要决定「从来没人访问的行要不要刷」——不刷则要给 sweep 一个「最近被读过」的信号,刷则每一轮大半在刷没人看的行。 **Context** — 先看那个 PR 部署后 `stale, re-fetching` 与 `stale, budget busy, serving stale` 两个计数的比例,定 sweep 节奏。入口:`detail.go` `isStale` + `internal/queue` 新 worker + river 注册(`UniqueOpts.ByState` 必须含四态,见 `test/integration/queue_smoke_test.go`)。 **Depends on / blocked by** — 「stale 重拉改非阻塞 + 404 负缓存」PR 先落地;与「sweep 和用户请求共用同一个 AniList 额度」同源。 --- ## bingbot / Semrush 在反复爬一批现在 404 的 `/anime/{id}`,要查它们以前是不是真页 **What** — nginx 日志里详情页的 404 有相当一部分来自 bingbot 和 Semrush,同一批 id 被反复打,另有少量带 `google.com` referer 的真人点击。查这批 id 是不是曾经在 `anime_cache` / sitemap 分片里、后来被删掉或合并掉的:对照 GSC「未找到 (404)」报告、历史 sitemap 分片、以及删行/合并类 migration(同名番剧合并、Hentai 过滤、synonyms 清理都要排一遍)。 **Why** — 搜索引擎爬虫不会凭空猜 id,它们爬的是曾经见过的 URL。如果这批 id 曾经是我们的真页,现在 404 意味着**已索引页面在流失**;如果不是,那是外链污染,可以不管。两种结论的处置完全不同(301/410 vs 无视),所以值得一小时把真假判掉。 **Pros** — 判为真则能定位到具体那次删行,并决定 301 到合并后的 id 还是 410;顺带校验 sitemap 是否还在列已删 id。 **Cons** — 纯调查,要翻 GSC + 旧 sitemap + 数据库历史;可能结论是「外链垃圾」一场空。 **Context** — 2026-09-20 查 Sentry 5xx 时的旁证:把详情页 404 按 UA / referer 分桶即可看到。同一天落地的「404 负缓存」只让这些 404 不再消耗 AniList,不回答它们从哪来。 **Depends on / blocked by** — 无。 --- ## AniList 已删掉的条目我们还留着:补不全、每次缓存过期都再问一次,而且没人知道有多少 **What** — 把「AniList 不再有这个 id」存成一个事实:`anime_cache` 加一列(例如 `anilist_gone_at`),由已经掌握这个信息的地方写入——facts sweep 与 ratings sweep 批量查询时没被返回的 id,以及详情页重拉拿到 AniList 404 的已有行。写了之后 `isStale` 对这种行不再触发重拉;再决定页面怎么对外:照常保留、从 sitemap 去掉、`noindex`,或者能找到合并目标时 301 过去。 **Why** — 2026-09-23 对 #201 做线上测试时看到:库里一直补不全(`trailer_checked_at` 为空)的几行,全是 AniList 已经删掉的老番 Specials。每次进程内缓存过期后有人访问,它们就排队问一次 AniList,拿 404,记一条 Warn,返回旧行——这是一个永不收敛的重试。数据完整、只是后来被 AniList 删掉的行同样如此,只是走不排队的那条路。三个问题: - **没人知道有多少。** 两个 sweep 其实知道哪些 id 没被返回,但它们把这种行和「查过了」打成同一个戳(`MarkAnimeFactsChecked` / `MarkAnilistRatingChecked`,为的是别让它们永远排在批次最前),唯一留下的痕迹是每轮日志里的一个计数 `rowsAbsentUpstream`。 - **页面对外的说法是错的。** 详情页 JSON-LD 的 `sameAs` 固定带 `https://anilist.co/anime/{id}`(`animeJsonLd.ts`),对这些行它指向一个 AniList 已经不认的条目;sitemap 按 `anime_cache` 出,它们也在里面。 - **负缓存帮不上。** #201 的 `absent` 只在冷 id 分支写(DB 先行,行存在就读不到它),有意如此——所以对「我们有、AniList 没有」的行,每小时一次的重问不会停。 **Pros** — 重问归零;第一次能回答「目录里有多少条目在上游已经不存在」;SEO 处置有了依据,不用猜。 **Cons** — 多一列加一条 migration;两个 sweep 的「没返回」要和「这批请求失败」严格分开(后者现在整批不打戳,这个区分已经有了,但写新列时不能弄混);AniList 偶尔会把条目合并到另一个 id,只知道「没了」不知道「去哪了」,301 需要另找来源(AniList 不给合并目标),做不到就只能 `noindex` 或保留。 **Context** — 线上测试见 CHANGELOG「详情页的 AniList 额度」条目的「部署后」段。入口:`internal/queue/anime_facts.go` `applyBatch` 的第二个循环、`ratings_refresh.go` 同位置、`internal/anime/detail.go` stale 分支拿到 `NOT_FOUND` 的地方、`isStale`。与「bingbot / Semrush 在反复爬一批现在 404 的 `/anime/{id}`」是镜像问题:那条是「我们没有、爬虫以为有」,这条是「我们有、上游已经没有」。 **Depends on / blocked by** — 无。 --- ## 未缓存的详情页在 AniList 够不到时返 502,而且没有可降级的数据 **What** — 给「行不存在 + AniList 取不到」这条路径一个比 502 更好的答案:要么 404(我们确实没有这个条目),要么排队补齐后返回 202/占位,而不是把上游失败原样透出。 **Why** — `detail.go` 已经有优雅降级,但它只在**行存在**时生效(「re-fetch 失败就返回已读到的陈旧行」)。行不存在时没有任何可返回的东西,直接 502。2026-09-08 实测两例(`88764`、`108289`,两者都不在 `anime_cache` 里),成因是 `anilist: rate limit wait: rate: Wait(n=1) would exceed context deadline` —— 限速器排队超过了请求的 5 秒预算(`queryTimeout`)。当时代码里写着 15 秒的 `refetchTimeout`,但它套在已带 5 秒 deadline 的 ctx 上,子 ctx 活不过父 ctx,从未生效(Sentry `duration_ms: 5001`);2026-09-23 已删。 **Pros** — `/anime/*` 是 SEO 主力面,502 是这个仓库已经为之修过一轮的东西(见「详情页 500 爬虫×限流」)。 **Cons** — 「我们没有这个条目」和「我们暂时取不到」是两件事,用同一个状态码回答任何一件都会误导某一方;要分开就要引入一个新的响应形状。 **Context** — 2026-09-08。`minInterval` 从 700ms 提到 2.1s(限流实际额度 30/min)之后这个窗口变宽了 3 倍:5 秒预算在 700ms 时容得下约 7 个排队请求,2.1s 时只有 2 个。**2026-09-23 进展**:占队列的大头(stale 读时重拉,≈340/h)已改成不排队的 `DetailNoWait`,冷 id 队列里只剩冷 id 自己和 sweep 批次。**根因不是这个常量,是 30/min 对一个会在用户请求上做冷启动取数的站点本来就很紧** —— 真正的解法在「后台工作应有独立额度」那条,以及不要在请求路径上冷启动取数。实测频率很低(18 分钟 2 个)。 **Depends on / blocked by** — 与「sweep 和用户请求共用同一个 AniList 额度」那条同源。 --- ## 部署杀出的孤儿作业会压制 sweep 一小时(三个 sweep 都有) **What** — 让周期 sweep 不再依赖 `UniqueOpts` 去防重:去掉唯一性,改为靠队列的 `MaxWorkers: 1` 加候选查询的读取时间戳。或者调低 river 的 `RescueStuckJobsAfter`(默认 1 小时),但要先确认没有作业的正常时长会撞上这个窗口。 **Why** — `running` 行不代表有 pass 在跑,部署杀进程不改它,rescuer 一小时后才回收。这一小时里 `RunOnStart` 的入队被自己的尸体压制,而且**没有症状**(不产出 = 追平了,两者同形)。2026-09-08 实测:一次部署后 `anilist_ratings` / `bangumi_ratings` / `hant_backfill` 同时卡 59 分钟。 ⚠️ **不要用「把 running 从 ByState 拿掉」来修**。已经在生产上试过并回滚:river 的 `UniqueOpts.validate` 强制要求 available/pending/running/scheduled 四个都在,少一个会让 `PeriodicJobEnqueuer` 对该 kind 整个失败——只留一行 ERROR 日志,服务照常跑,sweep 再也不入队,一小时的压制变成永久。守卫见 `TestRatingsUniqueStatesMatchRiver`。 **Pros** — 三个 sweep 一起受益;ratings 是每小时跑的,一小时的窗口对它最不能忍。而且去掉唯一性对 ratings 本来就安全:候选资格是读取时间戳,紧接着的第二次 pass 取的是下一批,不是重复劳动。 **Cons** — 去掉唯一性后 river 行会累积(每小时一条,24 条/天,completed 会被 river 自己清理);如果 worker 卡住,作业会堆积而不是被合并。`hant_backfill` 是**整表**pass,对它去掉唯一性要更谨慎——两次并发全表 pass 是真的重复劳动,它依赖的是 `MaxWorkers: 1`。 **Context** — 2026-09-08。ratings 每小时跑所以最疼;`hant_backfill` 是季度作业,一小时对它无所谓。 **Depends on / blocked by** — 无。 ## 角色中文名全库 0 行:V2 写的字段上游没有,写了也会被详情重拉整表删掉 **What** — 让 `anime_characters` 的富化列(`name_cn` / `voice_actor_cn`)真的能落地并且活过下一次详情重拉;或者承认 Bangumi 那条路给不了,删掉 V2 里这段死代码。 **Why** — 两个各自独立就足够致命的原因叠在一起: 1. **上游没有这个字段。** V2 走的是 `GET /v0/subjects/{id}/characters`,客户端解码 `Character.NameCN` / `Actor.NameCN`(`internal/bangumi/client.go:217-229`),但这个端点返回的字符对象只有 `id / name / type / images / relation / summary / actors`,演员对象只有 `id / name / type / images / career / locked / short_summary`——**两层都没有 `name_cn`**(2026-09-15 对 400602 实测)。所以 `bangumi_v2.go:336-345` 算出来的 `nameCN` 和 `voiceActorCN` 永远是 nil,`UpdateAnimeCharacterCN` 即使命中也是写两个 NULL。中文名在 Bangumi 上要 `GET /v0/characters/{id}` 逐个拉(在 `infobox` 里),一部番几十次请求。 2. **写了也活不过一天。** 详情重拉(`detail.go:762-766`)对 `anime_characters` 是 `DeleteAnimeCharacters` + 逐行 `InsertAnimeCharacter`,插入时 `name_cn = NULL`(`normalize.go:203` 自己注释「V2 之后填」)。任何 V2 写进去的东西在下一次 24h TTL 到期时整表消失,而 V2 是单向棘轮不重跑。生产库 82,274 行 characters 里 `voice_actor_image_url` 带 `bgm.tv` 的是 **0**——这才是「UPDATE 从没留下过痕迹」的直接证据(V2 命中时会把它覆成 Bangumi 图)。 顺带一个被这两条掩盖的 bug:`bangumi_v2.go:349-352` 把**角色**的 `c.Images.Medium` 写进 **`voice_actor_image_url`**,声优的图在 `c.Actors[0].Images`。现在没人看见,是因为它从来没成功写过。 **Context** — 2026-09-15 元数据计划 1.4 的调查结论,封顶半天,实际 20 分钟。不在阶段 1 修,因为正确的修法依赖阶段 2.2:`anime_characters` 存 `character_id` 之后,详情重拉才能改成按 `(anime_id, character_id)` upsert 而不是删表重建,富化列才有地方活下来;而中文名本身要么接 `/v0/characters/{id}`(按角色数计费,只值得对主角做),要么放弃。在那之前 V2 的这一段是死代码,删掉比留着更诚实。 **2026-10-08 更新** — 第 2 条已解除(migration 0042):详情重拉和 `anime_credits` sweep 都按 `(anime_id, character_id)` upsert,`UpsertAnimeCharacter` 不写 `name_cn`,别处写进去的值能活过重拉;`voice_actor_cn` 只在 `voice_actor_id` 不变时保留。没有 character id 的旧行仍会在下一次重拉时删除。第 1 条(上游没有这个字段)仍在。 **Depends on / blocked by** — 阶段 2.2(角色 id)。与 `## bangumi_version 是单向棘轮` 同属一根。 --- ## 补全 sweep 跑完后重跑 bgmnames,并把它做成每周自动任务 **What** — 等 `anime_credits` sweep(角色、职员)和 `profiles` sweep(人物、角色资料)的首轮回填跑完,用当时最新的 Bangumi Archive 数据包(,每周二 21:03 UTC 发布)重跑一次 `cmd/bgmnames`:先不带 `--apply` 跑一遍,看配对数、冲突和将要改动的行,确认后再加 `--apply` 写入。长期把它从手动工具改成定时任务:新数据包发布后自动下载、校验、应用,不再依赖人记得每周去跑。 **Why** — 角色和人物的中文名(`bgm_character_map` / `bgm_person_map` 的 `name_cn`)以及角色简介(`bgm_character_map.summary`)只在 bgmnames 运行时写入,而它目前只在 #217 部署时跑过一次。之后两条 sweep 每小时都在往库里加角色和人物,这些新行在下一次运行之前都没有中文名,角色标签页、角色页、人物页上只显示 AniList 的名字。bgmnames 匹配的是库里当前的行,所以即使数据包没换,重跑也能补上这部分;一次写入是一个事务,跑完两张表正好等于这一轮的结果,重复运行是安全的。 **Pros** — 中文名和角色简介重新和库里的角色、人物对齐;做成定时任务后每周的新数据包自动生效。 **Cons** — 每次要下载约 440 MB 的数据包,解压其中 4 个 jsonlines 文件。做成定时任务要把工具放进镜像(现在不在镜像里),并处理下载失败、校验和与临时文件的磁盘占用。只有已绑定 Bangumi 的番,它的角色和人物才配得上,没绑定的不受益。 **Context** — 2026-10-11 核对时,首轮回填预计还要 3–4 天:角色约 30 小时,人物资料约 1 天,角色资料约 3 天,职员约 4 天。判断跑完的办法:`ListAnimeCastCandidates` / `ListAnimeStaffCandidates` 里不再有从未扫过(`cast_checked_at` / `staff_checked_at` 为空)的行,`ListPeopleCandidates` / `ListCharacterCandidates` 不再返回缺资料的人物和角色。下一个数据包是 2026-10-13,届时用最新的那份。 **Depends on / blocked by** — 两条 sweep 的首轮回填完成。 ## 预压范围第二期:详情页的预告片缩略图 **What** — 把详情页的预告片缩略图(i.ytimg.com,页面实际请求的宽度)加进 `image_variant_warm` 的夜间预压清单,并在 nginx 的 `$next_image_zone` 里把这些尺寸算作页面尺寸。 **Why** — 第一期上线后,详情页首屏只剩预告片缩略图在第一次打开时要现压,实测要好几秒。 **Pros** — 有预告片的番,详情页首屏的图全部从缓存出。 **Cons** — 源图在 YouTube,不在我们的原图镜像里:压图服务要直接回源 YouTube,`image_manager` 也不记录这类图,所以「只压镜像里已有原图的」这条规则管不到它,YouTube 回源的失败和限流要单独处理。分区规则现在只认 AniList 地址,要为 YouTube 地址另写一条。 **Context** — 第一期只覆盖 AniList 图(详情页、季度页):见 `nginx/default.p9.conf` 的 `$next_image_zone`、`go-api/internal/queue/image_variant_warm.go`、`next-app/src/lib/images/imageVariantShapes.test.tsx`。预告片缩略图由 `next.config.ts` 里 i.ytimg.com 的 remotePatterns 放行。 **Depends on / blocked by** — 第一期上线后,用 `/_next/image` 访问日志行尾的 `cache=… zone=…` 字段确认预告片缩略图的请求占比和命中率,值得再做。 ## 预压范围第二期:2 倍屏手机用的 828 宽横幅 **What** — 把 828 宽的横幅加进夜间预压清单(`image_variant_warm` 的清单和对应测试)。分区规则已经把 828 算作页面尺寸,只是没有预压。 **Why** — 宽 390 点上下的 2 倍屏手机打开老番时,横幅要的是 828 宽,第一次打开仍要现压;桌面、2 倍屏 Mac 用 1920,3 倍屏 iPhone 用 1200,都已预压。 **Pros** — 只多一个宽度,每部有横幅的番多压一张。 **Cons** — 不知道真实访客里有多少会请求 828,可能是为很少的人多压一批。 **Context** — `next.config.ts` 的 `deviceSizes` 是 640/828/1080/1200/1920,横幅是 `sizes="100vw"`,浏览器按屏宽和倍率挑一个。 **Depends on / blocked by** — 第一期上线后,用访问日志统计横幅各宽度的请求占比;828 占比明显再做。 ## 多设备会话上线满 14 天后,删掉 users 上的旧会话列和过渡代码 **What** — 删 `users.refresh_token` / `previous_refresh_token` / `refresh_rotated_at` 三列,以及只为它们存在的过渡代码:旧格式 refresh token(不带 `sid`)的认领事务、认领后的宽限与重用判断、旧式登出分支、`user_sessions.legacy_hash` 列、`ClearRefreshTokenIfMatches` 查询、「退出其他设备」事务里顺带清旧 token 的 `ClearLegacyRefreshTokens` 查询、`internal/db/session_columns_test.go` 扫描测试,以及 `test/integration/user_session_columns_test.go` 里剩下的旧列测试。同一批还有对不带 `sid` 的访问令牌的放行:go-api `jwtx/session.go` 的 `checkSession` 里 `SessionID == uuid.Nil` 那一支、next-app `proxy.ts` 的 `decoded?.sid` 判断、ws-server `sessionLive.js` 的 `!user.sid`——这些只为会话检查上线前签发的访问令牌存在,那批令牌 15 分钟内就过期了;之后再放行,等于让将来某处漏签 `sid` 的令牌悄悄绕过「退出设备」。 **Why** — 多设备会话上线前签发的 refresh token 有效期是 14 天,过期后这些代码和列不会再被任何请求走到;留着的话,读代码的人要同时理解两套会话机制,`users` 表里也一直留着旧的 token 原文。 **Pros** — 会话逻辑只剩 `user_sessions` 一套;删掉一个文本扫描测试和一组只守旧列的集成测试。 **Cons** — 一次 drop column 迁移;之后再回滚到多设备会话之前的版本,所有人都要重新登录(上线时已经接受)。 **Context** — 上线前签发的旧 token 不带 `sid`,第一次刷新时会被认领成一条会话,同时清空 `users` 上的三列。 **Depends on / blocked by** — 多设备会话部署满 14 天,留几天余量后再做。 ## 定时清理过期的会话行 **What** — 加一个定时任务,删除 `user_sessions` 里 `expires_at` 已过的行。 **Why** — 过期行目前只在该用户下次登录时被清掉;再也不回来的用户,过期行会一直留在表里。 **Pros** — 表里只剩有效会话。 **Cons** — 以现在的规模不值得:每个用户最多 20 行,而且除了清理本身没有查询读这些行。 **Context** — 登录时的清理在多设备会话的会话代码里(每人保留最近使用的 20 条并删掉已过期的)。 **Depends on / blocked by** — 用户量大幅增长、`user_sessions` 行数明显变多时再做。 ## 会话失效时告诉用户为什么被登出 **What** — 一台设备的会话因为 30 天没用而过期、因 token 重用被吊销、同一账号超过 20 台设备被挤掉、被本人在另一台设备的「登录设备」里退出、或者改了密码时,下次打开页面给用户一句说明(例如「登录已过期,请重新登录」),而不是悄悄变成未登录。 **Why** — 用户被合理登出时,现在只会看到自己突然变成未登录,很容易以为又是「被顶号」。 **Pros** — 被登出的人知道原因,少一类「又被踢了」的误会。 **Cons** — 是界面改动,要先出画布;后端要把失效原因带给前端(目前 refresh 失败统一返回 401 INVALID_TOKEN)。 **Context** — 多设备会话上线后,go-api 每次刷新都会记一行带结果类型的会话日志(rotated / grace / reuse_revoked / not_found / legacy_unmatched 等),可以据此统计这类登出到底有多常见。 **Depends on / blocked by** — 多设备会话上线;会话日志显示这类结果不罕见时再做;画布通过。