Ctrl K
ComunidadGuías…

TODOS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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`,
  且它绕过正常渲染 —— 得自带 `<html>`、字体和全局 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/<id>/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/<season>/<year>` —— 原本唯一的正例在 `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` + `<input webkitdirectory>`),直接搬。
- **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 数据包(<https://github.com/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** — 多设备会话上线;会话日志显示这类结果不罕见时再做;画布通过。