CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# AnimeGo 开发日志
---
## [未发布]
### e2e:详情页「下一集」那条用例第一次超时后,重试一定失败
`detail-facts.spec.ts` 在模块加载时用 `Date.now()` 算出下一集时间,写进测试库。重试会起一个新 worker,重新算出一个晚几秒的时间写进去,但详情页还缓存着第一次的时间。于是只要第一次失败,不管原因是什么,重试都会因为两个时间差十几秒而失败(#269 的 CI 第 1 片就是这样)。`seasonal-browse.spec.ts` 用的是本小时的整点,重试一旦跨过整点也会出同样的问题。
改动:
- globalSetup 在最开始把本次运行的开始时间写进 `E2E_RUN_STARTED_AT`,所有 worker 继承这个值;`fixtures/runClock.ts` 负责读取,没有 globalSetup 时退回 worker 自己的加载时间。
- `detail-facts` 的下一集时间改为从运行开始时间算起;`seasonal-browse` 取运行开始那一小时的整点。
- `detail-facts` 第一次失败的直接原因是:客户端跳转到一个 dev 服务器还没渲染过的详情页,在 CI 上超过了 5 秒。这两步跳转的等待放宽到 15 秒。
测试:用临时 spec 实测,3 条用例故意让第一次失败,5 个 worker(其中 3 个是重试)读到的开始时间完全一样;e2e 类型检查的错误数没有增加。
### 磁力资源:字幕组补全,字幕组条可以用鼠标滑动
磁力面板里有些番的字幕组明显不全。Re:0 第四季在 Mikan 上有 11 个字幕组,列表里少了 ANi、黒ネズミたち、百冬练习组、百冬练习组&LoliHouse、绿茶字幕组 5 个,Mikan 上的 308 条资源只出现了 128 条。原因是 Mikan 把这部番拆成「丧失篇」「夺还篇」两页,一部番对应多页时以前一页都不用,退回按标题搜索,只能搜到一小部分。另外,Nyaa 上按欧美压制组惯例命名的资源把组名写在标题末尾(「…H.264-VARYG」),以前认不出,全归在「未署名」里。字幕组一多,横条就放不下,用鼠标却没法往右翻。
改动:
- **一部番拆成多页**:bangumi-data 里一个 AniList id 对应几个 Mikan 页面时,几页的资源全部读进来(每次最多 4 页),不再按标题搜索 Mikan。映射表里目前有 7 部这样的番:Re:0 第四季、航海王和埃鲁巴夫篇、进击的巨人最终季和 Part.2 等。用真实请求核对:Re:0 第四季从 185 条、12 个字幕组变成 365 条、17 个字幕组,缺的 5 个都回来了。每周自动更新映射表的 PR 正文也改了,写明这些番会读取全部页面,审核时要连带检查。
- **标题末尾的组名**:标题开头没有方括号组名时,去掉末尾括号里的备注和扩展名,从最后一个词读出「-组名」,要求前面紧跟编码、音轨、声道、片源或语言标记(`x264-Tsundere-Raws`、`H 264-VARYG`、`.WEBRiP.x265-KAF`、`FLAC 2.0-StaFer`)。`WEB-DL`、`Blu-ray`、集数前的横杠、带连字符的标题词都不会被当成组名。用约 340 条没有方括号的 Nyaa 真实标题核对过,读出来的都是真实发布组。
- **字幕组条**:接上首页「今日更新」的横向滑轨。下面有滑动块,可以拖滑块、点轨道,键盘方向键和 Home/End 也能用;鼠标可以直接拖着横条走,甩一下会继续滑,停下时总是一格对齐左边。拖完松手不会选中手下那一格,单击照常选中。全部放得下时不显示滑动块。手机上照旧用手指滑,滑动块加高到 44px。读屏能读出滑动块的位置,如「第 1–5 个,共 24 个」。
- **首页滑动块,手机上拖一下就松**:手指按住「今日更新」或「按色调逛」的滑块拖动时,第一次移动后就会松开。原因是按下时浏览器把指针交给了滑块里的小元素,滑动块接管时,那个元素丢失指针的事件冒泡上来,被当成手指抬起。现在只有滑动块自己丢失指针才算松手,三处都修好了。
测试:
- Go:映射表查找(一对多返回全部页面,返回的切片调用方可以随便改)、内嵌映射表里每个一对多 id 都返回全部页面、按番搜索时每页各读一次且不再按标题搜 Mikan、一页失败只丢那一页、最多 4 页、缓存 key 与页面顺序无关;接口按映射表解析(一对多 + 自己的 bgm id 也返回全部页面);生成器报告的新措辞;`ParseFansub` 的末尾组名(8 种真实写法)和 6 种不该认成组名的结尾。`go vet` 干净;需要 Postgres 容器的包交给 CI。
- 前端:字幕组条的 DOM 测试,覆盖横条溢出才出滑动块、拖滑块、鼠标拖动后不选中、单击选中、手指拖滑块全程不松、键盘逐格和到两端。撤掉共享 hook 的修复后,手指那条测试会失败。`bun test` 3964 条全绿,`tsc --noEmit`、eslint 干净。
- 沙箱 e2e(`magnet-panel.spec.ts`,新增 24 个字幕组的夹具,标题和磁力都是假的):桌面拖滑块后横条停在某一格;鼠标拖横条不改变选中组,单击照常选中;手机上滑块不小于 44px,手指拖滑块能把横条带到最后。本地对着开发服务器各跑 4 次全过。
### 排行榜:历代 Top 100 和年度 Top 100(照 AniList 的 Top 100)
首页「完整年榜」以前链到 `/year/{年}`:那一年的全部番按人气排、一页一页翻,和首页按评分排的年度榜对不上。现在有了真正的排行榜:`/top` 是历代前 100,`/top/{年}` 是某一年的前 100。
改动:
- **口径**:非成人、有评分、AniList 上至少 1000 人在追(人数太少时平均分说明不了什么)、不是音乐 MV;按评分排,同分看人气,再同看 id;名次连续编号,最多 100 名。年份和年份页、类型页用的是同一个发行年(季度的年份,没有季度的用开播日期的年份)。
- **新接口**:`GET /api/anime/top?year=&format=&season=&genre=` 返回最多 100 条,每条就是浏览接口的条目再加 `rank`;`GET /api/anime/top/years` 返回有排名的年份和条数,从新到旧。参数不合法返回 400。两个接口都有 10 分钟的进程内缓存,并发的同一请求只查一次库。旧的 `/api/anime/yearly-top` 不变(它有外部的接口读者)。
- **页面**:40px 大标题「历代 Top 100」/「2025 年度 Top 100」;下面一条切换条——历代、近 13 年里有排名的年份、「更早」(菜单里是更早的有排名的年份),当前格下面垫一块滑块;再下面是格式(全部 / TV / 剧场版 / ONA / OVA / 特别篇)、类型下拉(单选),年度页还有季度(全年 / 冬 / 春 / 夏 / 秋);右边三种视图:封面网格(每行 5 部,封面左上角压一个本番色的名次圆标)、图表卡(右上角名次和评分)、列表(卡片左边一列大号名次)。筛选后在筛选结果里重新取前 100、重新编号。
- 每个筛选、切换条的每一格都是真链接(`?format=movie&genre=action&season=fall`);点下去页头和切换条立刻换成新榜单,内容区显示对应视图的骨架,新页面到了页头不再重新淡入。视图选择写在地址里(和季度页一样),刷新、分享都保持。
- 整榜(最多 100 部)都在服务端 HTML 里、按名次排好、每部都是真链接;视觉上先显示 20 部(手机网格 21 部),「显示更多 · 还有 N 部」或亲手滚到底附近再显示下一批,满 100 不再出现。
- **手机**:标题旁是视图切换(封面网格 / 列表),切换条横向滑动并把当前年份滚进视野,筛选一行横向滑动;网格 3 列(圆标 30px),列表是「名次 · 封面 · 两行标题 · 季度和格式 · 评分」。
- **SEO**:每个榜单的 canonical 指向自己(参数顺序固定,不含视图);带筛选的页面 noindex、follow;站点地图列出 `/top` 和每个有排名的年份;页面带 schema.org `ItemList`(名次、番剧地址、名称)。没有排名的年份是 404,不是空榜。
- **旧地址**:`/year/{年}`(包括任何 `?page=N`)永久跳转(308)到 `/top/{年}`;没有排名的年份直接 404。详情页的面包屑里,没有季度的番挂在它那一年的排行榜下面(只对排行榜会计入的番这样做,保证那一页存在)。
- **首页年度榜**改成同一口径,而且和 `/top/{年}` 用同一个请求、同一份缓存,前 10 名保证和年度页的前 10 名一模一样;年份按北京时间算,和切换条最新的一格一致。
- 导航栏「分类」和「关于」之间加「排行」(英文 Top 100,繁体「排行」),桌面和手机菜单都有;页脚的「高分榜」指向 `/top`。
- 简体、繁体、英文三语。读屏:每部番的标题前读出「第 N 名」,名次圆标和数字列不重复朗读。
- 季度页的封面卡、图表卡、列表行多了一个可选的「名次」参数,供排行榜复用;季度页和类型页本身不变。
测试:
- Go:参数解析单测(含原型属性名、季度没有年份、年份越界)、handler 单测(名次连续、缓存命中不再查库、并发合并成一次、调用方断开不回 500、panic 回 500);真 Postgres 集成测试覆盖排序、成人 / Hentai / 无分 / 0 分 / 人气不足 / MV 六种排除、没有格式的番照样参与、100 条上限、发行年表达式、三个筛选单独和组合后重新编号、同分先比人气再比 id、年份接口,以及新查询每次都按自己的参数规划(不走 generic plan)。
- 前端:路径、参数解析、批次、名次、年份条、元数据(canonical / noindex)、JSON-LD 的单测;页面服务端 HTML 的渲染测试(一个 h1、100 个按名次排的番剧链接和「第 N 名」、圆标、20 部之后隐藏、切换条链接和当前格、筛选链接、三种视图、空结果);面包屑、站点地图、导航的现有测试跟着更新。
- 沙箱 e2e `top-100.spec.ts`:自带一批 2019 年和 1955 年的番(含人气 999、MV、成人、无分、0 分各一部,都不能出现),覆盖排序和名次、显示更多、三种筛选、页头不重播淡入、三种视图和地址、切换视图后离开再返回仍保留已展开的批次、更早菜单、历代榜、旧地址 308 和 404、手机布局和抽屉菜单;`nav.spec.ts` 加上「排行」。CI 的预热列表加上年度页。
### 磁力资源:改成右侧抽屉 / 手机底部面板,并修好四个数据问题
旧的磁力弹窗是 1135 行行内样式写死的三栏:手机上左栏字幕组和右栏封面占掉大半宽度,资源列表只剩一条缝,复制和打开按钮约 30×24;桌面上是苹果蓝加 emoji 按钮、一张提示卡、一个只会重排不会筛选的集数条。数据上也有四处错:续作关键词搜索混进别的番(Re:0 第四季的结果里有《一念永恒 第4季》、转生史莱姆第四季、我的英雄学院等)、animes.garden 的大小多乘了 1024(单集显示几百 GB)、动漫花园大量「0 KB」、Mikan 的日期没有时区导致它独有的资源全排在最后。
改动(前端):
- **方案 A**:桌面(≥768px)从右侧滑出 620 宽、满高的抽屉,背后是模糊遮罩;手机是从底部升起的面板,顶部留 34px,带抓手条。
- **字幕组条**:「全部」加各字幕组,按条数从多到少,定宽格子可以横向滑动,当前格下面是分类页那种滑块;条数随简繁筛选变化。
- **筛选只剩简繁**:全部字幕 / 简体 / 繁体 / 简繁。简体包括简繁双语的资源,繁体同理,简繁只列双语。没有按集筛选,也没有画质筛选。
- **每条资源**:字幕组(本番色)、集数标签(「第 N 集」或「合集」,认不出就不显示)、日期;完整标题(最多三行);分辨率、简繁及内嵌 / 内封、编码、WEB / BDRip 标签;大小和来源;「复制磁力」主按钮(复制后两秒内变成绿色「已复制」)和「用下载工具打开」。手机上两个按钮另起一行,高 44px。
- **搜索**:输入框加标题语种按钮(中文 / Romaji / English / 日本語,只列这部番有的)。默认哪个都不选,照旧按这部番的全部标题一起搜;点某个语种或手动输入就按那个词搜,清空输入框再搜就回到全部标题。搜索中显示正在查的几个来源和骨架行,结果到了整体换上;没有「搜索中…」那行字,也没有说明文字和 emoji。
- **累计集号**:打开面板时同时取这部番的集数偏移,字幕组按全系列累计编的号换算成本季集数(芙莉莲第二季的 [38] 显示为第 10 集),本季装不下又换算不了的号不显示集数;本季集数还不知道时,超过偏移的号可能是本季自己的也可能是累计的,同样不显示。「S2 - 05」「Season 3 - 05」这类写法认作第 5 集,不会被当成区间显示「合集」。
- **本番色**:强调色取详情页正在用的色相,和季度页、首页卡片用同一套色阶,不再是苹果蓝。
- 弹窗规范:挂到 body 上,打开时焦点进面板、Tab 只在面板里循环、Esc 和点遮罩关闭、关闭后焦点回到打开它的按钮(从「播放」说明里打开的也回到「播放」;Safari 下同样成立);页面滚动锁走 `lib/scrollLock`。读屏只有一个播报区(搜索中、条数、没找到、失败、已复制 / 复制失败),每条的按钮都带上这条的标题作描述。复制被浏览器拒绝时按钮显示「复制失败」。
- 热门番一次能返回上千条,列表先画 40 条,读者亲手滚到底部附近(滚轮、手指、翻页键,或键盘焦点到了最后几条)再加 40 条;浏览器自己挪动列表不算,所以 WebKit 不会一口气连着加载。列表末尾还有「显示更多 · 还有 N 条」按钮,读屏或拖滚动条的读者也能看到后面的资源。
- 「减少动态效果」打开时不做滑入、行入场和骨架扫光。
- 简体、繁体、英文三语,客户端和服务端两套词典都加了。旧的 `TorrentModal.tsx`、`torrentModalLogic.ts` 删除,标题解析、筛选、集号换算放进 `lib/magnet` 的纯函数模块。
改动(后端,`go-api/internal/torrents`):
- **续作误配**:按关键词搜来的资源要和这部番的标题对得上才保留(中文、日文按相邻两字、拉丁文按词算覆盖率,先去掉「第四季」「4th Season」这类季号;标题包括繁体标题),按番剧 id 拉的 Mikan 订阅和 AnimeTosho 资源照旧全部保留;手动关键词搜索不过滤。搜索用的关键词仍是原来那四个,不多发请求。用线上返回结果的标题做测试夹具:Re:0 第四季的 317 条里有 189 条是别的番(我的英雄学院 64 条、转生史莱姆 28 条、文豪野犬 19 条、GM-Team 五部国漫的「第4季」55 条等,全部来自动漫花园的关键词搜索),过滤后剩下 Re:0 本身的 128 条,一条不少;芙莉莲第二季的 312 条(简体、繁体「葬送的芙莉蓮」、英文标题、粤语配音、合集)一条不删。
- **animes.garden 的大小**:上游给的是字节,以前当 KB 格式化。用同一个种子(按 infohash)在 garden 和 Nyaa 上的大小核对过。
- **大小统一成二进制单位**,和 Nyaa 一致,同一个文件在列表里不会出现两个数。
- **动漫花园「0 KB」**:拿不到大小时不再给「0 KB」,前端不显示大小。
- **日期统一成 RFC 3339(UTC)**:各来源原来 RFC 2822 和 ISO 混用;Mikan 的时间没有时区,按北京时间解析,它独有的资源不再全排在最后。
- **种子缓存**:设了 500 条上限却只能存约 8 条(ristretto 每条另算 56 字节内部开销),现在真能存 500 条。
测试:
- 前端单测:标题解析(集号、合集、区间、日期、半集、简繁、内嵌内封、分辨率、编码、片源,用真实标题)、字幕组条计数与排序、简繁筛选、累计集号换算、日期与大小格式、请求与集数偏移的读取、本番色变量;`bun test` 全绿(3909 条),`tsc --noEmit`、eslint 与 lint ratchet 干净。
- Go 单测:缓存写入 100 条都能读回(旧配置只剩 8 条);garden 大小按字节换算(用同一个种子在 Nyaa 上的大小核对的三组数值);二进制大小格式;动漫花园的占位长度(实测 500 条全是 1)不显示大小;每个来源的日期格式、Mikan 按 UTC+8(用同一个种子在 Mikan 和动漫花园上的时间核对的三组)、解析失败为空、Mikan 独有资源按日期排序;相关性过滤在两个夹具上的条数、混进 Re:0 的 4th Season 被去掉、按 id 拉的资源和手动搜索不过滤、繁体标题参与匹配而搜索词仍是原来四个。`go test ./...`(4545 条,含子测试)和 `go vet ./...` 全绿,`sqlc generate` 后无差异。
- 沙箱 e2e(`magnet-panel.spec.ts`,拦截两个接口、夹具里是假磁力链接):桌面抽屉位置和尺寸、字幕组条的条数与筛选、简繁筛选与条数联动、[38] 显示为第 10 集、复制后显示「已复制」并写入剪贴板、Tab 不出面板、Esc 关闭后焦点回到按钮、点遮罩关闭、100 条只先画 40 条且不滚不加载、滚轮加载到 80 条、末尾按钮加载剩下 20 条;手机 390 宽的底部面板、按钮不小于 44px、没有横向溢出、手指滑动加载下一批。本地 10 条全过,`--repeat-each=3` 30 条全过。
- 本地真浏览器:桌面 1440 和手机 390 打开芙莉莲第二季和 Re:0 第四季的面板,对照画布截图;WebKit(桌面和 iPhone)下 Tab 不出面板、关闭后焦点回到按钮。
### 类型页:切换类型、查看全部、返回时,标题和类型条不再闪一下
在类型条上点另一个类型时,标题和滑块会先切到新类型,内容区换成骨架屏。等新页面到了,整页会重新挂载,标题和类型条的进场淡入从透明度 0 重放一遍:本来一直显示着的标题凭空消失再出现,看起来就是「全部闪一下」。「查看全部」进网格时的返回链接、网格点返回时的标题也是同一个原因。
现在点击时会记下正在去的那个视图的页头,新页面挂载时如果自己的页头正是加载期间显示着的那个,就按原样画出来,不再播放进场动画;只有新出现的部数自己淡入。浏览器后退中途取消时会清掉这条记录。首次打开页面、从别的页面进来时,进场动画照旧。
测试:新单测 `carriedHeader.test.ts` 覆盖记下、匹配、只认最新一次、清除;沙箱 e2e `genre-hub.spec.ts` 在切类型、查看全部、返回三条路径上逐帧记录页头的透明度,要求到达的页头始终是 1。同一断言对线上旧版跑会失败(到达时降到 0),对本分支通过。
### 季度列表翻页:同分的番会在两页重复、或者哪页都不出现
`/api/anime/seasonal` 用 `LIMIT/OFFSET` 分页,排序却只有 `average_score DESC NULLS LAST`。同分的番之间没有次序,Postgres 每次排序给它们的顺序取决于这一页的 `LIMIT`,于是同一部番可能既是第 1 页的最后一张又是第 2 页的第一张,另一部同分的番两页都没有。`GetSeasonalAnime` 的排序补上 `anilist_id` 作为最后一个键,变成全序。其它分页查询(浏览、搜索、举报、动态流等)都已经有唯一键兜底,只有这一处漏了。
测试:新的 PG 测试 `seasonal_order_pg_test.go` 插入一部高分番和五部同分番(按 id 倒序插入,让「排序碰巧给的顺序」和「按 id」不一致),分三页每页两部取回,拼起来必须恰好是 `9,1,2,3,4,5`。在旧查询上它拿到的是 `9,5,4,3,2,1`(红),加上次序键后通过;`go test ./...` 全绿。
### 类型页:改成类型主页——四条横排、评分前 10、三排砖,「查看全部」打开同一路由下的网格
以前的 `/genre/{类型}` 是一张按人气排的封面网格,每页 24 部,靠「上一页 / 下一页」翻。现在先是一个类型主页,一眼看到这个类型的全貌;要看全部时再打开同一路由下的网格。
改动:
- **类型主页**:标题旁是总数和连载中的数量;下面一条 18 个类型的切换条,各带部数,当前类型下面垫一块滑块,点别的类型时滑块先滑过去,页面随后到。主体左边四条横排——正在连载、人气最高、近年新作(六年前至今)、经典老番(2009 年及以前),各 6 部,标题旁是部数,右边「查看全部」;不满 4 部的横排不出现。右边一栏是评分前 10(名次、小封面、年份 · 格式 · 集数、评分表情;读屏会读出名次),「完整排行」打开按评分排的全部。
- **三排砖**:年代分布、常一起出现的类型、代表制作公司,每排最多 7 块。砖的底色取第一张封面的色相,右上角叠三张代表作封面、悬停时散开,左下角是名字和部数;没有线条、比例条或图表。三排按顺序挑封面,前面的砖用过的封面后面尽量不再用。年代和类型的砖打开对应的网格,制作公司的砖链到那家公司自己的页面。
- **网格**(同一路由,`?list=`、`?decade=`、`?with=`):返回链接、「动作 · 正在连载」这样的标题和部数、6 列封面网格(和季度页同一种卡片:悬停浮层、追番按钮、放送角标),每页 36 部。「显示更多 · 还有 N 部」是指向下一页的真链接,有脚本时按下就原地接着加载,前几页滚到附近也会自动加载(只跟着读者亲手的滚动——滚轮、手指、翻页键;浏览器自己挪动视口、用 Tab 把链接带进视野都不算,所以不会一口气连着加载好几页,也不会在键盘用户要按它的时候先把它换掉);一个网格原地最多展开十页,再往后就打开下一页本身。点进一部番再返回,已加载的几页还在;用键盘按下时,焦点落到新加载的第一部上。旧的 `?page=N` 永久跳转(308)到人气排序网格里装着旧第 N 页第一部的那一页(旧版每页 24 部,网格每页 36 部)。每个网格视图有自己的 canonical,比如 `?decade=1990`、`?with=fantasy`(两个类型的组合从哪一边打开都是同一批番,canonical 写成切换条上排在前面的类型那一边);超出最后一页的页码是 404,不是空网格。
- 卡片上的时间行:在播的番照旧写下一集还有多久,其余写哪一年哪一季、全几集;手机卡片写「年份 · 格式」。
- 视图之间、类型之间的切换是正常跳转,等待时立即换成目标视图的标题,内容区显示骨架屏。每一部番、每个类型、每块砖和下一页都是 HTML 里的真链接。
- **手机**:切换条横向滑动,并把当前类型滚进视野(用鼠标的窄窗口下面有一条细滚动条);横排和砖排变成横向滑动的轨道(卡片 118px、砖 138px),评分前 10 排在横排之后;网格 3 列。平板宽度下横排和砖排同样是轨道,评分前 10 分两列。
- 简体、繁体、英文三语;英文的切换条在桌面上排成两行,部数写作 "anime"(单复数同形,和季度页一致)。
- 读屏:切换条和砖的名字就是它们的可见文字(比如「奇幻 2,078 部」),再在后面补上看图才知道的几个字(「动作和奇幻都有」),用语音点「奇幻」也能点中。
- 服务端取数改成匿名请求:类型页三个接口的回答对所有读者都一样,现在在读者之间共享缓存,不再按每个读者的 Cookie 各存一份。制作公司页和年份页的取数同样改了。
- 季度页的封面卡片多了一个可选的时间行参数和「横排」样式,供类型页复用;季度页本身不变。
测试:
- 单测:地址参数的解析(视图的优先顺序、未知值回到主页、旧 `?page=N`、原型属性名)、每种视图对应的接口参数、三种语言的标题、砖封面去重、数字分组、接口回答的校验。
- 渲染测试:主页和网格的服务端 HTML(标题、18 个类型链接、每张卡片的链接和标题、评分前 10、砖的链接、下一页链接、旧分页、空网格),三种语言。
- 沙箱 e2e:用一个别的用例不用的类型自带数据,覆盖主页各区块、「查看全部」到网格再返回、等待时的骨架屏、按下和滚动加载更多、返回后保留已加载的页、年代 / 类型 / 制作公司砖、切换条滑块先动、浮层、旧分页的 308 跳转和网格的 canonical、状态码(未知类型 404,越界页码 404,未知参数仍是主页)、英文和繁体、手机布局。CI 的预热列表加上类型页。
- 图片尺寸防漂移测试加上了类型页。
### 类型页(后端):`/browse` 多了五个筛选,新增一次取齐类型主页的接口
类型页改版要的数据,这里是 go-api 这一半。
改动:
- **`GET /api/anime/browse` 新增五个可选参数**:`with`(第二个类型,只能和 `genre` 一起用,不能和它相同,也不能是 Hentai);`status`(AniList 的五种状态);`yearFrom` / `yearTo`(发行年份的闭区间,1900–2100,起点晚于终点算错;设了任一边界,没有年份的番就不列入);`sort`(默认 `popularity`,即原来的顺序;`score` 按评分排,只排有评分、并且在 AniList 上至少有 1000 人追的番,总数按同一条件算)。参数写错一律 400 VALIDATION_ERROR,不会悄悄当成没写。`genre` / `studio` / `year` 三选一的规则不变;`genre` 现在必须是 18 个可浏览的类型之一,`studio` 必须是合法的 UTF-8 文本(不含 NUL,最长 200 字节),否则同样是 400——以前这类值会一路查到数据库,返回空列表或 500。
- **列表项改成季度页的同一种形状**:原有字段一个字节都不变,后面追加 `studio`、`nextAiringAt`、`nextAiringEpisode`、`popularity`、`favourites`、`source`、`startDate`、`duration`、`isSequel`。类型、工作室、年份三种 hub 页和 sitemap 照旧能读。
- **新增 `GET /api/anime/genre-overview?genre=Action`**:一个类型主页要的东西一次给齐——总数和连载中的数量;五条横排(正在连载、人气最高、近年新作、经典老番各 6 部,评分前 10);年代分布(2020 年代到 1970 年代,加「更早」,从新到旧,没有番的年代不出现);常一起出现的类型(最多 7 个,不含自己和 Hentai);代表制作公司(只算主制作公司,最多 7 家)。每块砖带最多 24 张候选封面,只取有封面的番,留给前端在砖与砖之间去重:热门的几部会排在一块又一块砖的最前面,候选少了,靠后的砖就挑不出三张别处没出现过的封面。只认 18 个可浏览的类型,别的一律 400;进程内按类型缓存 10 分钟,缓存按条目定尺寸;同一类型同时未命中的请求共用一次读取。
- **横排、砖块和「查看全部」打开的列表是同一套条件**:横排直接走 `/browse` 的那条语句,年代和类型砖的计数、封面也按同样的条件和顺序算,所以点开「查看全部」,网格开头就是横排上那几部;点年代或类型的砖,网格的总数就是砖上写的数。制作公司的砖链到那家公司自己的页面,那里列的是它所有类型的作品,砖上的数只算这个类型。每条横排、每种砖对应哪组 `/browse` 参数,写在 `genre_overview.go` 开头。
- **`/browse` 先挑出这一页的 id,再取整行**:以前排序和 OFFSET 跳过的每一行都要跑一遍逐行子查询、带着简介一起排序,深页因此慢,最深的那些页估算的代价还高到让 Postgres 打开 JIT 编译。现在旧链接 `?page=` 能到的深页从约 60–110ms 降到约 20–30ms;要是照旧一次取整行,加上季度页那几列以后会到约 470ms。第一页多了九个字段,多花不到 1ms。`with` 写成 IN 而不是第二个 EXISTS,意思一样,但计数的估算不会越过 JIT 的门槛(越过时每次请求多花约 50ms 编译)。
- **go-api 的数据库连接不再用 JIT**:网站的读请求都是几十毫秒的小表查询,编译一次 JIT 就要几十到几百毫秒,越过门槛只会更慢;`/browse` 和它的计数离门槛只差约五分之一,目录再长一些就会越过,深页会从约 30ms 变成约 100ms。现在连接池里的会话一律 `jit=off`(后台任务和几个命令行工具用的是同一个池)。
测试:
- 单测:每个新参数的解析和报错、三选一规则、列表和总数拿到的是同一组筛选参数、旧字段逐字节不变(沿用季度页那组从旧代码截下来的字节)、类型主页的五条横排正是对应的 `/browse` 列表、北京时间跨年那一刻「近年」起点的变化、年代表首尾相接不重叠、缓存命中与「失败不缓存」、`/genre-overview` 不会被 `/{anilistId}` 吃掉、同一类型同时未命中的 8 个请求只读一次(用 `testing/synctest` 确定地等到所有请求都在等)、读者中途离开读取照常完成并进缓存、读取 panic 返回 500、缓存装得下全部 18 个类型、错误信息写明是哪条横排或哪种砖、连接池关掉 JIT、`/browse` 拒绝非可浏览类型和非法的 studio 文本且不发出查询。
- 真 Postgres 测试:一份七十多部的种子目录,在 Go 里独立算出每个筛选、两种排序和每块砖应得的结果,再和数据库的回答逐项比对;分页读完整个列表不重不漏;年代边界(1969、1970、1979、2029、2030)、没有年份的番、只有开播日期的剧场版、正好卡在评分门槛上的番、成人番(按标记和按类型)哪里都不出现、委员会挂名不算制作公司、同数时按名字排;砖的候选数上限对三种砖都真的生效;`/browse` 和它的计数在 pgx 默认的语句缓存下一直按实参规划(13 组不同参数后 generic plan 仍为 0),谓词一旦被改写成会切到通用计划的形式,这个测试就会失败。
- 变异检查:把 SQL 里的评分门槛、成人过滤、排除自身、年代边界、封面排序、同数规则、外层排序等逐一改坏,对应的测试都会失败。
### 图片:夜间预压不再一慢就停
上一版的预压任务有一条刹车:任何一张图超过 5 秒才回来,就停 10 分钟。压图服务忙着处理别的请求时,新压一张常常就要 5 秒以上,于是任务每干一小会儿就停 10 分钟,大部分时间在等,一晚只走完清单开头的一小段。
改动:
- **去掉「超过 5 秒就停」**:任务本来就一次只发一个请求,前一个回来了才发下一个。压图服务忙时,它的请求跟着排队,任务自己就慢下来了。
- **压图服务明确说忙(503、429)时仍然停**,但从 10 分钟改成 2 分钟。一分钟都没压完的,nginx 会回 503,同样停 2 分钟。
- nginx 一直不回、超时的请求照旧算失败(连续 3 次停掉本轮并报 ERROR),不再顺带暂停。
- **go-api 的容器日志从 3 个 10MB 改成 5 个 50MB**,大约能留一天,夜里每一轮的统计行到第二天还看得到。
测试:
- 单测:一串慢回答(几秒到将近一分钟)一张接一张地问完,不暂停,各自照常计数;503、429 停 2 分钟;暂停仍不超过本轮预算和时间窗;nginx 不回的请求算失败,本轮接着跑。
### 图片:老番页面的图提前压好,不再和别的图抢缓存
浏览器加载的不是原图,是 `/_next/image` 按显示尺寸压出来的 AVIF。每个尺寸第一次被请求时现压,压完存进 nginx 的缓存。以前所有尺寸挤在一块缓存里,满了就删最久没用的:访问少的老番,图最先被挤掉,再打开时要排队现压,第一次打开很慢,甚至有图是空的。
改动:
- **nginx 分两块缓存**:页面会用到的尺寸(AVIF 的封面、横幅、角色和职员头像,宽度是详情页、季度页和卡片实际请求的那几档)留在原来的 `next_image`,名字、目录和缓存键都不变,已经压好的照样命中;其余请求(别的格式、别的宽度、别的图)进新的 `next_image_misc`,满了只在自己区里淘汰。`/_next/image` 的访问日志行尾多了 `cache=… zone=…`,可以按区看命中率。
- **夜间预压任务 `image_variant_warm`**(go-api,river,队列 `image_variant_warm`,一个槽,暂停这个队列就停):每晚北京时间 01:00–07:00(03:15–04:00 让开备份),对每部非成人番详情页和季度页会显示的图,用 HEAD 逐个问 nginx 的内网入口:已经在缓存里的不压,只让它保持「最近被用过」;不在的现压并存好。清单照详情页接口取图(用户提交的修改优先,前 8 个角色和他们的声优、前 10 个职员,相关作品和推荐作品的封面),只取原图已在镜像里的。
- **滚动刷新**:每晚另外把页面尺寸按图片地址分成 25 组,强制重压其中一组,所以每张图 25 天内都会刷新一次,不会在白天过期(有效期仍是 30 天),地址里没有内容指纹的图也最多 25 天就跟上 AniList。
- **刹车**:单个请求超过 5 秒、或压图服务忙(503、429),就停 10 分钟;连续 3 次失败停掉本轮并报 ERROR(参数被拒的 400、没有缓存状态头的 200 都算失败);磁盘剩不到 10 GiB 跳过本轮。同一晚的下一轮从上一轮停下的地方接着跑,每轮在日志里写一行统计。
- **开关**:`IMAGE_VARIANT_WARM_BASE_URL`,为空就不跑;`docker-compose.yml` 默认指向 nginx 的内网入口 `http://nginx:8090/_next/image`,部署后即生效。
- 内网 8090 口新增 `/_next/image`:和公开入口同区、同键,只收镜像地址的页面尺寸,不回旧副本,响应带 `X-Cache-Status`,认 `X-Warm-Refresh: 1` 强制重压;公开入口不认这个头。
测试:
- nginx 行为测试:每类图、每档宽度落在哪个区;镜像地址和 AniList 地址共用一个条目;两个区都带全站安全头;忙时的 503 在两个区都不进缓存;内网入口的 HEAD 预压后读者请求直接命中、旁路头只在内网生效、不是页面尺寸或不是镜像地址一律拒绝;分区之前存的条目,换成新配置后照样命中。原有用例原样通过(读缓存键时改为两个区都读;过期副本的计数因内网入口多一处有效期而从 3 变 4)。
- 防漂移:bun 测试渲染详情页头图、四个区块和季度页三种视图,按本站的图片尺寸配置,断言请求的每个 AniList 尺寸都在 nginx 的页面尺寸清单里;Go 测试断言任务的尺寸清单和 nginx 的清单一致。前端改了图片宽度,CI 当天就会报错。
- 预压任务的单测和真 Postgres 测试:时间窗边界(按北京时间)、25 组轮换、拼出的参数符合 nginx 的格式校验、各种应答和刹车、同一晚多轮接着跑;目标清单只取该取的图(用户修改优先、只取已镜像的原图、去重)。
### 全站提示:改成底部胶囊,同一时间只留一条
以前的提示是 react-hot-toast 的默认样子,在页面顶部正中,正好压在导航栏上;进场从 0.6 倍带回弹落下。连续操作时一条接一条往下排:在季度页连点 6 部追番,会从顶上排下 6 条一模一样的「已加入「在看」 撤销」,盖住导航和筛选栏,也分不清哪个「撤销」是哪部的。
改动:
- 位置挪到底部正中(离底 28px,手机 16px 加安全区),不再盖导航;标签页过期提示出现时,提示抬到它上面。
- 外观和站里另外两个提示(语言提示、标签页过期提示)统一成同一种毛玻璃胶囊:成功是绿色对勾,失败是红色感叹号加一圈淡红边,加载中是转圈;「撤销」这类主操作是白色小胶囊,「查看我的追番」这类是安静的文字链。
- 同一时间只显示一条:新提示顶替旧的。连续追番时,被顶掉的那几部卡片上都有 ✓,再点就能取消。追番的确认按点的先后显示:前一部的请求回得慢,也不会把后一部的提示顶掉;失败的照常弹出。
- 一键追番的提示带上番名,例如「来自深渊 觉醒的神秘 已加入「在看」」;窄屏先省略番名。
- 动效改成淡入加上移 12 像素(0.26 秒),退场淡出加下移 6 像素(0.18 秒),不再缩放;系统开了「减弱动态效果」时不移动。
- 全站 17 处调用不用改,仍是 `toast.success()` 这些写法;以前三处自己写行内样式的带操作提示,改用共用的样式类。
测试:
- 单测:同一时间只留一条的判断,包括按 id 更新后重新出现的提示。
- 单测:按点的先后显示确认(回得慢的旧确认不显示,失败的不占顺序)。
- 沙箱 e2e:在季度页连点 3 部追番,第一部的请求故意拖慢 1.5 秒,屏幕上始终只有一条提示,在窗口底部、不碰导航,写的是最后那部的番名,带「撤销」。
### 季度页:改成浏览页——放送日柱图、色调、筛选和三种视图,筛选不再刷新页面
以前的季度页是一排季度切换(上一季 / 下一季、四个季度、近几年的年份)、类型 / 格式 / 状态药丸和一个排序下拉,先放 20 部;每点一个药丸、每按一次「显示更多」,都要回服务端重新渲染一次。现在整季一次取回,筛选、排序、换视图、显示更多都在浏览器里立刻完成;状态写进地址栏(`history.replaceState`,不进历史记录),复制链接打开的是同一个筛选结果。
改动:
- **标题旁边一张小柱图**:在播的番多(连载中、排得出下一集时间的不少于 20 部,通常就是本季)时是一至日七根柱,按北京时间算哪天放送,今天那根标绿;点一根只看那天、按放送时间排,再点一次恢复。其他季度是按开播月份的柱(没有开播日期、而下一集是第 1 集的,按那一集的播出日算,其余归「待定」),点了按开播日期排。
- **筛选栏**:搜索(中文、繁体、日文、罗马字、英文标题都能搜到)、类型(可多选)、年份、季度、格式,「更多筛选」里是播出状态、原作、新作 / 续作。年份和季度的菜单是真链接,收起时也在 HTML 里,爬虫照旧能顺着走到每一季;年份从 2000 年一直列到明年(旧版只列近几年)。旧版的「上一季 / 下一季」按钮没有了:同一年的四季在季度菜单里,别的年份在年份菜单里。换季度、换年份是正常跳转,等待时结果区显示骨架屏——这一页唯一的骨架屏。
- **标签行**:已选的条件逐个列出,点 × 只去掉那一个,「清除全部」一次清空;右边是色调圆点(悬停展开成「蓝调 26」,选中后只看这个色调,页面底色换成它的颜色)、排序(人气、评分、收藏、下一集、放送时间、开播日期、标题)和三种视图(封面网格、图表卡、列表)。
- **封面网格**:桌面 6 列,鼠标停在卡片上弹出 AniList 那样的浮层:放送进度(下一集还有多久,或者已完结、何时开播)、评分、主制作公司、格式和集数、Bangumi 评分、最多三个类型,靠右的两列往左弹;窗口窄到只剩三列时不弹(封面两边都放不下)。封面左上角的角标:24 小时内要播的写「今天 / 明天 22:00」,过去 24 小时刚播过的写「第 N 集更新」。
- **图表卡和列表**:图表卡是封面加倒计时、元信息和简介;列表一行一部,列出评分和人气、格式和集数、播出状态。
- **显示更多**:网格一批 36 部、图表卡 12、列表 16(手机网格 21、图表卡 7,手机上没有列表视图)。没显示的也在 HTML 里,作为链接让爬虫读到,只是先隐藏;滚到按钮附近会自动显示下一批。
- **手机**:搜索框旁边一个筛选按钮(数字是已选的类型、格式和播出状态个数),点开是底部面板:季度(选中块会滑过去,都是链接)、年份、排序、类型、格式、播出状态,底部按钮写着「查看 N 部」。面板挂在 body 下,打开时锁住页面滚动,焦点留在面板里,Esc、点暗处、点按钮都能关。
- **追番按钮**:用站内同一套一键追番。「+」直接加进「在看」,再点「✓」取消追番:只追了、还什么都没记的番直接取消,提示里能撤销;已经有观看进度、评分、改过状态或动态有人回复点赞的,先弹出确认框说明会删掉什么,确认了才删(默认焦点在「保留」)。没登录的先去登录,回来后自动加上。其他页面海报角上的「+」不变,「✓」仍然是去详情页。
- **时间**:服务端渲染和水合时按北京时间;水合后改按读者所在的时区,和首页「今日更新」、放送表一样(北京时区的读者看到的不变)。放送日柱图、「今天 / 明天」角标和放送时刻都跟着读者的时钟,每 30 秒刷新一次,不会水合不一致。
- 动效照 AniList 的时长(卡片进场 0.3 秒、浮层 0.22 秒、下拉 0.2 秒),系统开了「减弱动态效果」时全部关掉。鼠标停在卡片上时封面图在框里慢慢放大一点(封面网格和图表卡一样);追番的「+」只淡入,不缩放也不旋转——这么小的图标一缩放,结束那一下会跳。
- `<title>`、canonical、hreflang、OG,以及不存在的季度回 404,都没变。旧链接里的 `?show=`、单个 `genre=`、`sort=score|title|format` 仍然能打开。
- 读接口的几个注意点:「下一集时间」只和这一行最近一次同步一样新,可能已经过去——过去 24 小时内的当作「第 N 集更新」,更早的什么都不显示,倒计时不会出现负数;放送日只算连载中的番,还没开播的按开播月份算;`isSequel` 为 null 是还不知道(没拉过详情),「只看新作」只收确定不是续作的,「新作 / 续作」筛选也只在至少有一部确定是续作时才提供;部数一律按返回的条数算,不读 `pagination.total`。
- 删掉不再使用的 SeasonNav、SeasonalFilterChips、SeasonalShowMore、SeasonalPagination,以及只有它们用到的词条。
测试:
- 单测:地址栏参数的解析和写回(包括旧参数)、每种筛选和排序、两种柱图和「今天」、三种语言的放送和倒计时文字、角标规则(包括已经过去的下一集)、色调分组、批次计算、季度链接。
- 渲染测试(服务端 HTML):每部番都是以标题为文字的 `/anime/{id}` 链接,第一批之外的带 `hidden`;年份、季度菜单收起时也是链接;只有一个 h1;没有主制作公司时不留空行、元信息里没有多余的分隔点。
- 沙箱 e2e(`seasonal-browse.spec.ts`,用 2099 年两个只给测试用的季度):筛选、标签和地址栏一起变,去掉一个标签只去掉那一个条件;换视图;显示更多(点按钮和滚动);放送日柱图和开播月份柱图;色调;浮层内容;Esc 和点外面关菜单;换季度时的骨架屏;减弱动态效果;追番写入订阅,再点「✓」取消(有进度的先确认,Esc 保留);手机网格和筛选面板。把「第一批之外隐藏」和菜单的 Esc 改坏后,对应用例都会失败(实际改坏跑过)。
- 线上 e2e(`seasonal.spec.ts`)在每个 PR 上打的是已经部署的站点,所以只断言新旧两版都满足的部分(h1、带标题的番剧链接、HTML 里的季度链接);新版独有的检查等线上换成新版后才会执行。
### 取消追番:新接口只删「只追了番」的订阅
`DELETE /api/subscriptions/:anilistId/untouched` 只在这条订阅除了「追了」什么都没有时才删:状态是一键追番写的「在看」,没有标过集、没有观看时间、没有评分,状态动态也没人点赞或回复(已删除的回复不算)。只要有其中任何一样,就什么都不删,返回 409 `CONFLICT`(`Subscription has history`),让页面先问读者。没有这条订阅时返回 200(已经没有可删的),所以这个接口的 404 只表示服务端还没有这个接口:旧版后端会回 404、什么都不删;要是做成普通删除接口上的一个参数,旧版会忽略参数直接删掉。删掉时连同它的状态动态,和普通删除一样。普通的 `DELETE /api/subscriptions/:anilistId` 没有任何变化。
季度页的「✓」就是靠它:再点一次时先调这个接口,409 时弹确认框,确认后才用普通删除。
测试:处理函数的单测覆盖 200 / 409 / 500、没有这条订阅时的 200,以及这个路径不会落到普通删除;真 Postgres 的测试逐一造了只追了番、标过集、打过分、改过状态、动态被点赞、被回复、回复已删、有观看时间但进度清零的订阅,确认只有该删的被删、标集记录和被点赞的动态都还在。另有一条并发测试:先开事务给这部番标一集、不提交,确认删除请求卡在这一行的锁上,提交后它返回 409、刚标的那一集还在(把语句里的 `FOR UPDATE` 去掉,这条测试会失败)。
### 季度接口:每一项多带九个字段,给新版季度页排序、筛选和角标用
`GET /api/anime/seasonal` 的每一项新增九个字段,原有字段一个字节都不变(有下游客户端直接解码这个接口)。不知道的值是 null,键总在:
- `studio`:主制作公司里按名字排第一的那家,就是详情页「制作」一行列出的第一家;制作委员会、发行方这些非主制作不算。
- `nextAiringAt` / `nextAiringEpisode`:AniList 最近一次给出的下一集播出时间(RFC 3339,UTC)和集号。和这一行一样新,可能已经过去,读的一方要拿自己的时钟比。
- `popularity` / `favourites`:AniList 的人气和收藏数。
- `source`:原作类型(ORIGINAL、MANGA、LIGHT_NOVEL……)。
- `startDate`:开播日期,`YYYY-MM-DD`;AniList 只给到年或月的算不知道。
- `duration`:单集分钟数。
- `isSequel`:AniList 列出的前作(PREQUEL)里有动画(TV、TV_SHORT、MOVIE、SPECIAL、OVA、ONA、MUSIC)。前作只有漫画、小说的不算续作。还没拉过详情的番是 null(「还不知道」),不是 false。
改动:
- **响应换成手写的结构**(`seasonalItem`),不再直接输出 sqlc 生成的行:旧字段按原来的顺序、名字和类型照抄,新字段接在后面。时间显式转成 UTC,因为 pgx 读出的时间带的是进程所在的时区。
- **主制作按「最多一个元素的数组」查**:sqlc 会把选 NOT NULL 列的标量子查询当成非空字符串,没有主制作的番一扫描就出错;改成左连接 `anime_studios` 的话,规划器会给全表的主制作建一张哈希表。现在每部番一次主键查找。
- **季度的 AniList 查询多选四个字段**:startDate、endDate、duration、source,和 facts sweep 选的那四个相同。冷启动(库里这一季一部都没有,现去拉 AniList)的第一次响应就带开播日期、时长和原作,不用等 sweep;每天的预热任务也会顺带刷新当季和下一季的这四项,而 sweep 对没完结的番一个月才重查一次。upsert 对这四项是 COALESCE,AniList 没给的不会抹掉库里已有的。实测一整页 50 部不报复杂度错误,请求次数不变。
- **`studio` 和 `isSequel` 只在拉过详情的番上有值**:这两张子表只有详情接口写。只被列表写过的番,`studio` 和 `isSequel` 都是 null,意思是「还不知道」(`isSequel` 看 `detail_fetched_at` 判断拉没拉过详情)。冷启动不去拉制作公司和关联:那会给一页里每部番多加两层嵌套连接,还要让列表路径去写子表;也没有为此加 sweep。
- 列表本身不变:哪些番出现(成人番按列、按类型两道排除)、顺序(评分从高到低,没评分的在最后)、总数。
测试:
- 单测:旧字段逐字节等于改之前的输出(从改之前的代码录下来的,HTML 字符不转义),新字段接在后面;全空的行每个新键都在、值为 null;同一时刻从不同时区读出都输出同一个 UTC 时间;日期格式,±infinity 为 null;冷启动把四项写进 upsert。
- AniList 查询文本:季度查询必须选这四个字段,不能选 studios 和 relations。
- 真 Postgres,子表用详情接口的写入代码写:两家主制作取名字靠前的那家,名字更靠前的非主制作不算,有两家主制作也只出一项;前作是七种动画格式之一才算续作,MANGA、NOVEL、ONE_SHOT 和没写格式的不算;拉过详情、什么都不知道的番九个字段全是 null 和 false,只被列表写过的番 `isSequel` 是 null;成人番和别的季度不出现,顺序、总数不变。冷启动:第一次响应就带开播日期、时长、原作、人气、收藏和下一集;第二次走库里的行,逐字节相同,不再请求 AniList;拉过详情之后 `studio` 和 `isSequel` 有了值。
- 变异:主制作不看 is_main、主制作倒序、主制作不排序、续作不看格式、续作漏掉 MUSIC、续作不看关联类型、不转 UTC、日期改成 RFC 3339、`studio` 不映射、`genres` 改成空数组、季度查询去掉 duration、季度查询加上 studios,12 个全被抓到。「主制作不排序」一开始没抓到:测试库没有统计信息时,规划器按主键 (anime_id, studio) 读,本来就按名字返回;有了统计信息(包括接近线上规模的本地合成数据)它改走 bitmap 堆扫描,按写入顺序返回。测试在写完数据后先 ANALYZE,去掉排序就会失败。
- 执行计划(本地合成数据,规模接近线上,取一季整页):新加的子查询都走索引,每部番各一次。单次 EXPLAIN 整页从 3.0ms 到 3.5ms;本机负载很高时新旧交替各跑 15 次取中位数,是 2–3ms 到 11–13ms,大头是主制作那次查找(每部番一次 bitmap 堆扫描加排序)。临时加一个 `(anime_id, studio) WHERE is_main` 的部分索引,它就变成只读索引,整页只比改之前慢两成左右;但这个接口在 Next 那边有 300 秒的数据缓存,绝对开销是每页十毫秒上下,这次没加索引。
### 新番:开播前没匹配上 Bangumi 的,每周重新搜一次
赛博朋克:边缘行者 2 在站内一直没有中文名,用中文搜「赛博朋克」「边缘行者 2」都搜不到。原因是富化流程只在番剧入库那天去 Bangumi 搜一次:那时 Bangumi 上还没有这部的条目(或资料不全),没匹配上就停在那里,之后再也不会搜。新番往往在开播前几个月就出现在 AniList,这种「搜得太早」是常态;而能自动补救的 id 对照表,对新番要滞后好几个月。
改动:
- **新增定期任务 `bgm_rebind_search`**:挑出没绑定 Bangumi、状态是在播或未开播、非成人向、没被管理员手动修正过、id 对照表里也没有的番(对照表里有的归 id 对照表的扫描管,它不绑是有意的),每部每周最多重搜一次,每 6 小时跑一轮,每轮最多 100 部,人气高的先搜。搜索和打分沿用原来的规则和门槛;另外要求两边的季号和分段一致,不一致就不绑(原来的打分会忽略季号,第三季可能被绑到第二季的条目上)。某个 Bangumi 条目已经被别的番占用时也不绑。
- **绑定和后续富化一起提交**:绑上的番在同一个事务里排队拉取中文名、评分和分集信息;排队失败就整体回滚,下一轮再试。绑定时清掉 V1 停放时打的「待审核」标记;管理员手动打的「待审核」保留。
- **不碰交给 V1 的行**:管理员「重置」或其他途径把番退回 V1 处理(版本号 0)之后,这个任务既不绑定也不记时间,即使重置发生在它读出候选之后。
- **新列 `anime_cache.bgm_rebind_checked_at`**(迁移 0051)记录上次重搜的时间,用来实现「每周一次」。三处把番退回 V1 的语句(管理员重置、批量回填重置、撤销旧绑定)都会一并清掉它,所以「`bgm_match_source = 'fuzzy_high'` 且这一列有值」就是这个任务绑的那些番。
- **迁移带 3 秒锁超时**:加列要短暂独占 `anime_cache`,如果撞上夜间备份,部署会在 3 秒后失败,而不是让全站读请求排队卡住。失败时表结构不变,执行 `migrate force 50` 后重新部署即可。
- **默认关闭**:部署后在 `.env.production` 设 `BGM_REBIND_SWEEP_ENABLED=true` 再重启才会运行;暂停 `bgm_bind` 队列可以随时停下,不用重启。
测试:
- 单测:精确匹配会绑定;没有结果、置信度不够、季号不一致(真实数据:芙莉莲第三季对第二季的条目)、条目已被占用、Bangumi 直接拒绝这次搜索(400 之类,或返回的不是搜索结果)都不绑,并记下时间等一周;网络失败、5xx、429、占用检查失败、事务回滚都不记时间,下一轮重试;开关为空、写错或为 false 时什么都不做。季号用的是线上真实标题(「2クール」对「第2クール」、「II」对「Ⅱ」)。
- 集成测试(真 Postgres):候选只包含该包含的番(暂停中、已完结、对照表里有的、刚退回 V1 的都不算),按「没搜过的优先、人气高的优先」排序;绑定语句会清掉 V1 打的「待审核」、保留人工打的,不覆盖已有中文名,并拒绝已被占用的条目、手动修正过的番、已绑定的番和已退回 V1 的番;排队失败时绑定回滚、不记时间;同一轮里两部番搜到同一个条目,只绑第一部,第二部记为已被占用;三种退回 V1 的操作都会清掉时间戳。
- 迁移测试:另一个事务持有 `anime_cache` 的读锁时,0051 在约 3 秒内因锁超时失败,迁移记录停在 51 且标记为 dirty;`migrate force 50` 后能正常执行、回滚、再执行。
- 变异检查:分别删掉「已被占用」「非成人」「对照表里有」「版本号大于 0」「只清 V1 的待审核」「重置时清时间戳」和锁超时这几条判断,每次都恰好是对应的测试失败。
- 用线上同一套搜索和打分,对 2025–2027 年 215 部没绑定的番试跑了一遍(只读):44 部现在能高置信匹配。季号检查挡下其中 2 部:芙莉莲第三季(挡对了),和『ゲルぴよ 2』(AniList 原名末尾的「2」不算季号标记,被误挡,维持原状)。
### 详情页:没加入追番的番,点集数格子能记上了
在没加入追番的番剧详情页上,点集数列表里任何一集都记不上:勾先打上,随即消失,弹出「第 N 集没记上,请再试一次」。查询订阅的接口在没有订阅时返回的是 200 加 `data: null`,不是 404;订阅按钮一直按「没订阅」处理,集数列表却把它读成一条状态为「在看」的订阅,于是跳过「先建订阅」这一步,直接去记集数。服务端找不到这条订阅,回 404,格子回滚。
改动:
- **`data: null` 读作没有订阅**:解析挪到 `watchedEpisodeState.ts` 的 `parseSubscriptionRead`。第一次点格子时会先建订阅(状态「在看」,弹出「已加入追番」),再记这一集。已有订阅的番不受影响。
测试:
- 单测:`data: null` 解析为没有订阅;有订阅时照常读出状态、集数和评分;未知状态和读不出的响应都算没有订阅。
- 沙箱 e2e(`episode-grid-first-mark.spec.ts`):新用户打开一部没订阅的番,点第 2 集,数据库里建了订阅、已看到第 2 集;刷新后第 2 集仍打着勾。之前的单测喂的是集数列表以为的响应形状,不是后端真正返回的,所以一直没发现。
### 详情页:在播的番也显示「已看 N / 总集数」
「在看」下拉框旁边的「已看 N / 总集数」和小进度条,只在 AniList 给出总集数时才显示。在播的番 AniList 一般要等完结才有集数,所以这些番上这块是空的;可同一页下面的集数列表会改用 Bangumi 的集数画格子,并显示「已看 2 / 12 集」,两处对不上。
改动:
- **和集数列表用同一个总集数**:新增 `gridTotal`,规则和集数列表相同——先用 AniList 的集数,没有就用 Bangumi 的,再没有就用分集标题里最大的集号。页面在服务端算好,只交给这块进度显示;三样都没有时照旧不显示。磁力搜索仍用 AniList 的集数,推断出来的集数也不进结构化数据。
测试:
- 单测:有 AniList 集数时用它;没有时用 Bangumi 的集数;只有分集标题时用最大集号;什么都没有时为空。
- 沙箱 e2e(`detail-progress-total.spec.ts`):AniList 没有集数、Bangumi 有 12 集的番,「在看」旁边显示「已看 2/12」,点第 3 集后变成「已看 3/12」。
- 本地用当前分支渲染线上的冰之城墙第二季(订阅接口在浏览器里拦截,不写线上):显示「已看 2/12」和进度条;AniList 有集数的番照旧。
### 本地库:导入边读文件边匹配,第一张卡几秒内出来
导入要先读每个文件的前 16MB 算 MD5(弹弹play 按它匹配,库里也按它认文件)。以前要把整批文件全部算完才开始匹配:一份 371 个文件的库放在移动硬盘上,要读 6.2GB,前半分钟一张卡都不出,算完了才一组一组去匹配。
改动:
- **读完一组就导入一组**:先按文件夹和标题分好组(分组用不到哈希),再把所有文件按导入的顺序排队算哈希。每组只等自己的文件算完就去匹配、建卡,后面的组在这期间接着读。
- **MD5 改用 WebAssembly(hash-wasm)**:算一个 16MB 比原来的 spark-md5 快约 4 倍,导入只受读盘速度限制,也不再占满 CPU 跟页面抢。算出的值和原来相同。去掉 spark-md5 依赖。
- **取消导入或离开库页面时,还没导入的组留到下次扫描**:排队还没读的文件不再读;读到一半被取消的那一组不导入(哈希不全,这些文件会用「文件名 + 大小」当编号)。已经导入的组保留。以前取消只在全部算完哈希之前有用,开始匹配之后取消,导入仍会在后台跑完;在算哈希时离开库页面,没算完的文件会用「文件名 + 大小」当编号入库,之后重新扫描也不会再补算。每次导入各自记是否被取消,取消之后马上再导入,不会让被取消的那次接着跑。
- **修了哈希超时串号**:一个文件 10 秒没算完(移动硬盘休眠后唤醒时可能这样),以前那个 worker 会被放回池里。它稍后算出的这个文件的结果,会被当成下一个文件的哈希;之后它给出的每个结果都错一位,文件会被认成别的文件。现在一超时就结束这个 worker,换一个新的。
实测:在 Chrome 里用真实的导入代码导入同一份 371 个文件的库,匹配走线上,和修改前交替各跑两次。第一张卡从 32 秒提前到 2.4–2.8 秒;全部导完从 41–42 秒降到 27–30 秒。现在的时间基本都花在读盘上:Chrome 读这块移动硬盘约 218MB/s,换读法、加并发都不变。两种代码导入的结果逐行相同(卡、集号、文件路径、哈希)。
测试:
- 哈希池:按调用顺序送给 worker;超时的文件晚到的结果不会算到下一个文件头上(修复前不通过);超时后换上的新 worker,在释放池时同样会结束;排队中被取消的请求不送出,已经在读的读完。
- 按组哈希:所有文件一次排好队,按导入顺序;一组只等自己的文件;算好的组里,分组和代表文件都换成算好的那份,传入的组不被改动;已有哈希或没有文件的不再读;算不出来的照旧不带哈希导入,并记一条警告。
- 导入:后一部番的文件还在读时,前一部已经建好卡;边读边导入和先全部算完再导入,库里的结果相同;取消后没导入的番剧留到下次;正在读的那一组被取消时不导入(去掉这项检查后这条不通过)。
- 在 Chrome 里用真实的导入 hook 和放慢的哈希 worker(模拟慢盘),导入一份 12 部番、42 个文件的合成库:开始 1.5 秒、还在算哈希时离开页面,修改前导入在后台跑完,42 个文件里 38 个用「文件名 + 大小」当编号入库;修改后导入停下,已入库的 4 个文件都有哈希。第一张卡出来后取消、马上再导入同一批文件:12 张卡、42 集,全部有哈希。
### 本地库:标题相同的几个文件夹各自去匹配,认出不同番剧时分开建卡
清空库重新导入(或第一次导入)时,「冰之城墙」第一季文件夹里的 14 集和根目录里第二季的「Koori no Jouheki - 16」进了同一张「冰之城墙 第二季」卡。导入时标题相同的几个文件夹会合成一组,只拿其中一个文件去匹配。根目录排在前面,拿去匹配的是「- 16」,弹弹play 认出它是第二季,于是整组按第二季建卡,绑定第二季的 AniList 编号;打开库时这张卡又和 Netflix 那一集建的第二季卡自动合并。上一条(续作的新一集按匹配到的番剧归卡)只限制了「放进已有的卡」,新建的卡仍然用这一个文件的匹配结果命名和绑定。
改动:
- **合组之后、匹配之前,每个文件夹各问一次**:一组里有不止一个文件夹时,每个文件夹挑一集正片去匹配(根目录里的散文件算同一个文件夹)。已经入库的文件夹不去问,直接用它所在那张卡绑定的编号。哪个文件夹认出的 AniList 编号和匹配文件所在文件夹的不同,就把它拆出去单独成组,按自己的匹配结果建卡或归卡。
- **两边都认出来、而且编号不同,才拆**:没认出来的文件夹留在原来的组里,因为它可能就是同一部番、弹弹play 还没收录,拆出去就多出一张没有编号、合并不回来的卡。只有特别篇或 OVA 的文件夹不去问,也不拆:它们在 AniList 上常常是单独的条目,但属于这部番。
- **同一个文件只问一次**:拆分时问过的文件,后面正式匹配时直接用那次的结果。一组只有一个文件夹时不多问。在一份 371 个文件的库上,匹配请求从 36 次变成 39 次。询问失败了也不在同一次导入里重问:如果拆分时没问到、正式匹配时重问成功,这组就既没拆开、又按那一个文件的结果建卡,正是要修的情况。线上的匹配客户端失败时返回空结果,不会走到这一步;会抛错的客户端,这一组记为失败,下次扫描再试。
- **拆分在处理到这一组时才做**:问的请求分散在整个导入过程里,不会全挤在第一行进度出来之前。拆分出了任何错,这一组照旧整组匹配,不会中断整次导入。
- 「+ 添加文件夹」也一样:第一季已经入库时,它的文件夹按那张卡的绑定回答,不去匹配;根目录的「- 16」拆出去,按自己的匹配进第二季的卡。上一条里这种情况「- 16」只能按标题留在第一季旁边。
- **根目录里的几个散文件算一个文件夹**:根目录的文件导入时各自成一组,上一条把「来自不止一组」当成「来自不止一个文件夹」,于是根目录里同时出现的「- 16」「- 17」不能按匹配归卡,又按标题进了第一季。现在按文件夹数,它们一起进第二季的卡。
- **副本先去掉再拆**:同一个文件在两个文件夹里各有一份时,先按内容去掉多出来的那份,再拆分;否则两份被拆到两组,会在两张卡上各建一集。
还没解决的两种情况(都和修改前一样):第一季文件夹那次没问到答案时(哈希超时、被限流、弹弹play 没收录),不会拆开,整组仍按「- 16」的结果建卡;「+ 添加文件夹」时根目录里已经在第二季卡上的一集,和新的一集、已入库的第一季文件夹一起导入,已入库的那一集会被挪到第一季的卡上。
已经放错的卡不会自动改。部署后删掉这张混在一起的卡,再重新扫描,第一季会单独成一张卡,「- 16」归到第二季的卡。
测试:
- 导入(内存数据库,真实的分组、合组和写入):空库第一次导入第一季文件夹和根目录的「- 16」,分成两张卡,各绑各的编号,第一季的卡用第一季的标题;两个文件夹认出同一部番时仍是一张卡,每个文件夹只问一次;没认出来的文件夹留在组里;匹配文件所在的文件夹没认出来时也不拆;特别篇的文件夹不问也不拆;排在前面的文件夹已经入库时,只问新的那个文件夹;三个文件夹认出三部番时分成三张卡;根目录几个散文件一起作为一个文件夹,只问一次;拆出来的每一组在导入进度里各占一行;客户端每次都抛错时这一组记为失败、整次导入照常结束;问失败一次的文件不在同一次导入里重问,第一季不会被当成第二季;一个文件在被拆开的两个文件夹里各有一份时只建一集,另一份记成副本;根目录里同时出现的「- 16」「- 17」进第二季的卡。前两条修复前不通过;「客户端抛错」和「失败不重问」两条在去掉兜底、或失败后重问时不通过;副本和根目录这两条在上一版拆分上不通过(都实际改回去跑过)。上一条里「已入库的文件不动」那条用例改成新的结果:第一季不动,「- 16」进第二季的卡,第一季的文件一个都没有送去匹配。
- 用真实的解析、合组、导入和匹配客户端,在一份 371 个文件的本地库快照上重放整库导入:修复前第一季 14 集和「- 16」在同一张第二季卡上;修复后第一季一张卡(1–14 集),第二季一张卡(Netflix 的第 1 集和「- 16」),其余 33 张卡逐个文件和修复前相同。同一份快照上重放「删掉混在一起的卡,再重新扫描」:修复前 15 个文件又全进第二季;修复后第一季单独成卡,「- 16」进 Netflix 那张第二季卡。
### 首页「按色调逛」:每个色调都能看全,改成可以拖动的一整行
以前这一块看不全,有三层原因:首页只取本季评分最高的 48 部来分组(本季实际有 99 部),每个色调最多放 10 部,桌面上又用样式把第 7 张以后的卡片藏掉了(窗口变窄时只留 4 张、3 张)。药丸上写「蓝调 15 部」,下面只露 6 张,剩下的没有任何办法看到;数字本身也只是那 48 部里的,不是整季的。
改动:
- **取全季**:首页一次取回本季全部番剧(季度接口的上限 200,一季大约一百部),药丸上的数字就是这个色调本季的全部部数;不再截到 10 部。主视觉和「本季高分」仍然只取前 5、前 12。
- **一整行**:每个色调排成一整行,用和「今日更新」同一套滑动:手机上直接滑;桌面上用鼠标拖,快速拖会带惯性,最后停在一张卡片上。下面同样有一条细滑动条,可以拖动、点轨道,键盘用 ← → 一张张走;滑动条换成这个色调自己的颜色。卡片改成固定宽度:宽屏 192px(和原来一行 6 张时差不多),平板宽度 160px,手机 124px(和原来一样),不再按列数拉伸。
- **行尾接下一组**:滑到行尾有一块「蓝调就这些 · 下一组 · 紫调 4 部 · 看紫调」,点了直接换到下一个色调(最后一个接回第一个),焦点落到新色调的药丸上。只有一个色调时不显示。
- 切换色调时每一行保留自己滑到的位置。
- **没打开过的色调不加载封面**:七个色调的行都叠在一起排好版,只是看不见。如果都加载,每一行眼前那一屏的封面都会下载,又看不见,还会占着图片服务和浏览器的连接。现在一个色调第一次被选中时才加载它的封面。首页这一块一开始只下载当前色调眼前的几张(实测 7 张,原来是 26 张)。
- 「今日更新」的滑动条样式抽成共用的 `railSlider.module.css`,滑动相关的 hooks 改名为 `useRail.ts`,两处共用;「今日更新」的行为不变。
测试:渲染测试(每个色调的卡片数等于药丸上的数字,不再截到 10;每行的滑动条用色调名命名并指向自己那一行;行尾写出下一组和部数,最后一组接回第一组;只有一个色调时没有行尾;同一时刻只有一个色调对辅助技术可见;没打开过的色调不出现图片);`nextFamily` 单测(下一组、末尾回到第一组、只有一组或找不到时为空);原来检查「今日更新」样式不许藏卡片的测试也扫这一块的样式,并能认出原来那条 `nth-child(n + 7)`;沙箱 e2e 新增一条:当前色调的卡片数等于药丸数字,滑到行尾点「看下一组」会换色调并把焦点交给新药丸。
### 本地库:同一集的其他版本列在详情页里,点开就播那一份
同一集在库里有第二个文件时(比如另一个字幕组的整季,或者根目录里单独下载的一集),导入会把它记成这一集的其他版本,不再另建一张卡。但界面上从来没有地方显示它们:详情页和播放器都只认每一集的主文件,第二份整季文件放在库里,既看不到也播不了。
改动:
- **详情页新增「其他版本」**:放在「未归类」下面,按来源分组。文件夹里的文件按文件夹分,根目录的文件按字幕组分,没有字幕组就用文件名。每个格子的编号和上面这一集的格子一致,播放进度也跟着这一集走。文件夹名太长时换行显示,不截断,因为字幕组通常写在名字最后。
- **点开播放的就是那一份**:跳到播放器时带上这个文件。只有这一集确实有这个文件时才播放它,否则照常播放主文件。上一集、下一集仍按集号切换,播放的是各集的主文件。
- 播放器顶部记下了当前播放的是哪个文件(`data-playing-file`),端到端测试靠它判断。
测试:
- 单元:按来源分组;编号跟着这一集的格子走,连续编号被换算的情况也一样;根目录的文件按字幕组或文件名分组;已不存在的、已被替换的、本身是某一集主文件的都不列出;同一个文件只列一次;输入顺序不影响结果;能按文件找到它所属的那一集。
- 新增沙箱 e2e:在 OPFS 里放一个主文件,另一个文件夹里放第二个版本。断言:详情页列出这个版本,点开后播放器播放的就是它;再点这一集原来的格子,播放的是主文件;没有其他版本的卡不显示这一栏。本地对着 `next dev` 跑过,两条都通过。
- 用一份本地库快照里的真实番剧,在桌面和手机宽度下截图检查。这部番的主文件来自 LoliHouse,另有 ktnbytes 的整季和 GreenTea 的第 1 集。
### 本地库:续作的新一集按匹配到的番剧归卡,不再按标题放进上一季
库里同时有第一季和第二季两张卡时,第二季的新一集如果文件名只写了番剧名(比如「Koori no Jouheki - 16」),会被放进第一季那张卡,记成第 16 集;第一季只有 14 集,这一集落在格子外面。匹配本身是对的:弹弹play 认出的就是第二季,第二季那张卡也已经绑定了这部番。问题在导入时有一条判断:「已经认出是哪部番,就不再按目录和标题找卡」,它看的是弹弹play 自己的番剧编号,而匹配接口从来不返回这个编号,所以这条判断从没生效过,最后是按标题找的卡。第二季那张卡的标题是匹配时取来的「Koori no Jouheki 2nd Season」,和只写了番剧名的文件对不上,对得上的只有第一季。凡是续作的卡,新一集的文件名又只带番剧名,都会这样。
改动:
- **按匹配到的 AniList 编号找卡**:匹配带回了 AniList 编号,而库里正好有一张卡绑定了这个编号,新文件就直接归到这张卡。同一批导入里先建好的卡也算。
- **目录和标题不再盖过匹配结果**:按目录或标题找到的卡如果绑定的是另一个 AniList 编号,就不用它。没有绑定的卡照旧可以接收新文件。
- **匹配结果只在能代表整组文件时才用来归卡**:导入时同一部番的文件会合成一组,只拿其中一个去匹配;标题相同的几个文件夹也会合成一组。所以上面两条只在这一组都来自同一个文件夹、而且里面没有库里已有的文件时才生效,其余情况和以前一样按目录和标题找卡。否则一个新文件的匹配结果会带着别的文件走:「+ 添加文件夹」重新读整个库时,根目录里的「- 16」和第一季的文件夹合成一组,第一季已经入库的 14 集会被一起挪到第二季的卡上。
- **特别篇留在原来的番剧里**:特别篇、OVA 常常在 AniList 上是单独的条目,匹配结果和番剧本身不同。放在一部番的文件夹里时,仍然归到这部番的卡,不因为匹配到别的编号就单独建一张卡(进度推送本来也只推送正片)。
- **合并进别的卡的番剧不参与按编号找卡**:它身上的绑定可能是旧的,进度同步本来就不用它;按它归卡,会把另一部番的新剧集藏进合并后的卡里。
- **一张卡里混着两种编号时也能对上格子**:第二季的卡从「S02E01」开始(存为 1),后来又加进连续编号的「- 16」。以前偏移只能整张卡一起换算,换算不了就一个都不换:16 落在格子外,推送进度时原样发 16,被服务端的范围检查拒绝,这张卡的进度一直同步不上。现在整张卡换算不了时,只换算那些已经超过本季集数、减去前几季的集数后正好落在本季之内的编号;本来就在本季范围内的编号不动;换算后会和卡上已有编号撞上的也不动(两季被放进同一张卡时就会这样)。格子和进度推送用的是同一条规则。
- 已经放错卡的文件不会自动移走,库里已有的文件会留在原来的剧集上。删掉整张卡再扫描也不能把它放对:同一次导入里,续作只写番剧名的文件会和同名的上一季文件夹合成一组,跟着同一个匹配结果走。这是本次之前就有的行为,在上面「标题相同的几个文件夹各自去匹配」一条里修复。
测试:
- 导入:按匹配接口真实的返回形状写用例(弹弹play 编号为 0,只带 AniList 编号):有卡绑定匹配到的编号时归到那张卡,而不是标题对上的卡;标题或目录对上的卡绑定的是别的编号时另建一张;没有绑定的卡照旧接收;编号相同时照旧归入。
- 导入(用内存数据库跑真实的分组、合组和写入,断言每个文件最后在哪张卡上):已入库的文件和新文件合成一组时,已入库的文件不动,不管新文件在别的文件夹还是同一个文件夹;跨文件夹合成的一组全是新文件时,不归到代表文件匹配到的卡;同一批里跨文件夹的一组建卡后,不让后面的文件按它的编号归卡,不会有文件被并成另一集的备用文件;文件夹里的特别篇匹配到别的编号时仍然归这部番;合并进别的卡的番剧的绑定不吸收新文件。这六条在修复前都不通过。
- 编号换算:混着两种编号的卡只换算超出本季的编号,换算后会撞号时不换;格子和进度推送给出同样的编号。原来断言「混编号的卡一个都不换」的用例改成新规则。
- 用真实的解析、导入、匹配客户端和格子代码,在一份本地库快照上重放这次导入:修复前进第一季、落在格子外;修复后进第二季,显示为第 2 集。同一份快照上还重放了「+ 添加文件夹」:第一季的文件都留在第一季的卡上,第二季的卡不变。
### 磁力搜索:按番读取蜜柑计划的完整订阅,补上老番的早年资源
以前蜜柑计划(Mikan Project)只用来按标题搜索:拿番剧的几个标题去搜,搜到什么算什么。蜜柑计划还给每一季番剧单独提供一条 RSS,列出各字幕组为这一季发过的全部资源,早年的也在。现在打开一部番的磁力列表时,如果知道它在蜜柑计划上是哪一页,就直接读这一页的订阅,不再拿标题去搜蜜柑;其他资源站照旧按标题搜索。其他资源站的搜索结果往往翻不到太早的发布,所以越老的番补上的资源越多。
改动:
- **对照表**:新增 `go-api/cmd/mikanmap`,从 [bangumi-data](https://github.com/bangumi-data/bangumi-data)(CC BY 4.0)的 `dist/data.json` 生成 AniList 编号、Bangumi 条目编号到蜜柑计划番剧编号的对照表,嵌入 go-api(`internal/mikanidmap/mikan_map.json`),文件里写明来源、授权和所用的 bangumi-data 版本。一个编号对应蜜柑计划上不止一页的(比如 AniList 把两季算作一部),不收进对照,列进报告;AniList 编号的这类冲突也写进文件,查找时要用。新增每周运行的 `refresh-mikan-map.yml`,和 Bangumi 对照表一样重新生成并开 PR,PR 正文列出冲突、变动和移除的条目。
- **查找**:先按 AniList 编号查,查不到再用这部番已绑定的 Bangumi 条目查。两种情况不用订阅,照旧按标题搜蜜柑:一是 AniList 编号本身有冲突,没有哪一页列全了这部番的资源,用它的 Bangumi 条目只会挑中其中一页;二是用 Bangumi 条目查到的那一页,bangumi-data 归给了另一个 AniList 编号,两边对这个条目是哪部番说法不一。宁可照旧搜索,也不漏掉资源或把别的番的资源列进来。
- **去重**:订阅里的资源和其他来源一起按 infohash 去重,十六进制和 base32 两种写法的磁力链接算同一个种子,也和蜜柑自己的搜索结果去重。同一个种子留哪一份的规则不变:有做种数的优先,其次按来源排序。
- **很长的订阅**:连载多年的番,一页订阅会非常长,只读最前面的一部分;请求的时间用完时还没读完,也保留已经读到的部分。订阅从新到旧排列,舍掉的是最早的发布。
- 查不到蜜柑编号的番,行为和以前完全一样;`?q=` 关键词搜索不变;接口返回的结构不变。
- README 致谢里加上 bangumi-data。
测试:生成器(解析 bangumi-data 样例,AniList 和 Bangumi 两种编号都能查,冲突不进表并列入报告,AniList 冲突写进文件,结果与条目顺序无关、两次生成逐字节相同,生成的文件能被查找代码读回);查找(AniList 命中、Bangumi 兜底、都查不到、Bangumi 查到的页面属于别的 AniList 编号时不用、有冲突的 AniList 编号用自己的 Bangumi 条目也不用);订阅读取(按线上订阅的格式解析,超出上限或读到超时都保留最新的完整条目,截断发生在第一条之前或上游本身断开时报错,上游返回错误状态时报错,无效编号不发请求;关键词搜索不受上限影响,超时照旧报错);聚合(有蜜柑编号时读订阅、不搜蜜柑、其他来源照常搜,没有时和以前一样,同一种子的十六进制和 base32 写法只留一条,缓存键区分蜜柑编号,订阅失败不影响其他来源);接口(从 AniList 编号解析、用 Bangumi 兜底,返回结构不变)。
### 磁力搜索:AnimeTosho 换了订阅地址,重新接上
AnimeTosho 的订阅原来放在 `feed.animetosho.org`,这个域名已经解析不到了,所以磁力列表里一直没有 AnimeTosho 的结果,按 AniDB 编号拉取整部番的那条订阅也一起失效了。同样的 JSON 现在放在主站的 `/feed` 下面,改成读 `https://animetosho.org/feed/json`;请求参数、返回格式和解析都不变。AnimeTosho 是唯一给出做种数的来源,接回来以后,同一个种子会优先留下带做种数的那一份。
测试:两条地址(按标题搜索、按 AniDB 编号)都改为断言完整的新地址。
### 播放器:可以切换字幕轨道,重新排版
以前每一集只能看到一条字幕:有外挂字幕就用外挂的,没有就用 MKV 里第一条 ASS 轨道,其余轨道都读不到。一个文件里同时封了简体、繁体和英文时,只能看到排在最前面的那条,也没有地方换。
**字幕**
- 播放器右下角新增「字幕」按钮。面板里列出这一集的所有字幕:MKV 内封的每一条文字轨道、视频旁边的外挂字幕文件、自己加载的字幕文件,以及「关闭字幕」。每条都标出格式(ASS / SRT)、来源(内封 / 外挂 / 本地),以及文件里写的默认、强制、双语标记。面板在全屏时也能用。
- 默认选哪条:先看上次在面板里选过的语言(选过「关闭」就保持关闭),再看网站当前的语言(简体界面优先简体,繁体界面优先繁体),最后才看文件自带的默认标记。语言从轨道名、语言标签和外挂文件名(如 `.sc.ass`、`.tc.ass`、`[简体]`)判断。只有标志、歌词的强制轨道不会被自动选上。
- 时间可以调:每次 ±0.5 秒,没有范围限制,对 ASS 和纯文本字幕都有效。只作用于当前这一集,换集后回到 0。
- 从「我的库」播放时,现在也会读取视频旁边和下一层子文件夹(如 `Subs/`、`SC/`)里的外挂字幕。以前库里播放的视频从来不带外挂字幕。拖文件夹播放时,同一集的多个外挂字幕也都会列出来,不再只取一个。
- MKV 里附带的字体会交给 ASS 渲染器使用,特效字幕按发布组指定的字体显示,不再依赖本机字体权限;以前没有授权本机字体时,中文 ASS 字幕可能显示成方框。
- 图形字幕(PGS 等)会列出来并标明「暂不支持」,不会被选中。
- 自己加载的 ASS 字幕现在也走 ASS 渲染器,保留样式。以前它只进纯文本层,而且当内封 ASS 正在显示时会被挡住看不到。
**读取更快、更省内存**
- 内封字幕改为分块读取,不再一次把整个视频读进内存。在一个 800 MB 的测试文件上:浏览器里点开播放后约 0.3 秒列出轨道并显示开头的字幕,约 2.3 秒读完全部轨道;读取过程中占用的内存从约等于文件大小降到几十 MB。
- 先读完开头一段就显示字幕,后面的边读边补,不用等整个文件扫描完。
- 切换轨道、补全字幕、调时间都在同一个 ASS 渲染器上完成,不再每次销毁重建,切换时不会闪空。
**页面排版**
- 播放器按窗口高度缩放,控制栏和下方的选集按钮不用滚动就能看到。以前播放器比窗口还高,要往下滚才看得到控制栏。
- 去掉重复显示集数的状态条和角标;标题栏一行显示番名、集数和本集标题,「该集暂无弹幕」也移到标题栏。
- 字幕大小、高度和「加载字幕文件」从设置菜单移到字幕面板里,设置菜单只保留倍速和弹幕开关。
- 弹幕热点图直接贴在进度条上。以前两者之间空了约 11.5px(进度条上方的留白加上进度条轨道的一半),看起来像两条分开的东西;悬停进度条、全屏时也都对齐。
测试:新增 MKV 解析(手工构造的字节,覆盖压缩轨道、字体附件、未知长度的 Cluster、分块边界、提前结束和中断,另有一个 ffmpeg 生成的真实文件)、语言判断和默认轨道选择、外挂字幕匹配、库文件夹读取、字幕状态和时间平移的单元测试。
### 本地库不再把同一文件的第二份、移动过的文件当成新剧集
同一个视频在硬盘上有两份时(比如整理文件夹时复制了而不是移动),库里这一集只有一条文件记录,只能记一个路径。每次自动扫描都把没记下的那一份当成新文件导入,并把记录改成它的路径,下一次扫描再换回来,循环不止:每次回到页面都提示「发现 1 个新剧集」,这部番被顶进「新加入」,每次还要问一次弹弹play。移动过的文件也会被当成新剧集提示一次。
改动:
- **文件记录会记下副本**:同样的内容在别处被找到、而记录里的路径还在时,记录保留原路径(播放用它),新路径记为副本,扫描认得它,不再反复导入。原路径不在了(文件被移动、改名,或那一份被删掉),就由找到的路径接管,所属剧集不变。
- **扫描先看完所有文件夹再导入,并按文件夹判断文件在不在**:副本和原文件可能在不同的库文件夹里。判断记录里的文件还在不在,看的是它所在的那个文件夹,而不只是相对路径:另一个文件夹里恰好有同样的相对路径,不算原文件还在。没能读取的文件夹(硬盘拔掉、没有授权)不当作文件已经没了,只有副本会暂时顶上。
- **在库文件夹之间移动也会重新挂接**:比如把整个番剧文件夹从「下载」移到「动画」、里面的相对路径不变,以前记录会一直指向原来的文件夹,播放时找不到文件;现在确认原文件夹里已经没有这个文件后,就改指向新的位置。
- **两个文件夹里同名、内容不同的文件不再互相顶替**:以前一个会把另一个标记成「已被替换」,下一次扫描再反过来,每次都要重新读文件;现在只在同一个文件夹内判断替换。
- 副本路径上刚被写入的文件也要等过静默期再读取,不会把下载了一半的内容当成一个文件。
- **库里已有的文件不再重新匹配**:先认出这是库里已有的内容,直接留在原来的剧集上,不查匹配缓存,也不调用弹弹play。
- **只有真正的新剧集才提示**:移动、改名、副本都不计入「发现 N 个新剧集」,也不把番剧顶进「新加入」;同一个新文件的两份只算一集。播放页在文件换了路径时同样会刷新,不会还用旧路径。
- 同一批导入里有两份相同内容时只导入一份,另一份记为副本,不再给同一集建两条剧集记录。
- 未匹配的文件也会记副本,不再来回改写路径;重新导入不会清掉它已有的匹配。
测试:
- 用真实的扫描、枚举和导入代码对着内存里的文件夹复现:两份副本连扫三轮,修复前每轮都报 1 个新剧集,修复后第一轮记下副本、之后不再导入;删掉记录里那一份,另一份接管;移动文件不提示、不调用弹弹play。
- 多个库文件夹:在一个文件夹里移动文件、而另一个文件夹有同样的相对路径时,记录始终指向能打开的文件;整个文件夹移到另一个库文件夹后重新挂接;同名不同内容的两个文件扫描多轮都不再互相标记。
- 新增沙箱 e2e:在 OPFS 里放两份相同的文件,反复挂载扫描,断言记录不再变化、没有提示、不调用匹配接口;删掉一份、移动一个文件之后也一样。
### 声优只保留日配,从 AniList 下载时就过滤
上一版补全演职员时,库里存了每个角色的日配、中配、韩配;一个角色这三种都没有时,还会把其他语言的声优当成主声优存进去。现在只要日语声优,并且在从 AniList 下载时就过滤,而不是下载下来再在页面上藏起来。
改动:
- **下载**:读详情和演职员补全共用同一段角色查询,现在请求 `voiceActorRoles(language: JAPANESE, ...)`,AniList 只返回日语声优。备注(「童年」「第 553 集起」这类)和配音团体照常保留。热门番的冷启动详情请求,从 AniList 拿到的数据因此小了不少,因为不再带回每个角色好几种语言的配音。
- **写入**:写库前再按语言过滤一次,以防 AniList 忽略这个参数。主声优是第一个没有备注的日语声优,没有就取第一个日语声优,再没有就留空,不再退而使用其他语言。番剧的出品国也不再决定用哪种语言:中国、韩国作品有日配就显示日配,没有就不显示声优。
- **角色标签页接口**:只列出、只统计日语声优。旧链接里的 `lang=zh`、`lang=ko` 仍然接受,按 `ja` 回答,不会报 400。所有返回的 JSON 结构不变,`/api/anime/:id` 的结构也不变。
- **前端**:只有一种配音语言时,角色标签页不再画只有一个按钮的配音切换。卡片上声优下方仍注明「日配」。
- **迁移 0050(只改数据)**:
- 主声优不是日语的角色,换成这个角色最合适的日语声优:先选没有备注的,再按原来的顺序。没有日语声优的,清空声优字段。
- 删除所有非日语的配音行,并把每个角色的配音重新编号,主声优排第一。
- 只因这些配音才出现在库里的人(AniList 人物资料),连同照片一起删除。他们的照片不再保存,图片镜像也不再缓存。仍有日语配音、制作职务或读者已通过的修改的人,资料保留。迁移前就没有被任何作品引用的人物资料不受影响。已经缓存在镜像里的这些照片,不在这次迁移里清理。
- 回滚不恢复任何数据。之后每部番的下一次详情刷新或演职员补全,会按当时的规则重新下载。
- 用与生产同等规模的模拟数据测过,耗时不到一秒(在 WebAssembly 版 PostgreSQL 上测,比服务器慢)。迁移期间,对这几张表的写入要等它结束,读取不受影响。
- 部署时先迁移、再构建,构建期间旧程序还会按旧规则写回中配、韩配,并可能把对应的人物资料抓回来。新程序启动后应立即再手工执行一次这个迁移,它可以重复执行。
- 只配过日语以外的配音、又没有制作职务的人,人物页随之不再存在。
测试:
- 查询文本必须请求 `voiceActorRoles(language: JAPANESE`。AniList 仍返回其他语言时一律不存,只有其他语言配音的角色没有声优。中国作品有日配时,以日配为主声优(读详情和补全任务两条写入路径都覆盖)。
- 角色接口带 `lang=zh`、`lang=ko` 时,回答与 `ja` 逐字节相同,只列出、只统计日语。
- 新增 `TestMigration0050_PG`,覆盖迁移的四步、该保留的人、`image_refs` 里被删掉的照片,以及再执行一遍不改变任何数据。
### 运行时生成的页面不再写进磁盘
next-app 默认把运行时生成的每个 ISR 页面(HTML、RSC 数据和分段数据)写进容器里的 `.next/server/app`,数据缓存写进 `.next/cache`。这两处都没有上限,也从不清理,只有容器被重建时才清空。
#217 之后,每部番的几个标签页、每个角色页和人物页都按三种语言被爬虫抓取,这些文件涨得飞快:一次部署后不到一天,就能把服务器的磁盘写满,而数据库也在这块盘上。图片预热任务的磁盘保护因此触发了一次。
- **只放内存**:`experimental.isrFlushToDisk` 设为 `false`,运行时生成的页面只放在内存缓存里。内存缓存从默认的 50 MB 调到 128 MB,next-app 自己只用了 1 GB 上限里的一小部分。
- **构建时预生成的页面不受影响**:照常从镜像里读,这个开关只停掉写磁盘,以及数据缓存从磁盘的读取。
- **代价**:被挤出内存的页面,下次请求时要现场渲染,不能先返回旧版本。这些页面每 60 秒就重新验证一次,CDN 也缓存了它们,影响很小。
- **新增测试**:固定这两个配置,以后谁把它们改回去,测试就会失败。
### AniList 返回空答案时,不再当成完整答案写进库
AniList 有时会返回一个不报错、内容却是空的答案,比如 `Page` 为 null:它会被解码成空列表,不会报错。有三处代码把这种答案当了真,把一次上游故障变成了永久的数据损失:
- **评分和 facts 定时任务**:一批 50 个 id,答案里一个都没有时,旧代码把 50 个全部记成「已查过」。老番的评分、完结番的日期/时长/原作类型只在「从没查过」时才读,这一批就再也不会被读。
- 现在一批不少于 10 个 id、而答案一个都没提到时,按请求失败处理:不记「已查过」,也不写任何一行,下一轮再试。规则和数字都与资料定时任务已有的防护相同。
- 答案里缺了部分 id 时,缺的那些仍照旧记为「已查过」(AniList 删除或合并了它们);不到 10 个 id 的尾批返回空答案,也照旧相信。
- **演职员补全任务**:拿到的所有页都为空时,旧代码把它当成完整名单写入,删掉这部番已存的全部角色或职员,并记下「没有下一页」,这部番从此不再被补全。现在按失败处理:不写入,已存的行和标记保留,一天后重试,本轮结束。
- **读详情刷新**:角色或职员的第一页为空、又说没有下一页时,旧代码会删掉这部番已存的全部角色(包括补全任务存的第 26 条以后)和声优,并把它移出补全任务。现在空列表不写入,也不更新「是否有下一页」;另一份非空的列表照常写入。AniList 上确实一个角色都没有的番,库里本来就没有可删的行,不受影响。
测试:
- 新增单元测试覆盖 `Page: null`、空列表、只含没请求过的 id、空的「最后一页」,以及防护的边界(部分缺席仍记为已查、不到 10 个 id 的尾批仍相信空答案)。把防护去掉后,这些测试全部失败。
- 扩展 `TestDetail_RefreshKeepsTheSweepsCredits_PG`:补全任务存满之后,再来一次空的读详情刷新,所有行和标记都保留。
### 部署不再每次都重启数据库
每次部署时,即使一个服务的代码完全没变、镜像层全部命中缓存,构建出来的镜像 ID 也会变:BuildKit 会给每个镜像附一份带构建时间的来源证明。于是 `up -d` 把所有自己构建的服务都重建一遍,postgres 也在其中,数据库每次部署都会重启。
更糟的是 compose 先重建 postgres、后重建 go-api。go-api 关机、想把正在跑的后台任务记录下来时,数据库已经停了,图片预热任务因此又在任务表里卡成「运行中」一小时,上一条的修复在部署时等于没用上。
部署脚本现在关掉这份来源证明(`BUILDX_NO_DEFAULT_ATTESTATIONS=1`)。这些镜像只在本机使用、不推送到任何仓库,来源证明本来就用不上。在服务器上实测:同一服务连续构建两次,镜像 ID 相同,compose 也就不会再重建它。改动后的第一次部署会因为镜像格式变化,把各服务再重建一次;之后没改动的服务就保持原样运行。
### 其余直接从浏览器加载 AniList 的图片也改走镜像
镜像上线后实测,首页、详情页、角色和人物页等主要页面已经全部走镜像。但还有一些地方由浏览器直接向 AniList 要整张原图:
- 公开个人主页「正在追」的封面和顶部背景;
- landing 的预览卡;
- 详情页社区标签里的动态封面;
- 没上传照片的成员的头像。他们的头像会用所选番剧的封面代替,出现在导航栏、评论、「谁在看」、关注列表和社区里;
- 个人资料页、设置页、会员卡的卡面,以及后台的编辑审核。
改动:
- **新增 `lib/images/anilistImg.ts`**:只对镜像提供的那几类 AniList 图片,给出经压缩服务、以镜像为图源的地址。
- 普通 `<img>` 用 `src` 加 `srcSet`。
- CSS 背景,以及只能放一个地址的地方,用 2 倍屏那一档。
- 成员自己上传的照片、本地预览、Bangumi 和弹弹play 的图、站内默认图,一律原样不动。
- **`FallbackImg` 统一处理**:头像回退成番剧封面时,按头像尺寸取压缩图,不再加载整张原图。
- **各处按显示尺寸取图**:个人主页背景 1920,下拉菜单头图 244,设置页预览 290,会员卡 320;头像和缩略图按各自的尺寸。
- **新增守门测试**:凡是渲染普通 `<img>`、或用 `cssUrl()` 拼背景的文件,都必须经过这两个函数。不经过的要写进白名单并注明理由,目前只有首页大图和照片裁剪两处。
- **这次不动的**:媒体库和播放器的图片来源可能是任意站点,仍按原样加载。
测试:
- 前端 `bun test` 2981 条全部通过。
- 新增工具函数的来源映射测试和 `FallbackImg` 的渲染测试。
- 守门测试按语法树扫描。把社区动态改回直连地址时,它会报出那个文件。
### 部署不再让后台任务卡住一小时
go-api 关机时只给后台任务 10 秒收尾,过了时间进程就退出。还在跑的任务在任务表里一直停在「运行中」,要等一小时后被救回。在那之前,同一种任务每一轮都被当成重复而跳过,进程启动时立即跑的那一轮也一样。图片预热一轮要跑大半小时,所以几乎每次部署都会让它停一小时。
现在关机分两步:先给 5 秒让任务正常结束,还没结束的再取消,等它们返回并记录下来(再给 5 秒),整个过程仍在 30 秒的停机宽限之内。预热任务被取消后,做完手头这一个请求就返回,下一个进程启动后立即接着跑。
测试:用真实的 river 和 Postgres,一个一直不结束的任务在关机后记录为已完成,不会停在「运行中」;把取消这一步去掉,测试就失败,结果正是线上那样停在「运行中」。
### AniList 的图片在自己服务器上存一份
页面上的封面、横幅、角色和声优/职员头像都是 AniList 图床上的图。压缩服务每压一张新图,都要回 AniList 取原图:AniList 一慢、一限我们的速或者出故障,新图就出不来。现在原图在服务器上存一份,页面经由压缩服务从这份取。
- **原图存在 nginx 里**:新增一块只放 AniList 原图的缓存区(`anilist_orig`,上限 30 GB),放在已有的 `nginx-cache` 卷上,部署和重建容器都不丢。条目不会因为没人访问而被清掉。
- **对外只读入口 `/img/anilist/<路径>`**
- 存了的原图直接返回;没存的转给 AniList 取,但不存。
- AniList 出错、连不上、返回 403 或 429 时,已存的原图照样返回,过了 30 天复查期的也一样。
- 只接受站上会用到的几类图:番剧和漫画的封面、横幅、角色头像、声优/职员头像。文件名只能由字母、数字和 `_.-` 组成,扩展名限 jpg、jpeg、png、gif、webp。其余路径(AniList 的用户头像、带 `..` 的路径等)一律 404,不会去请求 AniList。
- 访客的请求头不转给 AniList;AniList 的 Cookie、HSTS 等响应头,以及它的 CDN 自己的记录(`Age`、`CF-Ray`、存储后端的 `x-bz-*`)也不传给访客。
- CDN 能缓存多久由我们定:原图 30 天,AniList 的错误答复一律不让 CDN 缓存。
- **只有预热任务能往里存**:nginx 另开一个只在 compose 内网可达的端口 8090(不对外发布),只有经它请求的原图才会存下。go-api 新增每小时一次的图片预热任务:
- 从新视图 `image_refs`(数据库里存 AniList 图片地址的 12 列)取出所有地址,逐个请求这个内网入口,最快每秒 10 个,每轮最多 3 万个、50 分钟。
- 每个地址的结果记在新表 `image_manager`:
- 已存好;
- AniList 上没有(30 天后再试);
- 被限速(按 `Retry-After` 等,最长 24 小时,期间整个任务暂停);
- 请求失败(1 小时后再试,接着失败就按 2、4、8 小时……加倍,最长 30 天)。
- 失败的地址排在每轮最后,持续失败的图挡不住新图和其他图。
- 文件名不带内容哈希的旧地址,存好满 30 天会再请求一次,nginx 用条件请求向 AniList 复查,图没变就不重新下载。带哈希的地址内容永远不变(图一改,地址就跟着变),不再复查。
- 遇到 429 立刻停下这一轮;连续 3 次 5xx、超时或 403 也停,并报到 Sentry。主机磁盘剩余不到 10 GB 时整轮跳过,同样报到 Sentry。
- 内网入口对它不取的路径回 403 而不是 404。地址或路径配错时,任务会停下报警,不会把整批图当成「AniList 上没有」搁置一个月。连不上入口时只记 WARN(部署时的正常现象),连续 3 轮都连不上才报到 Sentry。
- 用自己的队列和一个 worker,不占评分、资料那条队列;在后台暂停这条队列就能停掉它。
- `IMAGE_WARM_BASE_URL` 为空时什么都不做,本地开发和 CI 都是这样。
- **页面改用自己的地址**:构建时设置了 `NEXT_PUBLIC_IMAGE_MIRROR_BASE`(docker-compose 默认 `https://animegoclub.com/img/anilist/`),页面上的 AniList 图片就改由压缩服务从 `/img/anilist/` 取原图;不设或为空时一切照旧。
- 压缩服务经公网域名取原图:Next 拒绝解析到内网地址的图片源,这个限制不打开。
- 结构化数据和分享卡片(JSON-LD、OG)里的图片仍是 AniList 地址。
- 在 `.env.production` 里把 `NEXT_PUBLIC_IMAGE_MIRROR_BASE` 和 `IMAGE_WARM_BASE_URL` 写成空值,下次部署就退回 AniList 并停掉预热。
- **切换时不用重压**:`/_next/image` 的 nginx 缓存键里,镜像地址会改写成同一张图的 AniList 地址,其余请求的键和以前逐字节相同。已经压好的图,在部署和切换前后都继续命中。只有由白名单字符和编码斜杠组成的路径才会改写,带其他转义的地址保留自己的键。
- **三份白名单绑在一起**:nginx、预热任务、前端各有一份路径白名单,前端和 go-api 的测试都直接读 nginx 配置逐字比对,任何一份改了而另两份没跟上,测试就失败。
- 部署脚本在状态里打印磁盘余量。冒烟检查多四条:
- 经 `/img/anilist/` 取一张原图;
- 用镜像地址请求一次 `/_next/image`;
- 在 next-image 容器里按 Next 的方式直接取一次镜像原图(前面没有任何缓存能替它作答,经 CDN 的这条路一断就会显示出来);
- 从 go-api 容器按 `IMAGE_WARM_BASE_URL` 请求一次内网入口。
测试:
- **nginx**:测试脚本收进仓库(`scripts/nginx-test/run.sh`),CI 新增 `nginx (config behaviour)` 任务,每个 PR 都跑。用真实的 nginx 镜像,配假的 next-image、next-app 和 AniList(证书由测试用的根证书经两级中间证书签发),151 条断言全部通过。
- 原有的路由、缓存、请求形状、并发上限、私有规则断言全部保留。
- 新旧两种地址共用一份压好的图,缓存文件里的键和改动前的格式一致;不同路径、不同宽度不会共用。
- 白名单两个方向:每类图都取得到;用户头像、错误扩展名、各种编码的 `..`、反斜杠、POST 都被拒绝,一次都没请求 AniList。
- 对外入口只读不存,预热入口存下后对外入口命中。AniList 返回 503、429、403 或连不上时,已存的原图照样返回,而预热入口如实返回错误。
- 过期的原图由预热请求用条件请求复查:304 续期,图变了才替换。
- 证书:签发者不受信任、名字不符的证书都会被拒绝。
- **go-api**:预热任务的数据库测试 21 项,覆盖:
- 视图漏读图片列时报错(新增列就能触发);
- 存好;满 30 天只复查不带哈希的地址;AniList 上没有、30 天后重试;
- 失败记录下来并排到最后(3 个持续失败的地址卡不住任务)、退避加倍并封顶 30 天、任何非失败的答复把失败计数清零;
- 429 立即停并挡住后续轮次,连续 3 次 5xx 或 403 停,两次 5xx 后成功不停;
- 连不上只记 WARN,第 3 轮报 ERROR,有任何答复就重新计数;
- 磁盘保护;开关为空时一次请求都不发;迁移回滚再重放。
单元测试另外覆盖状态码分类、`Retry-After` 的两种写法和上下限,以及正文传到一半断开算作故障。全部测试和集成测试通过。
- **前端**:`bun test` 2947 条全部通过,覆盖以下几方面:
- 地址映射表;
- `remotePatterns` 和可压缩判断的一致性(用 Next 自己的匹配函数);
- 每个直接用 next/image 的文件都必须经过镜像改写的守门测试;
- 开关打开时,详情页、卡片、角色页、人物页、首页大图在服务端渲染出镜像地址,两种地址都不抛错。
开关打开和关闭的生产构建都通过;打开时,服务端和浏览器端的代码都内联了同一个地址。
- **线上**:Playwright 线上测试新增「详情页每张图都加载出来」:先滚动页面触发懒加载,仍未请求的改为立即加载,再检查每张图都有像素。对当前线上连跑 3 次通过。
### CI 不再对线上跑 Lighthouse
`Lighthouse against live prod` 在每个 PR 和每次推送 main 时,都要把线上首页、详情页、季度页和 welcome 页各跑三遍。它测的是线上已经部署的版本,跟 PR 里的代码无关,而且从 2026-10-08 起每次都失败,不再提供有用的信号。workflow(`.github/workflows/lighthouse.yml`)和它的配置 `lighthouserc.json` 一起删除。线上 Playwright 保留,部署后仍当冒烟检查用。
### 图片的并发上限改成先排队,不公开的 nginx 规则有了存放处
上一条(next-image 单独容器)上线后,nginx 到 next-image 的上限是 64。超出就立刻返回 503,而图片失败不会重试,卡片就一直空着。有人成批请求图片时,这 64 个名额会被占满几十秒,这段时间里真实读者的新图也被拒了。
- **上限改成 256**(`max_conns`)。
- 在这之下,请求在 next-image 里排队等 CPU,晚一点出来,而不是直接失败。这个上限只防止成千上万的并发把 next-image 的内存撑爆。
- CPU 仍然由容器的 1.5 核上限隔开,页面不受影响。
- **读超时放宽**:`/_next/image` 的读超时从 30 秒放宽到 60 秒,排队的请求不会在半路被切成 504。
- **新增 `nginx/private/`**:只存在服务器上,git 忽略这个目录,docker-compose 把它挂进 nginx。
- `http-*.conf` 在 http 层被 include,`next-image-*.conf` 在 `location /_next/image` 里被 include。目录为空或不存在时,什么都不加。
- 临时拦截某类流量的规则放在这里,不必写进公开仓库。
测试:nginx 配真实容器和假上游,28 条断言全部通过。
- 300 个冷图同时请求时,256 个放行,上游同时处理的正好 256 个。
- 另外 44 个在 TLS 建立后 0.5 秒内得到带 `no-store` 的 503。
- 一页 48 张冷图照常全部 200。
- 上游 40 秒才返回的图仍然是 200。
- 私有规则挂载后生效,不影响别的请求;目录为空或根本没挂载时,配置照样通过。
### 图片压缩搬进单独的容器,抓图不会再拖垮页面
页面渲染和图片压缩原来在同一个 next-app 进程里。缓存里没有的图,每张都要现场下载、现场压成 AVIF,而谁都能请求站上的任何一张图。有人成批抓角色头像时,压缩占满了 next-app 的 CPU,详情页、角色页、人物页渲染超时,nginx 返回维护页;在首页点卡片,看起来就是「点了没反应」。
- **单独的容器**:新增 `next-image`(`docker-compose.yml`),和 next-app 用同一个镜像,不重复构建;nginx 只把 `/_next/image` 发给它。
- 它有自己的 CPU 上限(1.5 核)和内存上限(1 GB)。冷图再多,也只会让图片变慢,页面渲染留着其余的 CPU。
- 它不读 `.env.production`,只拿运行必需的几个变量和 `SENTRY_DSN`。它处理的是外部来的图片,不需要应用的密钥。
- 图片的磁盘缓存卷 `next-image-cache` 从 next-app 挪到它身上。
- **只接受页面生成的那一种请求**:next/image 生成的地址只有 `url`、`w`、`q` 三个参数,按这个顺序、全小写。别的形状一律 400,既不进缓存,也到不了压缩服务。
- **只限制要压缩的请求**:nginx 到 next-image 的上游连接上限是 64(`max_conns`),能盖住一页冷图同时请求的量(角色标签首屏就有 48 张头像)。
- 只有缓存答不了的请求才会到上游,所以已经缓存的图不受这个上限影响。
- 超出上限的请求立刻得到一个简短的 503,不排队,不显示维护页,带 `Cache-Control: no-store`,不会被边缘缓存住。
- **缓存装得下全站**:nginx 的图片缓存从 1 GB 扩到 8 GB,条目保留 30 天。
- 过期的条目只由一个请求去重新压缩,其余请求先拿旧图。
- 缓存目录挂在新卷 `nginx-cache` 上,nginx 容器重建也不丢。
- 缓存键是整条查询串加图片格式。查询串已经被上面那条规则限定成唯一的形状,同一张图只会有一个键。
- nginx 的内存上限从 256 MB 调到 512 MB,给缓存索引留地方。
- **只接受页面真正用到的宽度和质量**:每张图能被要求压出的版本从 90 种降到 30 种。直接请求不在列表里的宽度或质量返回 400。
- `images.deviceSizes` 改为 640、828、1080、1200、1920,`imageSizes` 改为 64、96、128、256、384,`qualities` 只留 85。
- 去掉 3840 和 2048:它们比任何原图都宽,Next 不放大,压出来和 1920 那张一模一样,却各占一份缓存。
- 去掉 32、48、750:用量很少,由下一档接替。
- 去掉质量 75:没有页面在用。传 75 的地方,组件会自动改成 85。
- 部署脚本的冒烟检查多一条 `/_next/image`。
测试:
- **nginx 新配置**:在真实的 nginx 容器里,配两个假的上游,25 条断言全部通过。
- 路由:图片请求只到 next-image,页面只到 next-app。
- 缓存:同一张图第二次请求不再到上游;只换图片格式时才多一次上游请求。
- 请求形状:参数名大写、顺序不对、多塞参数、`url` 重复、缺参数、`url` 为空都返回 400,而且一个都没到上游;正常形状照常 200。
- 并发:一页 48 张冷图同时请求,全部 200;90 张同时请求,64 张放行,26 张在 0.5 秒内得到带 `no-store` 的 503,上游同时处理的从没超过 64。
- 同一时刻请求已经缓存的图,0.02 秒返回 200。
- **线上地址**:从线上 11 个页面抓出 753 个不同的图片地址,新规则没有一个误拒。
- **Next 配置**:在开发服务器上验证,保留的宽度返回 200,去掉的宽度和质量 75 返回 400。
- **容器启动**:用当前生产镜像、只给 next-image 的那几个变量启动,健康检查通过,AniList 图和本地图都能压成 AVIF。
- `docker compose config` 解析通过;前端单测全部通过。
### 角色简介:中文页先显示 Bangumi 的简介,没有才显示 AniList 的英文
通过的画布上,角色页的简介是 Bangumi 的那一段,页面上一直是 AniList 的英文。现在角色在 Bangumi 上的简介也收进来了,中文读者先看到它;Bangumi 的简介大多是中文或日文。
- **数据**:离线工具 `cmd/bgmnames` 写角色中文名的时候,顺带把这个角色在 Bangumi 数据包里的简介写进 `bgm_character_map.summary`(迁移 0048,只加一个可空列)。人物的简介不读,人物页不显示简介。简介变了、新加了或删了,都算这一行变了,每周重跑会更新。预览报告多一项「带简介的角色数」。
- **写入前规整成角色页已有的写法**,和 AniList 的简介、读者修改的简介用同一个渲染器、同一个编辑框:
- Bangumi 的剧透 `[mask]…[/mask]` 变成 `~!…!~`,没闭合的一直剧透到结尾,和 AniList 的一样;
- 链接只留文字,图片整段去掉;
- 粗体、字号、颜色、居中、引用这些排版标签去掉、文字保留,不是 BBCode 的方括号(比如 `[CV]`)照原样留着;
- 换行统一成 `\n`。
- **接口**:`/api/characters/:id` 多一个 `bangumiDescription`,和 `profile` 并列,所以资料 sweep 还没拉到的角色也有。已采纳的简介修改(改写或清空)优先,这时 `bangumiDescription` 为空,三种语言都显示修改后的那份。
- **页面**:中文和繁体页先用 Bangumi 的,没有才用 AniList 的;英文页反过来。
- 用了 Bangumi 的那份时,下面写「简介来自 Bangumi」,链到 bgm.tv 的角色页,样式和作品页简介的署名一样。
- 按文字判断语种,标在 `lang` 上:有假名是日文,用日文字体;只有汉字是中文;拉丁字母更多是英文。
- 编辑状态里简介的初始内容就是页面显示的那份,没改就不算改动。
- **部署后**要在生产上重跑一次 `cmd/bgmnames`(先预览,再 `-apply`),简介才会有。
测试:Go 端,规整规则逐条(换行、剧透含跨行和没闭合、大小写、两种链接、排版标签、图片、非 BBCode 的方括号、空行、全是标签);读数据包时角色读简介、人物不读;比对时简介变了、新加、删除都算更新,没变不动;真 Postgres 上写入、重跑不变、改回为空;迁移 0048 在锁住演职员表、资料表、覆盖层表时能执行,已有的行保留,升、降、再升;接口返回 `bangumiDescription`,没有 AniList 资料时也有,修改(改写、清空)让它为空,空白当作没有。前端,按语言取简介和回退、只有标记没有文字不算、语种判断(英文里夹日文名仍是英文);角色页中文页显示 Bangumi 简介、`lang="ja"`、剧透折叠、署名链到 bgm.tv,英文页显示 AniList 的、不署名,没有 AniList 资料时中文页照样有简介;编辑状态的初始简介按语言取,改了才算改动。Playwright:只有 Bangumi 简介的角色在中文页显示日文简介、剧透可展开、署名链接正确,英文页回退到它;有 AniList 简介、没有 Bangumi 简介的角色显示英文、不署名。
### 详情页分成三个标签:概览、角色、制作(角色和制作人员终于能看全)
详情页原来只有一页,角色列 8 位、制作人员列 10 位;演职员补全之后,库里一部番最多存 400 个角色、400 条制作署名,页面上却看不到。现在详情页分成标签:概览留在原地址 `/anime/:id`,角色在 `/anime/:id/characters`,制作在 `/anime/:id/staff`。第四个标签「社区」在 `/anime/:id/social`,见下一条。标签栏是一张列表,加一个标签只是加一行。
- **每个标签是一个独立路由**:可以收藏、分享、被收录,标题、描述、canonical、hreflang 各自生成。三页的渲染方式和概览完全一样:ISR(60 秒),构建时预渲染热门番,其余在第一次访问时生成并缓存;渲染时不读 cookie 和请求头,照样走 Cloudflare 边缘缓存。CI 的 ISR 闸门(`assert-isr.mjs`)加上了这两个路由,沙箱 e2e 的预热也加上了它们。
- **库里没有的番是真 404**:角色和制作两页先问只读库的数量接口,库里没有就直接 `notFound()`,不再去请求可能回源 AniList 的 `/api/anime/:id`;页面上方没有 `loading.tsx`,状态码照样是 404。id 不是正整数(`0`、`abc`)时三页都直接 404,不发任何请求。
- **详情和列表对得上**:列表页先读 `/api/anime/:id`,再带着它的 `cachedAt` 读数量和列表。详情刷新之后渲染的页面,不会配上刷新之前缓存的列表。
- **首屏和标签栏共用**:首屏(横幅、封面、标题、分数、操作按钮)从概览页拆成共享组件,三个标签都用;标签栏在首屏下面,写着「角色 N」「制作 N」。手机上,角色和制作两个标签的首屏收成一行(封面缩略图加标题),列表在第一屏就能看到;概览和桌面端保持完整首屏。切换标签不跳回页顶;在手机上从标签栏切换时,标签栏会回到视野里,焦点落在选中的标签上。标签栏不做定位:登录后的评分弹层向下展开时会伸进标签栏,定位过的标签栏会盖住弹层的下半截,点「10」会点进「角色」。
- **概览**:线上已有的区块样子不变。角色区仍是 8 位,标题右边加「全部 N 位角色」(手机上是列表下方的整行按钮);制作区仍是 10 位,加「全部 N 位」。这一页的小字——字段名、计数、集号、角色和职务的小标签、评分标签、简介来源——从 30% 白提到 55% 白,只在详情页里生效,全站的 `--text-tertiary` 不变。
- **角色标签**:按定位筛选(全部 / 主角 / 配角 / 客串,各带数量,数量跟着搜索变);配音语言切换(日配 / 中配 / 韩配,只显示这部番有的语言,国漫默认中配);搜索角色名和当前语言的声优名(忽略大小写、全半角、空格和中点,「種﨑」也能搜到「種崎」);卡片左半是角色图、名字、另一个名字和定位,右半是声优名和照片,下面一行是「日配」,有童年配音时是「童年 · 某某」;最后是「再显示 N 位」。名字沿用站内的取名顺序(中文界面:中文名 → 日文名 → 英文名)。服务端渲染第一页(24 位),筛选、搜索、翻页在浏览器里请求接口:慢的回答不会盖住新的选择,翻页途中换了筛选也一样;追加的一页里和已显示重复的角色会去掉;请求失败时显示「没能加载,请重试」和重试按钮。手机上没有照片的声优不再多出一个 64×96 的空框。
- **制作标签**:按部门分组——原作、监督与演出、系列构成 / 脚本、人设与设定、作画、原画与动画、美术、色彩、摄影与 CG、剪辑、音乐、音响、制作、其他——每组带人数,上面是部门筛选和搜索(人名或职务)。每人一行:头像、日文名、英文名,以及这个人在这个部门的全部职务(中文职务名沿用 `staffRoleLabel`)。40 人以上的部门(比如原画)只列名字。「全部」视图里每组先显示 9 人(只列名字的组 36 人),其余点「展开全部 N 位」。
- **键盘和读屏**:标签栏是带 `aria-current` 的链接导航;筛选和搜索之后礼貌播报「共 N 位」;从键盘按「再显示」后,焦点移到新加的第一张卡片(最后一页加载完、按钮消失时,焦点不会掉回页顶);名字旁边的头像 `alt` 为空,不重复朗读;只列名字的部门,职务另给读屏一份文字。输入法拼字(拼音、假名)还没上屏时两个标签都不搜索,上屏后才搜。英文文案不再出现「All 1 characters」这类单复数错误。
- **卡片和人名链到角色页和人物页**(`/character/:id`、`/person/:id`,见下面「角色页和人物页」):角色卡左半张进角色页,右半张进声优的人物页,名字本身就是链接,只是点击范围铺满那半张,读屏读到的是卡上的名字;手机上整行进角色页,右边的声优名进人物页。制作标签的每一行和只列名字的人名、概览的角色行(左右各一个)和制作人员行都链到人物页。没存 AniList id 的旧行(0037 之前写入)没有页面,仍是文字。
- **三个只读接口**(go-api,`internal/anime/credit_lists.go`):`GET /api/anime/:id/characters?role=&lang=&q=&offset=&limit=`(定位、配音语言、搜索都在服务端过滤,offset/limit 分页,返回各定位和各语言的数量)、`GET /api/anime/:id/staff`(全部署名,加上人数)、`GET /api/anime/:id/credit-counts`(标签栏的两个数字,一条聚合查询,不读整张列表)。只读库里的 `anime_characters`、`anime_character_voices`、`anime_staff`,中文名来自 `bgm_character_map` / `bgm_person_map`;**从不请求 AniList**,库里没有的番是 404,参数不对是 400。`/api/anime/:id` 的返回结构一个字段都不变。角色在配音表里没有数据时,用角色行自带的那位声优:整部番在配音表里一条都没有(0042 之前写入)就当作日配,0042 之后写入的番按这部番自己的语言归类。每部番的列表读一次后在内存里留 10 分钟(每种最多 64 部,同时到达的冷请求只读一次库),之后的筛选、搜索、翻页都在内存里做。200 响应带和 `/api/anime/episodes` 一样的公共缓存头。只被季度页、搜索这些列表写过、还没取过详情的番,演职员表还是空的:这种番的列表不缓存,响应是 `no-store`,下一次 `/api/anime/:id` 补齐之后马上就能看到;取过详情但列表为空的只缓存 30 秒。客户端已经断开的请求不再记成 500。
- 开发环境下 `/api` 的转发目标改为跟随 `GO_API_INTERNAL_URL`(不设时仍是 `localhost:8080`),浏览器和服务端连到同一个 go-api。
测试:Go 端,视图逻辑不连库(配音按语言分组和顺序、角色行自带的声优何时顶上以及归到哪种语言、中文名的回退、定位和搜索过滤、两种数量、分页、默认语言的五种情况、搜索规整),接口用假库(逐字节核对返回结构、默认值、参数截断、错误 id 和参数是 400 且不读库、未知番 404、500、只有 200 带缓存头、一次读库服务多次查询、同时到达的冷请求只读一次、读库失败不缓存、没取过详情的番是 `no-store` 且不缓存、空列表 30 秒后重读、数量只用一条查询、路由不和详情页的通配冲突),以及真 Postgres(用详情刷新本身的写入器写入演职员,加一行 0037 之前的旧行,三个接口逐项核对,数量和列表逐项一致,没取过详情的番是 `no-store`,`/api/anime/:id` 仍是 25 位)。前端,部门分组(按真实职务文本逐条核对归属、只列名字的门槛、按人合并职务、搜索)、卡片视图(取名顺序、另一个名字、童年配音和其他备注、没有配音的角色、追加一页时去重)、标签栏、两个标签的服务端首屏(含播报和头像 `alt`)、id 解析,以及三份词典的同步检查。Playwright 沙箱用例在桌面和 390px 手机宽度下跑:标签栏和数量、概览的「全部」链接和标签切换、定位筛选、配音语言切换、搜索、「再显示」和之后的焦点、慢回答不盖住新选择(筛选之间、翻页和筛选之间)、请求失败和重试、两个标签的输入法拼字、部门筛选、「展开全部」、手机上收起的首屏和行高、从滚动过的标签栏切换后的位置和焦点、登录后评分弹层能点到「10」,以及库里没有的番在两个标签上都是 404;评分弹层、手机行高、标签切换位置这三条都在对应的缺陷上跑过一次,确认会失败。另外跑了一次生产构建(`next build --webpack`),三个路由都是 ●;`next start` 下三页 200,带和概览相同的缓存头,第二次请求命中缓存,未知 id 和非数字 id 都是 404。
### 作品页的「社区」:评价、讨论帖、最近动态和谁在追,哪里都没有评分
`/anime/[id]/social` 是作品页新的「社区」标签,另有写评价页 `/social/review` 和讨论帖页 `/social/threads/[threadId]`。哪里都不显示分数,也不收集分数。
- **评价**:一句话总结 10–60 字,正文至少 300 字(最多 2 万字),可以加粗、引用、剧透块(`~!…!~`,和 AniList 同一写法)和 http(s) 链接。可以标「含剧透」:列表里整篇折叠,正文点开才读。也可以标「仅自己可见」:接口对作者以外的人和匿名读者都当它不存在。每人每部番一篇,作者可以修改、删除;写评价页有本机草稿(按账号分开存,同一台电脑换账号拿不到上一个人的草稿)。列表先按「有用」票数、再按时间排,不能给自己投「有用」。字数按看得见的字算:连续空白算一个,零宽字符、韩文填充字、盲文空白和单独的组合符号都不算,凑空格达不到下限,也不能用看起来空白的字符当标题或回复。
- **讨论帖**:标题 4–80 字,正文最多 5000 字,可以标含剧透。列表里的摘要是纯文本,正文里的剧透块显示为 ▇▇。帖子页按时间列出回复,回复可以回复另一条回复(「回复 @某人」)。作者可以删除自己的帖子和回复。
- **最近动态**:订阅状态的变化(想看、在看、看完、弃番)现在会记一条动态,和订阅写入在同一条语句里,状态真的变了才记,重复写同一个状态不会重复记。每人每部番只留最新的一张:新状态会替换掉这个人这部番没人点赞、没人回复的旧卡片(有人互动过的保留),来回切换状态刷不了屏;把番移出列表时,这部番的状态动态一起删除。别人可以在动态下回复、点赞。不公开主页的人的动态只有自己看得到。
- **谁在追**:追这部番的人(公开主页的人,以及自己),每人显示状态和从什么时候开始,顶上一行按状态汇总。
- **通知**:有人回复你的帖子、你在帖子里的回复或你的动态时,铃铛里会有一条,点开直接落到那条回复。通知和回复在同一条插入语句里写,不通知自己,拉黑的双方之间不通知;删掉帖子或回复时,相关通知一起删掉;回复和删帖同时发生而留下的通知,也不再列出或计数。「有用」和点赞不通知;评价按设计没有回复,所以也没有通知。
- **防滥用**:写入都要登录,请求体必须是 `application/json`(其他一律 415),其他网站的表单没法借访客的 cookie 冒名发帖;空文本和全是空白的文本被拒绝;每人分别限流(评价每小时 10 篇、发帖每小时 5 个、回复每 10 分钟 20 条、「有用」和点赞每 10 分钟 60 次,计数在内存里,重启清零);删除都是软删除。评价、帖子、回复都能用原有的举报对话框举报:快照被举报的内容,同一个人对同一条内容只有一条待处理的举报,不能举报自己的。管理员在 `/api/admin/community/…` 下可以删除任何一条。拉黑以后双方互相看不到,之间的点赞和「有用」票一并删除。
- **接口**:`/api/anime/{id}/community` 下的读接口公开(汇总、评价、帖子、动态、谁在追),只读本地库,从不请求 AniList,库里没有的番返回 404。`/api/anime/:id` 的返回结构不变。
- **缓存**:三个页面都保持 ISR(60 秒)。服务端只渲染一份匿名内容,渲染时不读 cookie 和请求头;带登录态的社区接口响应一律 `Cache-Control: private, no-store`,不会被共享缓存存下;和读者有关的部分(自己的私密评价、投过的票、点过的赞、哪些是自己写的)登录后在浏览器里再读一次。这次读可能在读者已经点赞或回复之后才返回,这时旧答案会被丢掉重读,不会把刚做的操作从页面上抹掉。未知的番、格式不对的 id 和不存在的帖子都返回真正的 404。
- **migration 0046**:五张新表(`anime_reviews`、`anime_review_votes`、`anime_threads`、`community_replies`、`activity_likes`);`activity_events` 加 `status` 列和新的事件类型;`notifications` 加 `reply_id` 和两种新类型;`reports` 加三种新的举报对象。降级先删掉只有新结构才允许的行,再恢复 0045 的约束。
- **和其他标签同一个首屏和标签栏**:社区页用详情页共用的首屏和「概览 / 角色 / 制作 / 社区」标签栏,首屏的 AniList / Bangumi 分数照旧显示(不显示分数指的是社区的内容)。四个标签上都写「社区 N」,N 是匿名读者看到的评价、讨论帖和动态的总数:概览、角色、制作三页从新的 `GET /api/anime/:id/community/count` 取(只读库,用的就是三个列表各自的计数查询,不带读者;未知的番 404),社区页用它自己首屏数据里的三个总数,所以两边对得上;取不到时只写「社区」,不写 0。社区页、写评价页、帖子页的 id 解析改成和其他标签一样严格(`1e3`、`0154587` 直接 404),库里没有的番在社区页和写评价页也是先问不回源 AniList 的计数接口再 404;帖子页先读帖子,帖子不存在就不再去读详情。
测试:Go 端在真 Postgres 上测了字数上下限(全角空格、零宽字符、韩文填充字、只有空白)、私密评价对作者以外的人不可见、登录和限流、软删除、一人一篇(第二篇返回 409)、「有用」票唯一且不能投自己、动态的回复和点赞、谁在追、通知(谁收到、不通知自己、拉黑后不通知、删除后通知消失、含剧透的回复不引用原文)、三种新的举报对象、管理员删除、未知的番返回 404、来回切换状态只留一张卡片且有互动的卡片保留、退订删掉状态动态、已删帖子留下的通知不列出不计数、非 JSON 请求体返回 415、登录后的读响应不可共享缓存、帖子摘要遮住剧透,以及 migration 0046 的升、降、再升。前端有文字规则、评价标记解析、日期格式、状态更新、接口解析、草稿和「被写入抢先的读要重读」的单元测试,以及标签页和编辑器的渲染测试。Playwright 在桌面和 390px 宽度下各跑 7 条流程:匿名访问、写评价(先被拦下再发布)、投「有用」、发帖并回复、回复动态并点赞、私密评价只有作者看得到、未知 id 返回 404;其中两条故意让登录后的那次读晚于点赞或回复返回,换回没有这层保护的代码就会失败。
### 角色页和人物页可以改了:原位编辑、提交审核、后台逐项采纳,采纳的内容单独存成覆盖层
角色页和人物页右上角的「编辑」不再只是留位置:点进去,页面本身变成编辑状态,版式不变,看到的每一项都在原位变成可改的输入框(照通过的画布 EditCast、EditPerson、Mobile-Edit、Mobile-EditPerson、ReviewQueue 做)。改完填来源、提交审核,管理员在后台逐项对比、逐项采纳或不采纳,提交人通过站内通知知道结果。采纳的内容存进单独的覆盖层,AniList 刷新、各个 sweep 重写演职员表和档案时都碰不到它。
- **入口和路由**:「编辑」是一个普通链接(`rel="nofollow"`、不预取),指向 `/character/[id]/edit`、`/person/[id]/edit`。编辑状态单独成一条路由,而不是页面上的一个模式:角色页、人物页仍然是 ISR,渲染时不读 cookie,所有读者拿到同一份 HTML,页面里也不带编辑的代码;编辑路由是动态的、`noindex, nofollow`、`Cache-Control: private, no-store`,在服务端校验登录(和 proxy.ts、后台布局同一种 JWT 校验)。未登录点「编辑」会跳到 `/login?from=…/edit`,登录后回到编辑状态(保留语言前缀);不存在的 id 先于登录判断返回真正的 404。页面存不存在,用和公开页同一份带缓存的读取来判断(在读 cookie 之前发出,所以真的走缓存),未登录的访问对 API 的消耗和打开公开页一样;只有登录的人才会再读一份不走缓存的最新页面,草稿就是和它比较的。刷新、后退、分享链接都停在同一个状态;画布上的面包屑以「编辑」结尾、「取消」是回到页面的链接,也是这个意思。
- **能改什么**:照片(「更换图片」下面二选一:粘贴链接,或上传图片——浏览器先缩到最长边 1200px、转成 JPEG 再发,所以 WebP、HEIC 这类浏览器打得开的格式也能传);中文名、日文名、罗马音;角色的别名、人物的职业(标签可删可加);性别、血型(下拉,保留列表外的原值)、生日(人物分年月日;角色是月日,页面上显示年份的角色多一个年份框)、年龄、出身地;角色简介;角色的每一条配音(改备注、「更换」成别人、× 移除、↺ 恢复、「+ 添加声优」——人用人物页链接、AniList 链接或 id 指定,经 `/api/people/:id` 核对,只能选本站有页面的人);角色在每部作品里的定位(主角 / 配角 / 客串,海报左上角的下拉)。最后是必填的来源链接和可选的说明。
- **改动怎么算**:编辑状态打开时就是页面现在显示的样子(按读者的语言:神奈川县、声优、日配 · 童年),提交的只有和打开时不一样的字段,所以页面翻译过的标签不会被当成数据写回去;「已改 N 处」用同一个比较。名字不能清空。生日改了才检查:改过的生日要是真实存在的日期,人物的年份不晚于明年,角色可以是故事里的任何年份,否则不让提交;数据里原有的、编辑器不认的日期(比如 2 月 30 日),不动它就不挡别的修改。配音行下面那行如果是页面按配音信息拼出来的(日配 · 童年),清空它不算改动,因为没写的时候页面显示的就是这一行;清空写过的那行才回到拼出来的。桌面端名字右边是「已改 N 处 · 取消 · 提交审核」;手机端顶栏是「取消 · 名字 · 已改 N 处 · 提交」,另一套用 CSS 隐藏,所以手机上只有一个提交按钮。单行输入框里按回车不会提交。
- **提交接口** `POST /api/edits`(必须登录):`{"kind","entityId","sourceUrl","note","changes":{…}}`,成功是 201。服务端用和页面同一套读法(含已采纳的修改)重新比较一遍,只把真正变了的字段存成条目,每条记下提交人看到的旧值和提出的新值;一处都没变的提交直接拒绝。每个字段都有长度上限;未知字段、页面没有的字段、控制字符、不存在的日期、未知定位、角色不在的作品、不存在的配音行、本站不收录的人一律 400。别名、职业、配音、定位这些列表先按发来的条数检查,超了直接 400,不读页面、也不查人。限流按人:同一页面同时只能有一份待审核(部分唯一索引兜底),全部待审核最多 10 份,10 分钟最多 5 份、24 小时最多 20 份,在写入事务里加咨询锁再数一遍;同一个人同时只能有一个提交在处理,其余直接 429(否则并发请求都能通过前面的检查、各自去下载一张图);每一次提交尝试都计数,成功和被拒都算,每人 10 分钟最多 20 次(被拒的尝试也可能让服务器读页面、下载和解码图片,而前面那些限额只数存下来的提交)。
- **只收 JSON**:两个写接口 `POST /api/edits` 和 `POST /api/admin/edits/{id}/review` 只接受 `Content-Type: application/json`(带 charset 可以),表单、`text/plain`、`multipart/form-data` 或者不带 Content-Type 一律 415,在读写任何东西之前。生产环境的会话 cookie 是 `SameSite=None`,别的网站的表单或 no-cors fetch 带得上读者或管理员的 cookie,却发不出这个 Content-Type(要先过只放行本站的 CORS 预检),这样就挡住了跨站替读者提交、替管理员审核。
- **图片**:上传和链接都走头像那一套校验(`internal/avatars` 抽出了共用的 `DecodeDataURL`、`DecodeImage`、`WriteJPEG`):按文件内容判断格式、只收 JPEG 和 PNG、先读文件头检查尺寸再解码(防解压炸弹)、重新编码成只剩像素的 JPEG、文件名是新生成的 UUID;超过 1200px 的再缩小。链接只在提交时下载一次,之后和上传完全一样处理,页面从不直接引用那个链接:这样管理员审核的就是读者将来看到的那张(对方事后换图换不到这里),读者不会被带去第三方网站,对方失效或在读者的网络里打不开也不影响,服务器也只在这一刻对外发请求。下载的限制:只认 https、443 端口、不带账号密码、必须是域名而不是 IP、排除 localhost 和内网域名后缀;每次真正建立连接时(DNS 解析之后)检查对方地址必须是公网单播地址,私网、回环、链路本地、CGNAT、保留和文档地址段都拒绝,IPv6 只放行全球单播(2000::/3)并排除其中的特殊用途段(Teredo 等协议段、文档段、6to4),IPv4 映射、兼容、翻译这些内嵌 IPv4 的写法都在外面,所以解析到内网、或者中途换了解析的域名一样被挡;不走环境变量里的代理;最多跟 3 次跳转,每次都重新检查;必须是 200、`Content-Type` 为 JPEG 或 PNG、不超过 6 MiB、10 秒内完成。整个进程同一时间只解码一张图(一张 4000px 见方的渐进式 JPEG 解码时要占几百 MB,不管最后成不成功),排队的请求在自己的超时内等,等不到就 503;缩小时直接读解码出来的像素类型,不再每个像素分配一次。文件存在头像卷上:待审核在 `edits/pending`,采纳后移到 `edits/public`,公开路由 `/api/edit-images/{name}` 只读 `public`,所以「采纳之前不公开」由文件位置决定,不靠某个处理函数记得检查;不采纳的直接删除。待审核的图只能从后台路由 `/api/admin/edits/images/{name}` 看(要管理员登录,`private, no-store`)。
- **后台审核** `/admin/edits`:待审核数量,待审核 / 已处理、全部 / 角色 / 人物两组筛选;左边列表(「名字 · 作品」、类型、几处改动及是否含图片、提交人和「第 N 次提交」或「采纳过 N 次」),右边是选中的那份:来源、说明,然后每一处改动的原来和改成,图片新旧并排。每一处一个勾选框,取消勾选就要求写给提交人看的理由;「采纳勾选的 N 处」按勾选提交(一处都没勾时是「全部不采纳」),「全部采纳」直接全部采纳,「不采纳」把所有勾都去掉以便逐条写理由。筛选、翻页和选中的那份都在地址里。后台导航加了「修改审核」,原来几个锚点改成带 `/admin` 路径,从审核页也能跳回去。接口 `GET /api/admin/edits`、`GET /api/admin/edits/{id}`、`POST /api/admin/edits/{id}/review`(每一项都要有决定,不采纳的必须有理由)都在要求管理员的 `/api/admin` 路由组里。审核在一个事务里完成:采纳的条目合并进覆盖层(行锁,同一页面的两份审核不会互相覆盖字段)、对应图片移到 public、每一项和整份提交标记结果、给提交人发通知;事务失败会把已经移动的图片移回去。提交之后删除不采纳的图片,并把所有出现这个人或角色的番剧从详情缓存和「角色」「制作」列表的内存缓存里去掉。同一份提交审核两次返回 409。前端的审核动作是 server action,带管理员的 cookie 转发给 go-api,有采纳时立即重新生成这个页面三种语言的 ISR。
- **对着旧页面做的改动不能采纳**:每一处改动记着提交人当时看到的原值。如果审核时页面上这一项已经不是那个值(期间别人的修改被采纳了),采纳它就会把先采纳的那份悄悄改回去,比如别名列表少了前一个人加的那个、配音行换回原来的人。所以这样的改动标「页面已变」,勾选框不能勾,预填一条给提交人看的理由(可以改),只能不采纳;「全部采纳」也跳过它。服务端在审核事务里锁住这一页的覆盖层之后再核对一遍,勾选了这样的改动就整份返回 409、什么都不改,审核页弹出提示并重新载入。配音行只比较由谁配、下面那行写什么,不比较这个人现在叫什么、头像是哪张(他自己的页面被修改、AniList 改名都不影响这一行)。手机上后台顶栏的链接不再被挤成一字一行,改成一行横向滚动。
- **覆盖层**(`entity_overlays`):每个人物或角色一行、一个 JSON 文档(`internal/overlay`)。读的时候优先级是覆盖层 > Bangumi 中文名 > AniList。它只由审核写入,从不写进 AniList 会刷新、sweep 会重写的表(`anime_characters`、`anime_character_voices`、`anime_staff`、`people`、`characters`)。用到它的地方:`/api/characters/:id`(名字、别名、图片、资料、简介、各作品定位、配音的修改;配音每一行多了 `key` 和 `line` 两个字段);`/api/people/:id`(名字、图片、职业和资料,以及每条配音上角色的名字、图片和定位);`/api/anime/:id` 的角色和制作人员列表(只有名字和图片,包括角色的声优;JSON 结构一个字段都不变,定位和配音的增删换只在角色页体现),以及作品页「角色」「制作」两个标签的列表接口 `/api/anime/:id/characters`、`/api/anime/:id/staff`(同样只有名字和图片,概览和标签页对一个角色的定位不会说法不一;搜索按修改后的名字找);角色 sitemap(中文名和主角判断按覆盖后的值算,sitemap 和页面的 `indexable` 继续一致;人物、角色 sitemap 的 lastmod 都算上最近一次采纳)。修改只改变页面说什么,不改变页面存不存在,也不改变一个人被计入多少部作品。
- **通知**:沿用站内通知收件箱,新增类型 `edit_review`,指向那份提交;`GET /api/notifications` 给这种通知加一个 `edit` 对象(页面、采纳和不采纳的数量、不采纳的理由),其他类型的结构不变。铃铛里显示「你对「修塔尔克」的修改:采纳 1 处,未采纳 1 处」(或全部采纳 / 未被采纳),下面是理由;头像位置是那个页面的图片,不显示审核人,接口也不再返回审核人(这类通知的 `actor` 是空的,字段还在,结构不变);点开回到页面。管理员审核自己的提交不发通知。
- **迁移 0047**:新建 `edit_submissions`、`edit_items`、`entity_overlays` 三张表,`notifications` 加 `edit_submission_id` 列和 `edit_review` 类型。`notifications_type_chk` 写成「0046 的五种类型 + edit_review」:0046(社区标签页,在另一个分支)也改这条约束,所以 0047 必须在 0046 之后执行,0046 的类型列表如果再变,这里要跟着改。golang-migrate 只往上走:如果 0047 先于 0046 部署,0046 会被直接跳过而且不报错,所以合并和部署都要让 0046 先上(或者后合并的那个改用下一个编号)。迁移只锁 `notifications`(很小)和外键检查时的 `users`,不碰任何演职员表、档案表和 `anime_cache`。
- **部署注意**:先迁移再部署。图片默认放在 `AVATAR_DIR/edits` 下,沿用现有的头像卷,不需要改 compose(可以用 `EDIT_IMAGE_DIR` 换位置);头像卷的备份现在也包含这些图片。采纳的图片地址用 `CLIENT_ORIGIN` 拼成绝对地址(外部客户端只认 https 的绝对地址),所以它必须是站点对外的地址。
测试:go-api 的覆盖层文档(三态字段、合并规则、配音操作、定位);迁移 0047(锁住演职员表和档案表时能执行、各条约束、删除账号带走提交但保留覆盖层、降级再升级);页面构建(各字段的优先级、清空、无档案时由修改补出档案、每种配音修改、定位和索引判断、人物页上的角色修改);真 Postgres 上的两个页面、修改里新出现的人、sitemap 和页面一致、只有覆盖层不成页面;`/api/anime/:id` 的名字和图片、字段集不变、缓存在清掉之前不变、AniList 刷新后修改仍在且演职员表没被写;两个标签页列表接口的名字、图片和搜索,定位不变,内存缓存在审核采纳清掉之前不变;下载和连接的各项限制(内网、保留和映射地址、端口、域名、跳转、类型、大小、慢服务器)、图片的存储和发布、缩图、修改规则逐字段;列表按发来的条数拒绝、每次尝试都计数、同一时间只解码一张图、直接读像素和通用读法结果一致、IPv6 只放行全球单播、生日年份按页面类型、页面每个字段的读法和条目记下的原值一致、配音行的比较不看名字和头像;真 Postgres 上的各种拒绝、各项限流、只收 JSON(四种非 JSON 的 Content-Type 对提交和审核都是 415,不留提交、不下载不存图、不改任何条目、没有通知,同样的内容以 JSON 发送就能提交和审核)、并发提交只有一份成功(每个请求在单独的进程里,只靠数据库那道锁)、同一人同时只能处理一个、旧值过期的改动(标记、拒绝采纳且什么都不改、其余照常采纳)、通知里没有审核人、完整流程(提交、队列和权限、逐项新旧对比、被拒绝的审核什么都不改、部分采纳、页面显示采纳的名字和旧照片、演职员表没被写、通知、图片发布、管理员审核自己的提交)。next-app:编辑草稿模型(含生日只在改过时检查、角色年份、清空拼出来的配音备注不算改动)、错误文案映射、缩图尺寸、两个编辑页的服务端渲染(字段和值、每套顶栏一个提交按钮、英文、有年份的角色生日)、审核的决定(过期的改动起始为不采纳并带理由、「全部采纳」跳过它、面板按过期条目重新开始)和 server action(转发、各语言重新生成、拒绝、409)、通知解析(审核人为空也照常显示)、所有运行时拼出的词典键在六份词典里都有、页面上的「编辑」链接。Playwright(sandbox,桌面和 390px 手机):未登录点「编辑」去登录再回来、修改中文名和照片链接后提交、管理员采纳名字并写理由拒绝照片、页面显示新名字和旧照片、提交人收到通知、手机编辑页只有一个提交按钮且不横向滚动、编辑路由上不存在的 id 返回 404;另有一个只测数据库的 spec 确认它借用的 Stark 覆盖层原样放回(文档按 jsonb 对象写回,连同最近一次采纳的时间和人;之前写回成 jsonb 字符串,Stark 原本有覆盖层时会被表上的检查拒绝)。生产构建里两个页面仍是 ISR,编辑路由和审核页是动态的;未登录连续打开编辑页六次,API 只被读一次。
### 角色页和人物页:/character/[id]、/person/[id],桌面和手机各一套版式,只读本站数据库
点开角色或声优,终于有地方去了。两个新页面照通过的画布做(角色详情、声优详情及各自的手机版),用的是站内现有的组件、令牌和 CSS module 写法;右上角的「编辑」进入页面的编辑状态,见上一条。页面上不放来源说明之类的小字。
- **角色页**:大图;中文名、日文名、罗马音;别名(AniList 标成剧透的别名不读、不返回、不显示);性别、年龄、生日、血型;简介——AniList 的 markdown 解析成 React 节点再渲染,链接只留文字,`~!剧透!~` 折叠成一个「剧透」按钮,点开才显示,跨段落的剧透是同一个,简介的渲染不经过 `dangerouslySetInnerHTML`;声优:这个角色在各部番、各种语言的全部配音,同一人同一备注只列一次,原产国语言在前,同语言里主配音在前(「日配 · 童年」);出演作品:封面、本番定位、「格式 · 年份」,按时间先后。面包屑是「这个角色主演的最热门的番 › 角色 › 名字」。
- **人物页**:声优和制作人员同一个页面(AniList 也是同一种记录)。照片、名字、职业标签、生日 / 性别 / 出身地 / 血型(有忌日时加一行);代表角色 3 张:主角优先、番剧人气高的优先,一个角色、一部番各只出现一次;配音作品按年份分组、新到旧,没有日期的(未定档)排最前,每张卡是角色图加右下角的番剧小封面、角色名、「定位 · 番名」,头部有「新到旧 / 只看主角」(只在两者有区别时出现),先显示 21 张,下面是「查看全部 N 部」;有制作职务的人另有「制作作品」:每部番一张卡,列出这个人在这部番的全部职务(沿用站内的职务中文表)。人物的别名不读:AniList 那一栏里常有他们在别处(包括成人作品)用的艺名。
- **名字**:沿用站内详情页的名字阶梯——中文页先用 Bangumi 的简体中文名,再日文原名,最后罗马音;英文页反过来。阶梯和详情页演职员表用的是同一套,测试里逐语言对过。出身地在中文页换成日本的都道府县名(「神奈川县」),认不出的保留 AniList 原文;生日没有年份时写「6月4日」。
- **接口**:`GET /api/people/:id`、`GET /api/characters/:id`,只读数据库、从不请求 AniList。演职员表是页面的内容,AniList 档案(0044,只认 `fetched_at` 非空的行)和 Bangumi 中文名(0045)是附加:还没被档案 sweep 拉到的人,名字和头像取自演职员行(取最热门的那部番记下的)。没有任何非成人番引用的 id 返回 404,只出现在成人番里的也是;非法 id 返回 400。头像和角色图把 AniList 的 medium 路径换成同名的 large。
- **索引**:配音 ≥3 部或制作 ≥5 部(只数非成人番,按部数)的人物页进索引,其余 `noindex, follow`,链接照样传递给番剧页。理由:一两部作品的人物页说不出番剧页上没有的东西,全部放进索引会把抓取预算花在大量近似页面上;三部是声优页成为一份作品年表的起点,制作职务一部番常常记好几行、而且每行信息少,所以门槛更高。门槛是 go-api `internal/people` 里的两个常量,页面的 `indexable` 和 sitemap 列表读同一对常量。角色页只索引「在至少一部番里是主角、并且有 Bangumi 中文名」的角色。
- **SEO**:标题带原名(「花泽香菜(花澤香菜) · 声优」),描述用页面自己的事实拼出来(不用 AniList 的英文简介);canonical 和三语 hreflang 照现有的 `buildAlternates`;人物页输出 `Person`(`sameAs` 指向 AniList 和 Bangumi)和 `BreadcrumbList`,角色页只输出 `BreadcrumbList`(schema.org 没有虚构角色的类型,标成 `Person` 不对)。新增 `/sitemaps/people/` 和 `/sitemaps/characters/` 各 4 个分片,robots.txt 列出全部,只列进索引的页面,lastmod 是档案或所列番剧最近一次更新的时间;go-api 的列表结果缓存一小时。
- **缓存**:两个路由和 `/anime/[id]` 一样是 ISR(`revalidate = 60`、`dynamicParams = true`,`generateStaticParams` 返回空表——它们只读数据库,首次渲染就是一次带索引的查询,没有需要预热的东西),渲染时不读 cookie 和 header。不存在的 id 是真正的 404。CI 的构建任务新增一步,断言这两个路由在生产构建里仍是可 ISR 的。
测试:go-api 的纯函数(名字阶梯的来源顺序、无档案时取演职员行、年份分组和排序、代表角色的排序与去重、制作职务按番合并、部数统计、索引门槛、角色配音的去重和排序、large 图的路径替换);接口层(400 / 404 / 500、JSON 的键逐个钉住、`/sitemap` 不会被当成 id、sitemap 参数校验和缓存);真 Postgres 上的整条链路(有档案、只有戳的档案行、0042 之前只有 `anime_characters` 的配音、成人番过滤和 404、跨语言配音、sitemap 列表和页面 `indexable` 逐个对得上)。next-app:名字阶梯(和详情页的取名函数逐语言比对)、标签与出身地、剧透 markdown 解析(跨段落、未闭合、空白开头、链接和 HTML)、年表的筛选与折叠、SEO 标题 / 描述 / JSON-LD、sitemap 分片和 robots,以及两个页面的服务端渲染输出(剧透折叠、链接、面包屑、没有编辑按钮和来源小字、三种语言)。Playwright(sandbox,桌面和 390px 手机):角色页的剧透点开、声优和出演作品的链接、人物页的代表角色、「只看主角」和「查看全部」、制作人员页的 noindex、三语页面、各种 404,以及手机上没有横向滚动。
### 声优、职员和角色的简体中文名:从 Bangumi Archive 的周更 dump 在每部番里按原名匹配(离线工具,默认只预览)
详情页的 `nameCn`(角色中文名)和 `voiceActorCn`(声优中文名)一直是空的:AniList 没有中文名,存它们的列也从来没有数据来源。Bangumi 有——每个人物、角色的 wiki infobox 里都有「简体中文名」——而且每周把整个 wiki 导出成一份 dump(<https://github.com/bangumi/Archive>)。
- **在每部番里匹配,不按全站同名**:只读已经绑定 Bangumi 条目的番(`anime_cache.bgm_id`),并且不信任任何绑定,名字本身就是守卫。同一部番里,AniList 声优的原文名要等于这个条目演员表(person-characters)里某个人的日文名,才算同一个人;不配音的职员同理,在这个条目的职员表(subject-persons)里找。Bangumi 上叫「田中敦子」的有三个人(声优、动画人、脚本家),只有声优那位在芙莉莲的演员表里。绑错的番对上的是别的作品的演员表,名字对不上,什么都不写。
- **角色要两个信号**:先要它的声优匹配上(并且没有卷进冲突),person-characters 说明这个人在这个条目里配了哪几个角色,角色的原文名还必须等于其中一个的日文名。只看声优不够(一个声优在一部番里可能配好几个角色),只看角色名也不够(同名角色在不相干的作品里反复出现)。
- **比较前的规整**:NFKC,去掉所有空格和中点(・ ・ ·),拉丁字母不分大小写,再加一张很短的异体字表(﨑→崎、髙→高、𠮷→吉,都是 NFKC 不处理的字)。不做日文汉字和中文汉字的互换(黒 不等于 黑),也不做假名换写。
- **冲突两边都不写**:一个 AniList id 在任何一部番里对上两个 Bangumi id,或者一个 Bangumi id 被两个 AniList id 对上,两边都不写,报告里逐条列出。
- **中文名的来源**:infobox 的「简体中文名」,没有时用记录自带的 name_cn(目前 dump 里人物和角色的记录都没有这个字段);去掉末尾的一个括号注释(「田中敦子(声优)」→「田中敦子」,「池田由纪(小雪)」→「池田由纪」)。不做繁简转换,也不把假名转成汉字:Bangumi 写什么就存什么。
- **存储**(migration 0045):两张新表 `bgm_person_map`、`bgm_character_map`,按 AniList id 一行,记 Bangumi id(唯一)、中文名(Bangumi 没有时为 NULL,匹配照样保留)、来自哪一份 dump、什么时候匹配上的。没有放进 0044 的 `people` / `characters`:那两张表归 AniList 档案 sweep,它把「有这一行」当成「问过 AniList」;这份映射是 Bangumi 的,每周用新 dump 整体刷新,不该动 AniList 的数据。迁移只建两张新表,测试在锁住演职员表和 0044 两张表的情况下应用它。
- **读的地方**:`/api/anime/:id` 的 `nameCn`、`voiceActorCn` 先取匹配到的中文名,没有时退回行里原有的值;返回结构一个字段都不变(Nagare 客户端本来就解这两个字段)。导入从不写 `anime_characters`。这个接口里职员没有中文名字段,职员的匹配先存着,留给之后的人物页。
- **离线工具 `cmd/bgmnames`**,用法:
1. 最新 dump 的下载地址在 <https://github.com/bangumi/Archive/blob/master/aux/latest.json>(`browser_download_url`,附 sha256 摘要),每周三凌晨(GMT+8)更新;下载到仓库之外。
2. 只需解压四个文件,目录名建议沿用 zip 的名字(会作为来源写进每一行):`unzip dump-….zip person.jsonlines character.jsonlines subject-persons.jsonlines person-characters.jsonlines -d dump-…`
3. 先预览:`DATABASE_URL=… go run ./cmd/bgmnames --dump /path/to/dump-…`,打印绑定番数、人物和角色的匹配数与比例(人物分声优、职员两条路)、冲突、样例,以及写入会增删改多少行。
4. 确认后加 `--apply` 写入。写入是一个事务,结束后两张表正好是「这一轮匹配到的全部」:没变的配对原样不动(`matched_at` 和来源也不动),变了的替换,这一轮没再匹配到的删除(Bangumi 改正了、出现冲突了、绑定换了),所以每周重跑是安全的,同一份 dump 跑第二遍什么都不改。不用 bgm_id_map 那种 TRUNCATE:详情页每个请求都会 join 这两张表,TRUNCATE 的锁会让请求一直等到导入结束。dump 缺文件、某一行不是 JSON、缺了 id 字段都会报错退出(带文件名和行号),不会因为读少了而删掉本来正确的名字。两次导入同时跑时,后一次等前一次的事务结束。
- 这一轮不做定时任务,也不自动下载。
- 数据来源是 Bangumi 番组计划(bgm.tv)的 wiki,经 Bangumi Archive 发布;这个仓库没有附许可证,README 里也没有使用条款。
测试:规整(NFKC、全角半角空格、中点、异体字表只收 NFKC 不处理的字并保持很短、黒/黑和ノ/の不会被合并);中文名提取(CRLF、空的「简体中文名」不会读到下一行、列表值不算名字、括号注释的几种写法、不做任何转换);dump 读取(只留绑定条目用得到的记录、缺文件 / 截断的行 / 改了名的字段都报错并带文件名和行号、超长的行);匹配(计划文档的五个例子:和氣あず未→和气杏未、黒沢ともよ→黑泽朋世、市ノ瀬加那→市之濑加那、種﨑敦美→种崎敦美(两边都是异体字,以及只有一边是)、田中敦子(声优)→田中敦子;角色需要两个信号;绑错的番什么都不写;绑错但碰巧共用一个声优时只匹配到这个人、不匹配角色;三种冲突都两边不写;职员和声优两条路说法不一;同一个配对来自两部续作只算一个);真 Postgres 上的写入(预览不写、第一次全部插入、重跑零变化且 `matched_at` 不动、改名 / 换绑 / 两个 id 互换 Bangumi id / 消失、失败时两张表都回到原样)、完整的一轮(预览、写入、重跑、改绑到无关条目后旧匹配被删、一条都不写)、读的地方(匹配优先于行里的旧值、没有匹配时退回旧值、匹配了但没有中文名时为空、JSON 的键不变)、migration 0045 升、降、再升。
另外用真实的 dump(2026-10-06)跑了一遍:用 AniList 的详情数据灌入芙莉莲(AniList 154587,绑定 Bangumi 400602;第一页 25 个角色、51 条配音、25 个职员),预览后写入。人物 75 个里匹配上 47 个(62.7%),角色 25 个里匹配上 24 个(96%),没有冲突。没匹配上的 28 人是 23 位韩语配音(Bangumi 这个条目没有列韩配)和 5 位 Bangumi 上查无此名的职员;第一页的日语配音全部匹配。没匹配上的角色只有一个:AniList 叫「悪魔の子」,Bangumi 叫「魔族の子」。芙莉莲 / 种崎敦美、菲伦 / 市之濑加那、修塔尔克 / 小林千晃、辛美尔 / 冈本信彦都对;再跑一遍零变化;改绑到《铁臂阿童木》(1963,Bangumi 806)后一条都不写。
### 声优、职员和角色各存一份 AniList 档案:别名、简介、生日、职业、活跃年份(新 sweep,默认关闭)
库里的声优、职员和角色,一直只有演职员表里顺带的名字和头像(`anime_staff`、`anime_characters`、`anime_character_voices`,0037 起存了 AniList 的 id)。人物页、角色页要的其余资料——别名、简介、生日、职业、活跃年份、出身地、血型、收藏数——从来没有拉过。
- **两张新表**(migration 0044):`people`(AniList 的 Staff,声优和制作人员都在这里)和 `characters`,按 AniList id 一行。字段是全名、原文名、别名(角色另有 AniList 标成剧透的别名)、语言(`languageV2`)、大小两种头像、简介、性别、生日、忌日(人物)、年龄、活跃年份(人物)、出身地(人物)、血型、收藏数、AniList 链接。生日按年、月、日分三列存:生日大多只有月日没有年份,日期列存不下。人物的年龄是 AniList 按生日算出的数字,角色的年龄是自由文本(「1000+」「17-18」)。简介按 AniList 的 markdown 原样存,剧透标记 `~!…!~` 保留,显示多少由读的地方决定。
- **三个时间戳**:`fetched_at` 是 AniList 最近一次返回这条资料的时间,资料就是那一刻的;为 NULL 表示 AniList 从没返回过,这一行只用来记戳,读的时候要 `WHERE fetched_at IS NOT NULL`。`checked_at` 是 sweep 最近一次问它的时间。`absent_since` 是第一次问了却没返回的时间(AniList 删除了这个 id,或并进了别的 id),之后再返回就清空;已经存下的资料保留,因为还有演职员行在引用这个 id,就还需要名字。
- **迁移只建两张新表**:不加外键(演职员行先于资料写入,资料也比引用它的演职员行活得久),不在已有的表上建索引(按 id 查用 0037、0042 已有的索引),所以不会去等任何已有表上的锁。迁移测试在另一个事务锁住 `anime_cache` 和三张演职员表时应用 0044 来证明这一点(往 0044 里加一条碰 `anime_staff` 的语句,这条测试就会超时变红)。新表是空的,由 sweep 填,没有回填。
- **新 sweep `profiles`**(ratings 队列,每 5 分钟一轮):候选是演职员表引用到的全部人物 id(`anime_staff.staff_id`、`anime_characters.voice_actor_id`、`anime_character_voices.staff_id`)和全部角色 id(`anime_characters.character_id`)。从没问过的排在前面,按引用它的番里最高的人气排,引用条数只用来打破平局:人物页是从番剧页点进去的,热门番里的配角会比两百部冷门番里的原画更早被人看到;角色几乎都只出现在一部番里,按条数排没有区分度。其次是 90 天前问过的,最早的先;已经没有演职员行引用的 id 不再去问。每次请求 50 个 id(`Page(perPage: 50) { staff(id_in: …) }`,不读 AniList 给错的 `pageInfo.total`),人物和角色轮流,每轮最多 4 次请求。问了却没返回的 id 记为上游已删除,和这一批的资料在同一个事务里写入,90 天后再查一次。
- **默认关闭**:`PROFILES_SWEEP_ENABLED` 和 `ANIME_CREDITS_SWEEP_ENABLED` 一样在任务运行时读取,不设、或者解析出来不是 true(拼错也算)时,每一轮直接返回,不读候选、不请求 AniList。它和 credits、facts、ratings 三个 sweep 共用 ratings 队列,所以要停只能改这个变量再重启。启动时如果是关的,会打一条 Info 日志。
- **节奏照搬 credits sweep**:全部请求走 no-wait(令牌此刻空闲才取),每次请求前隔 6 秒;额度忙就隔 6 秒再试,三次之后结束本轮;429 或熔断立即结束。每小时最多 48 次请求、2,400 条资料,不到 AniList 额度的 3%,只用没人等的令牌。ratings 队列只有一个 worker,所以它和另外三个 AniList sweep 永远一轮接一轮、不会同时跑,四个加起来不到额度的十分之一。一批失败(5xx、数据库错误)时,整批的戳倒填成「一天后到期」并结束本轮,免得一批坏数据永远排在最前;10 个 id 以上的一批一个都没返回时,不当成「全被删了」,按失败处理;额度忙、429、本轮超时都不打戳。在一个造了 2 万部番、30 万职员行、25 万角色行、40 万配音行的本机测试库上,两条候选查询在积压期每轮合计约 1 秒,补完之后约 0.3–0.45 秒。
- **接口不变**:这次只收数据,不加接口;人物页、角色页的接口等界面设计定了再做。
测试:查询文本(逐个核对选了哪些字段、`id_in: $ids`、每页 50、不选 `pageInfo`;Go 结构体里每个 json 字段都必须出现在查询里);客户端解码(全部字段、列表里的 null、`Page: null` / 列表为 null / 没有列表都是错误,空列表不是;空批次和超过 50 个 id 不发请求也不占令牌;额度忙时不发请求;429 不重试并打开熔断);规整规则(空白算没有、别名去重、简介原样保留、日期按部分保留、活跃年份按位置截断、年龄必须为正);真 Postgres 上每一列的往返、重拉整体覆盖(包括 AniList 删掉的简介)、缺席戳和失败戳各自只动该动的列、一批写入是一个事务;两档候选的顺序(三种引用来源、取最高人气、条数打破平局、没有人气的排最后、到期的按时间、没人引用的不再问)、走生产 store 的完整一轮、失败的一批 24 小时后重新到期;sweep 的开关(8 种取值)、轮流分批和每轮上限、节奏、忙时让路、429、缺席、多出来的 id、空回答、失败倒填戳、本轮被取消时不打戳;migration 0044 的升、降、再升。另外用真实的 AniList 跑了一轮(芙莉莲的 10 位声优和职员、10 个角色,每类再加一个不存在的 id):2 次请求,20 条全部返回,不存在的 id 被 AniList 直接略过,记为缺席。
### 角色和职员不再停在前 25 个;配音按原产国选,童年配音和中日韩配音单独入表
之前一部番只存 AniList 第一页的角色和职员(各 25 个,嵌套分页的上限),每个角色只存 `voiceActors(language: JAPANESE)` 的第一位。而且每 24 小时的详情重拉对这两张表是整表删除重建,所以第一页之后的内容即使有人写进来,第二天也会被删掉。
- **查询**:角色排序从 `ROLE` 改成 AniList 网站用的 `[ROLE, RELEVANCE, ID]`(之前我们的前 8 位和 AniList 页面上的不同),职员 `[RELEVANCE, ID]`;末尾的 `ID` 让顺序完全确定,分两次请求取的页才能接得上。两条都选 `pageInfo { hasNextPage }`。配音改取 `voiceActorRoles(sort: [RELEVANCE, ID])`,**不带语言参数**:AniList 的语言枚举里没有中文,带参数就永远拿不到国漫的中配;返回值带 `languageV2`、`roleNotes`(「Childhood」等)、`dubGroup`。AniList 在查询没选 `node { id }` 时会**静默**返回空的配音列表,查询测试钉住了每一页都选它。
- **主配音**(`voice_actor_*` 列,`/api/anime/:id`、详情页和下游消费方都读这几个字段)按原产国选:CN / TW 选中文,KR 选韩语,其余日语;同一语言里优先没有角色备注的那位(主配音而不是童年配音);该语言没有就退到日语,再退到任何语言。日本番、每个角色只有一位日配时,选出来的人和以前一样。`voice_actor_ja` 存的是原文名,中文声优就是中文名。
- **新表 `anime_character_voices`**(migration 0042):每个角色的日语、中文、韩语配音——语言、备注、配音组、名字、头像、顺序(0 就是主配音),本番语言在前,其次日、中、韩,同一语言里按 AniList 的顺序,每个角色最多 8 位。英语、欧洲和东南亚语种的配音不存:AniList 把日本番的中配排在这些配音后面,全都存的话,热门角色的 8 个位置先被它们占满,中配反而进不来(名侦探柯南第一页的 25 个角色在 AniList 上有 22 位中配,按全存的规则只留得下 5 位)。按 `staff_id` 建了索引,给之后的声优人物页用。
- **写入改成按 id upsert**:角色按 `(anime_id, character_id)`、职员按 `(anime_id, staff_id, role)`(0042 加了唯一索引,空职位也算一个键,建索引前先去重、保留排在前面的那份)。详情重拉只覆盖第一页:AniList 说还有第二页时,第一页之后的行保留,编号顺延到第一页后面,所以 display_order 0–24 永远是 AniList 的第一页;说没有第二页时,第一页就是全部,其余的行删除。被重写的角色的配音整体替换,角色已经不在的配音一并删除。先 upsert 后删除,重拉过程中不会读到空列表(不在事务里的详情重拉,中途可能读到新旧混合)。`name_cn` 不会被重拉清空;`voice_actor_cn` 只在声优还是同一个人时保留。
- **每张表一条语句**:角色、配音、职员各用一条 upsert 写整份列表(行以 jsonb 数组传入,`jsonb_to_recordset` 展开;`text[]` 带不了 NULL)。逐行写的话,带全部配音的第一页约 250 条语句、每条各自提交,都挤在冷请求的 5 秒里;现在详情重拉的角色和职员写入约 9 条语句,比原来的整表删除重建(约 50 条)还少。json 标签就是语句里的列定义,测试把每一列往返校验一遍——键名写错时 `jsonb_to_recordset` 只会默默给 NULL。
- **新 sweep `anime_credits`**(ratings 队列,每 5 分钟一轮):挑出第一页之后还有内容的番(详情重拉记下的 `cast_has_more` / `staff_has_more`,或这两列还是 NULL、正好存满一页的番),拉完整列表:一次请求用别名在同一个 `Media` 上取第 1–8 页,第 8 页后面还有就再取 9–16 页,每部角色、职员各最多 400;在一个事务里整体替换并打戳,30 天后再查。从没查过的优先,同一档里热门优先;成人番照常存,过滤留给读的地方。详情重拉发现第二页从无到有(标志变成 true)时会清掉 sweep 的戳,在播番角色超过一页后不用等满 30 天。
- **默认关闭**:`ANIME_CREDITS_SWEEP_ENABLED` 不设、或者解析出来不是 true(拼错也算)时,每一轮直接返回,不读候选、不请求 AniList,和另外两个 sweep 的开关一样按关闭处理。在任务运行时读取,上线可以先关着,确认没问题再打开;要关也只需改环境变量再重启,不用重新部署。它和 facts、ratings 两个 sweep 共用 ratings 队列,所以不能靠暂停队列来停。启动时如果是关的,会打一条 Info 日志。
- **它不会挤掉用户的请求**:全部请求走 no-wait(令牌此刻空闲才取,有请求在排队时直接让开),每次请求前隔 6 秒;额度忙就隔 6 秒再试,三次之后结束本轮;429 或熔断立即结束。每轮最多 6 部(角色 3 部,职员拿剩下的;角色补完后 6 部都给职员),约每小时 70–100 次请求,只用没人等的令牌。某部番失败时把戳倒填成「一天后到期」并结束本轮,免得一部坏番永远排在最前;第二个请求拿不到额度时这部番退后一小时(它已经花掉了一次请求);AniList 已经不提供的番记为已查、保留现有的行;第二个请求失败或返回的页数不全时前半截不写。sweep 的事务先写 `anime_cache` 的戳再写角色行,和后台重置的加锁顺序一致。
- **接口不变**:`/api/anime/:id` 仍最多返回 25 个角色、25 个职员,按 display_order,字段不变。
- 后台「重置富化」会删掉全部角色,现在一并删掉它们的配音、清掉 sweep 的戳,让 sweep 把第一页之后的角色补回来。
- **两个 migration**:0042 建配音表、去重、建两个唯一索引;0043 给 `anime_cache` 加 sweep 用的四列。分开是为了让 `ALTER TABLE anime_cache` 要的排他锁单独排队,排队时不同时占着角色表和职员表;0042 里先建配音表(外键锁 `anime_cache`,只挡写不挡读)再动角色表,和后台重置的加锁顺序一致。在一个造了约 24 万角色行、28 万职员行的本机测试库上,两个迁移合起来不到 1 秒(0043 只有几毫秒)。
- **部署窗口**:deploy 是先 migrate、再 build 几分钟、再换容器,旧二进制的「整表删除再插入第一页」在新 schema 上照常可用,只跑完 0042 时也一样(迁移测试按旧语句原样跑过)。例外是 AniList 第一页把同一人、同一职位列了两次的少数番:旧二进制逐行插入、不去重,窗口内重拉到它们时第二份会撞上新的唯一索引,这部番的职员停在半截(主行已经盖了新鲜戳),等新二进制在 24 小时内的下一次重拉补齐。回滚 migration 会删掉第一页之后的行,因为旧代码读角色和职员不带上限。
测试:查询文本(每一页都在配音之前选 `node { id }`、不带语言参数、两种排序、`p1`–`p8` / `p9`–`p16` 别名、非法页段);客户端按别名解码、`Media: null` 与 HTTP 404 都是 404、少一页是错误不是空页、额度忙时不发请求;主配音按原产国 / 备注 / 退路的 11 种情形,以及芙莉莲 Stark 那种 13 位、10 种语言的配音只留日中韩、列在最后的中配不被挤掉、同语言超过 8 位时截断;真 Postgres 上两种写入模式(第一页重拉保留 sweep 写的行并顺延编号、整份写入删掉其余行和孤儿配音、没 id 的行不累积、`name_cn` 与 `voice_actor_cn` 的保留规则、职员空职位 upsert 两次不重复)和每一列的往返;走真实详情重拉路径的「sweep 写了 87 个角色,重拉后一个不少、接口仍返回第一页的 25 个」,把重拉改回整表替换这条会变红;sweep 的开关(8 种取值,关闭时不读候选、不请求 AniList)、分页、400 上限、节奏、忙时让路、429、404、失败倒填戳、第二个请求忙时退后一小时、页数不全不写,候选的顺序与过滤、标志变 true 清戳,以及事务失败时行和戳都不变;migration 0042 / 0043 各自单独和合起来的去重、旧写入路径、回滚。
### 先订阅、后打开的番,详情页不再空一天
订阅一部库里还没有的番时,`EnsureCached` 用详情查询向 AniList 拉了整份数据,却只存了主行,同时把它标成「详情已拉过」(#184 起)。详情页因此认为这部番新鲜而且完整,不会再去 AniList 拉:角色、职员、类型、标签、关联作品、推荐在 24 小时内全是空的,角色补全的 sweep 也接不住它,因为它只挑正好存满一页的番。
现在 `EnsureCached` 用同一份数据把子表一起写入,和详情重拉走同一个函数(`writeDetailChildren`),不多发请求。主行写失败照旧报错(订阅的外键要它);子表写失败只记日志、不挡订阅,留下的半截状态和详情重拉失败时一样。
测试:真 Postgres 上订阅填充之后,详情页直接读到 25 个角色、25 个职员、类型和关联作品,并且没有再请求 AniList,sweep 的「还有第二页」标志也已记下(改动前这条测试在旧代码上失败,四项全是空的);子表写失败不挡订阅,主行写失败仍然报错、也不写子表。
### Bangumi 文字里的 HTML 转义码不再原样显示;V3 不再把已有的中文名清空
- **转义码**:Bangumi 接口返回的番名、集名、简介、标签、角色名是 HTML 转义过的(`&`、`"`、`<`…)。我们原样存下,页面渲染时 React 再转义一次,读者看到的是「CAROLE & TUESDAY」「Ayu&Nina」。现在客户端解析完响应就统一还原(搜索、条目、角色、分集四个入口),还原到不再变化为止:Bangumi 上有几条集名被转义了两次(`&amp;`),意思都是原字符。已经存下的数据另行清理。
- **V3 清空中文名**:V2 发现条目没有中文名时会接着跑 V3,V3 原来无条件写 `title_chinese`,理由是「走到这里时标题一定是空的」。这只在第一次富化时成立:重跑 V2 时,标题可能来自管理员、弹弹play 补全或国漫的中文原名,V3 拿到空值就把它清掉了。改成 COALESCE:没拿到中文名就不动,拿到了照常写。
测试:客户端四个入口各一条还原测试,另有「转义两层」和「有次数上限」;真 Postgres 上覆盖 V3 的四种情形(有标题、条目无中文名 → 保留;无标题、有中文名 → 写入;有标题、有中文名 → 替换;都没有 → 仍为空)。修复前,「有标题、条目无中文名」那条会被清空。
### AniList→Bangumi 对照表:两条拼接路径说法不一的番不再收,每周刷新的 PR 列出每一处变化;AniDB 编号单独成表
对照表是拼出来的:Fribb 给 AniList ↔ MAL / AniDB,BangumiExtLinker 给 Bangumi ↔ MAL / AniDB。旧拼法先按 MAL 编号找、找不到才用 AniDB,一个编号挂着几个 Bangumi 条目时取文件里最后一个。同名剧场版、OVA 常常沿用 TV 版的 MAL 编号,于是不少 TV 版被配到了自己的剧场版上(AniList 170《灌篮高手》TV 版 → 剧场版,392《幽游白书》→《夜叉的阴谋》),也有配到无关番上的(179845《有兽焉 第四季》→《永生之海噬仙灵》),而且答案取决于上游文件的行序。对照表决定一部番的中文名、简介、分集标题从哪个 Bangumi 条目抄,也是这些通道的信任门槛,配错了就是公开页面上挂别的番的内容。
- **拼接规则**:MAL 和 AniDB 两条路都问。MAL 只指向一个条目、AniDB 没答案或也认这个条目,才收;MAL 指向好几个,用 AniDB 缩到唯一一个才收;只有 AniDB 时它必须只指向一个。两条路说法不一、或缩不到一个,这部番不进对照表;Fribb 把同一部番列了几行而几行说法不一,同样不收。结果与上游行序无关。
- **人工钉子** `go-api/cmd/bgmmap/overrides.json`:核对过的 AniList → Bangumi,盖过拼接结果,每条必须写理由;文件格式不对(包括合并冲突留下两个数组)整次刷新失败,不会悄悄丢掉一条。钉子要加在 main 上再重跑刷新:刷新从 main 生成并强推自己的分支,加在那个分支上的会被冲掉。第一批两条:315《小倩》(1997)钉在 1997 剧场版(Fribb 把它的 MAL 编号改成了 2024 年重映版那条);139986《妖神记 第五季》钉在第五季(BangumiExtLinker 把它的 MAL 编号挂在第六季上,开播日期对得上的是第五季)。
- **刷新 PR 的正文是生成程序写的报告**:钉子排在最前,列出拼接结果此刻怎么说(已经和钉子一致时提示可以删掉);改了、删了的每一部列出新旧 Bangumi 条目的名字和日期;被拒收的列出候选条目(两条路指向同一批时只列一次);AniDB 对照只给增删改的数目。超过 GitHub 正文长度上限前截在整行上、补上没闭合的 `<details>` 并注明截断,所以截断也截不掉钉子。上游番名里的 `#1`、`@` 会插一个看不见的连接符,免得在 PR 里链接到无关的 issue 或提及用户。机器人开的 PR 不触发 CI,所以刷新流程自己跑生成程序和加载器的测试。
- **AniDB 编号单独成表**(migration 0041 `anidb_id_map`,直接取 Fribb 的 AniList → AniDB,开机时和对照表一样整表替换)。磁力搜索的 AnimeTosho 整番订阅改读这张表:一部番的 AniDB 编号和 Bangumi 认不认它无关,留在同一行里的话,对照表拒收一部番就会顺带拿走它的订阅源;拆开以后,Bangumi 那边拼不上的番只要 Fribb 有 AniDB 编号也能用上。`bgm_id_map.anidb_id` 列保留、照写,不再有人读。
- 这次重新生成的对照表:新增 59、删除 105、改了 7 部(其中 144677《BASTARD!! 暗黑破坏神》从「第二部分」改到第一部分:AniList 这条是从 2022-06 开播的 24 集全篇)。
- 已经按旧对照表写进库里的绑定这次不动。被删掉的条目只是不再「确认」那些绑定:分集标题改走自己的名字与季号检查,中文简介不再从 Bangumi 写入,已经写入的不清;没有来源记录的老绑定从此要过 #205 的撤销检查,但那条规则只在名字不像且年份差 ≥2 年时才动手。新表答案不同的几部要另外复核改绑。
测试:`cmd/bgmmap` 拼接规则新增 11 条(每种拒收、AniDB 帮 MAL 缩小范围、MAL 帮 AniDB 缩小范围、Fribb 多行、一行可疑整部不收、拒收原因与 Fribb 行序无关、结果与上游行序无关),AniDB 对照 2 条(其中一条是「Bangumi 被拒收的番照样有 AniDB 编号」),钉子 5 条(含合并冲突留下两个数组、仓库里那份 `overrides.json` 必须能解析),报告 11 条(含真的走到截断:多字节长番名超限时截在整字上、不留没闭合的 `<details>`、钉子还在;`#`/`@` 不会变成链接或提及;2 万行删除时标题照写全数);`internal/bgmidmap` 新增三条(AniDB 嵌入解析、「AniDB 表不是对照表的子集」、对照表每一条都完整且只出现一次),真 Postgres 上新增一条:seed 两遍不重复、对照表里没有的番磁力输入照样带 AniDB 编号——把查询改回读 `bgm_id_map`,这条会变红。
### 今日更新:去掉左右按钮,换成行下一条可拖的细滑动条;甩动有惯性、停在卡片上;行尾说明今天结束
上一版在「今日更新」两端加了 ← → 圆按钮,它们压在封面上,像通用轮播。这一版去掉按钮,换成卡片行下面一条细滑动条(设计在画布上比过时间轴指针、表盘、安静边缘三案后定下):
- **滑动条,所有宽度都有。** 滑块宽度是这一行在屏内的比例(最窄 6%),位置是滚了多远。平时是一根 2px 的细线;鼠标移上去、按住或键盘聚焦时变粗并换成页面的颜色。按住滑块拖,按住的那一点一直在指针下;点轨道空白处,行平滑滑过去并停在一张卡上,按住接着拖就改成手跟着走。手机上可点区域 44px 高;手指在空轨道上松开且没移动才算点——从滑动条开始的竖向滑动是在滚页面,什么也不做。这一行放得下时滑动条保留位置但不画,量出来之后下面的内容不会跳。高对比度模式下轨道有边框、滑块用系统色,焦点照样看得见。
- **键盘和读屏**走滑动条前面一个看不见的 `<input type="range">`(取代了两个按钮,Tab 只多一站):← → 一张卡,Page Up / Page Down 一屏,Home / End 两端,平滑滚动中再按会从正在去的位置接着走(连按两下走两张)。它的值按停靠点计、不按像素,读屏加一就是一张卡;读出「第 a–b 部,共 n 部」。
- **甩动有惯性。** 桌面按住拖动、松手时还在动,行接着滑并逐帧减速,慢下来或撞到头就停;横向滚动(触控板)一来就让出,竖向滚轮滚的是页面、甩动照常走完。甩动中点一下只是停住,不会打开经过的那张卡(和原生惯性滚动一样)。拖动、甩动、拖滑块、点停之后,行都平滑停到最近一张卡对齐页边距的位置。拖动和甩动时不吸附,经过指针下的卡片不浮起。
- **行尾收尾面板**:「今天的番就这些」、明天的日期和部数(首页已有的 7 天放送表里取,明天没有就只写日期)、「看完整放送表」。它不是卡:没有状态、不计数。
- **入场**:这一行第一次进入视野时,卡片从左到右依次淡入上浮;加载时已经在屏幕上就不播,免得已经看到的内容闪一下。动画自己播完、不留隐藏状态,播完即撤(否则每到一部番开播,挪了位置的「现在」标记会再浮一次),`TodayRail.css.test.ts`「任何规则都不许隐藏卡片」的守卫照常通过。减弱动态效果时以上动画都不播。
- 只给按钮用的两个文案键(`todayPrevPage` / `todayNextPage`)从六个词典删掉,新增滑动条和收尾面板的 6 个键。
算术都在 `lib/home/railScroll.ts`(滑块尺寸和指针换算、停靠点、甩动的速度读取与衰减、读屏的「第几部」、入场顺序),接线在 `useTodayRail.ts`:滑块位置和 range 的值每帧直接写到元素上,不为挪一根条每帧重渲染整行卡片;`tomorrowOf` / `nextDayKey` 给收尾面板算明天。
测试:`railScroll.test.ts` 新增 31 条(滑块、拖滑块、停靠、甩动——包括「一次快甩在 1.5 秒内、合理距离里停下」——点停甩动不算点击、range 的停靠点序号、只有横向滚动接管甩动、可见范围、入场顺序),`time.test.ts` / `todaySlots.test.ts` 各加明天的用例;渲染测试换掉按钮那条,改为「没有按钮、滑动条有名字并控制这一行」「量出来之前不画也不占 Tab」「收尾面板的三句话和链接,且排在 现在 标记之后」「明天没有番只写日期」。sandbox e2e `today-rail.spec.ts`:按钮用例换成「拖滑块、点轨道(点在滑块以外的那一侧)、键盘(连按两下走两张、值按停靠点计)」,新增「快甩之后松手还在走、最后停在卡片上」「甩动中点击不打开卡片、竖向滚轮让甩动走完」「高对比度下滑动条和焦点都在」「第一次进入视野时入场、播完即撤」「行尾收尾面板」;手机新增「从滑动条开始竖着滑是滚页面、点空轨道才滑这一行」,滑动条可点区域至少 44px。测试先等这一行的 effect 跑完(滑动条第一次测量写入读屏值)再动手:dev 下水合和 effect 之间有明显间隔,提前动手会被开场定位打断。
### 首页「本季焦点」每 8 秒自动切换,按 WCAG 2.2.2 配齐暂停
首页首屏原来只靠五根短条和方向键切换(当时按 DESIGN.md「不要自动轮播」刻意没做)。现在每 8 秒切到下一部,DESIGN.md 那条规则同时改成「首页 hero 是唯一例外,且只按这些暂停规则」:
- 鼠标停在 hero 上、或键盘焦点在 hero 里时暂停,两者都结束后**接着剩下的时间**走。只认键盘焦点(`:focus-visible`):鼠标点过的按钮会一直留着焦点,按「任何焦点都暂停」算,读者按了「继续」hero 也不会动。触摸不算悬停。
- 短条后面多一个暂停 / 继续按钮,按一下一直停到再按;名字说的是按下去会做什么(暂停自动切换 / 继续自动切换,六个词典)。手机上是 44px 触控区,贴在这一行右端、不占文档流,出现时居中的短条不动。
- `prefers-reduced-motion` 下完全不自动切,也没有暂停按钮;水合前同样没有。
- 标签页在后台时不切,回来后这一部重新给满 8 秒。
- hero 整个滚出视野时也不切:焦点番决定整页底色,看不见的轮播仍会给读者正在看的区块换颜色。
- 点短条或按方向键切换后,新的那一部从头算 8 秒。
选中的短条变成同色的暗轨道,8 秒里被自己的颜色填满,暂停时停在原处——这是一段跟着暂停一起停住的 CSS 动画,倒计时看得见、组件不用每帧渲染。自动切换不进读屏的 live region(否则每 8 秒播报一次),手动切换照常播报。其余不变:五张一直挂着、切换只有 CSS 过渡、没走到的横幅不下载——只是现在自动切换前 2.5 秒先加载下一张,免得它在自己的淡入之后才蹦出来。
规则在 `lib/home/heroRotation.ts`(暂停原因、暂停时把已走的时间存起来、什么情况从头算、两个定时器),接线在 `useHeroRotation`:暂停状态来自事件回调和两个 `useSyncExternalStore`(标签页可见性;减弱动态效果,服务端按「是」算,所以服务端 HTML 是静止的),倒计时放在 ref 里,定时器在 layout effect 里挂上;effect 体里不同步 setState。悬停按鼠标此刻所在的元素判断(文档上的 `pointerover`),而不是等 hero 被离开:按下暂停 / 继续时光标下的图标节点被换掉,Chromium 在下一次移动时可能根本不给 hero 发 `pointerleave`(原生的和 React 模拟的都一样),只等 leave 的话,按了继续、鼠标移开,hero 仍停着;新目标上的 `pointerover` 总会发。鼠标移出窗口时没有新目标,仍由 hero 自己的 `pointerleave` 处理。
测试:`heroRotation.test.ts` 25 条;sandbox e2e `home-redesign.spec.ts` 原来那条「超过 5 秒也不自己换」改成 7 条轮播用例,跑在 Playwright 的假时钟上(页面加载时时钟照常走,之后暂停、手动拨),所以「8 秒边界」是精确的:自己会换、手动切换后重新给满 8 秒、悬停 / 键盘焦点 / 暂停按钮 / 后台标签页 / 滚出视野时都不换、减弱动态效果时不换也没有暂停按钮。把「切换不重新计时」「回到标签页不重新计时」「忽略悬停、焦点、视野」分别改坏,对应用例都会变红。
### 今日更新:桌面按住拖动、两端 ← →,手机不再折叠已播
「今日更新」是隐藏了滚动条的横向行。桌面鼠标滚轮滚的是页面不是这一行,看起来当天只有前几部;手机上已播的番折进一张「已播 N 部」的卡,而那条折叠规则是按卡片状态隐藏的——卡片每分钟重新归位,页面开着时一部番到点变成「已播」,就掉进折叠里、从行里消失了(「今天的番突然不见了」就是这个)。
- **每一部都是一张卡,任何宽度都不折叠。** 折叠卡、它的展开状态和样式都删了,只给它用的三个文案键(`todayAiredCount` / `todayShowAired` / `todayHideAired`)从六个词典里删掉。手机直接横滑,按卡吸附;「现在」标记仍在已播和未播之间。
- **桌面按住拖。** 只认鼠标主键(触摸本来就能横滑)。移动超过 6px 才算拖动:捕获指针、行跟着走,光标 grab → grabbing,拖动中不选中文字和图片、不吸附,浏览器自己的链接 / 图片拖拽被拦下。松手不会打开松手处的卡片:捕获了指针的那次点击落在行上,万一某个浏览器仍把点击给了卡片,拖动后的那一次点击也会被吞掉。普通点击照常打开。还没开始拖就在行外松开的按下(移出卡片取消点击、松在箭头上、右键菜单)会被丢掉,之后鼠标划过不会被当成拖动。
- **桌面两端 ← →**,一次翻大约一屏的卡,落在某张卡对齐页边距的位置;平滑滚动,减弱动态效果时瞬间到位。只在有鼠标、且这一行放不下时出现;到头时 `aria-disabled` 而不是 `disabled`,键盘焦点在按钮上按到头不会丢;变暗的是图标和底色而不是整个按钮(用透明度会把焦点环一起淡掉);`aria-controls` 指向这一行;焦点环是那一个蓝色。
- 打开时这一行滚到「现在」和下一部都在视野里(放得下时连带前一张已播的,手机上把「现在」放在边距处)——滚的是行自己,不用 `scrollIntoView`。
- 每张卡的播出时间是 `<time datetime>`。
算术(拖动阈值、点击要不要吞、在别处松开的按下、翻页落点、打开位置)在 `lib/home/railScroll.ts`,24 条单测;`todaySlots` 加一条:时钟走过播出时间时每一部都还在,只换状态。渲染测试 6 条(时钟走过播出时间前后卡片数不变、那一张变成已播、没有折叠);`TodayRail.css.test.ts` 3 条,检查这一行用到的样式表里没有任何宽度下隐藏卡片的规则——它在旧的折叠规则上是红的。sandbox e2e 新增 `today-rail.spec.ts` 4 条:桌面拖动会滚、松手不打开卡片(包括在同一张卡上开始和结束的短拖动)、普通点击仍打开;← → 用鼠标和键盘都能翻、到头 `aria-disabled`;390px 手机上卡片数等于标题旁的数字、全部可见、没有箭头、触摸滑动能滚;以及假时钟拨到某场真实播出前 2 分钟、再往后拨 3 分钟,那张卡变成「已播」、仍在行里可见、卡片总数不变。在手机宽度隐藏已播卡片会让两条手机用例变红。
### 「全部在追」:一个用「继续看」大卡列出全部在追番剧的页面
首页「继续看」区块头和放送表「我追的 · 本周」(超过 8 行时出现)的「全部在追」原来链到 `/profile`——带状态标签的通用「我的追番」页,和名字说的不一样。现在两处都打开新页 `/watching`:读者在追的每一部,用「继续看」打头的那张大卡(横幅、状态、上次观看、进度、下一集、「继续看第 N 集」)。`/profile` 不变,页头右侧「管理追番」一键可达。
- 和首页一样 `force-dynamic` 服务端渲染,在服务端带读者的 cookie 取在追列表(和首页同一个 `fetchWatching`),渲染期不读 cookie,浏览器里零请求。
- **登录门和 `/profile` 一样在 `proxy.ts`**:访客直接去 `/login?from=/watching`(保留语言前缀),不会看到空壳;按路径段匹配(`/watching`、`/watching/…`)。代理放行了但接口拒绝的会话由页面自己同样送去登录;这一页没有 `loading.tsx`,所以这个跳转是真正的 307。
- `fetchWatching` 现在多说一句失败是会话问题还是接口问题(`unavailable`)。首页和放送表照旧都按访客处理;这一页在接口失败时说「没加载出来」,不当成「你没有在追的番」。
- 顺序和「继续看」一致(接口的顺序,最近更新的在前,统一走 `continueCards`),所以这一页的前三张就是首页那三张。桌面两列,≤900px 一列,是一个列表;每张卡带自己那部番的颜色(颜色串照旧在 TS 里拼好写在卡片上),整页穿第一部有色相的番的颜色。前两张卡的横幅优先加载。
- 空的时候说「还没有在追的番剧」并链到本季。和首页一样放在 `SignedOutGate` 里:不跳页的登出会把列表换成登录提示。`noindex`;标题 全部在追 / Watching / 全部在追;7 个新文案键进了全部六个词典。
- ★ **登出后按浏览器后退,不会再看到上一位读者的列表。** Next 在前进 / 后退时复用缓存的页面,`SignedOutGate` 原来只在挂载期间听登出事件,从缓存里重新挂载的「继续看」和这一页会从「未登出」开始、把标题和进度又显示出来,而且不经过服务器。现在登出会记在当前文档上(`hasSignedOutHere`,按 window 记),`SignedOutGate` 用 `useSyncExternalStore` 读它,不再是 effect + setState;重新登录是整页跳转、本来就是新文档,所以不需要清除。首页「继续看」一起受益。
为了共用,「继续看」的两种卡片原样搬进 `components/home/ContinueCards.tsx`(`ContinueFeatureCard` / `ContinueCompactCard`),首页的标记没有变化。
测试:页面状态和取色(`lib/watching/view.ts`)7 条;登录门 5 条(无会话、已登录、过期会话、路径段边界、带语言前缀的跳转);`continueCards` 保持顺序 2 条;渲染测试 8 条(每部一张卡且按顺序、进度 / 下一集 / 继续看 / 颜色都在、空状态链到本季、失败时说失败也不显示数量、两处「全部在追」都指向 `/watching`);登出记在文档上 2 条。sandbox e2e 新增 `watching.spec.ts` 3 条:登录后只列出在追的三部(不含看完的那部),每张卡的进度、下一集、继续看和颜色都对,首页「继续看」按同样的顺序排在前面、它的「全部在追」打开这一页;追了本周九部的读者,放送表「我追的 · 本周」的「全部在追」打开这一页、九张都在;未登录访问 `/watching` 和 `/en/watching` 都去登录页并带着回来的地址。
### 全站顶栏:仿 AniList 的三栏结构,手机改成 ☰ 抽屉
所有页面的顶栏换成设计稿上的那一版。
**桌面(≥1024px)。** 三栏:左边字标,中间链接相对页面居中,右边搜索和账户。链接是 首页 · 放送表 · 季度 · 分类▾ · 关于,登录后在「关于」前多一个「我的追番」;`/library` 仍不进顶栏(在头像菜单里)。当前页白字并带 `aria-current`,比对的是去掉语言前缀的路径,所以 `/en` 下也成立;季度在任何一季的页面都算当前,分类在任何类型页都算当前。
- **分类▾** 是 AniList 式下拉:鼠标停一下就开、移走就关;点击 / Enter / Space 打开并保持,Esc(焦点回到触发器)、点别处、焦点移出都会关;淡入下移。里面是全部类型 hub,按读者语言显示——没有类型索引页,这个下拉就是索引,而且每页的 HTML 里都带着这些链接。
- **搜索**收成右侧的放大镜,点开向左展开成输入框,展开时中间的链接让位。Enter 去 `/search?q=…`(空框去 `/search`),Esc 收起并把焦点还给放大镜;**中文 / 日文输入法确认候选的那次 Enter 不会提交**(Safari 先结束输入法再发按键,只看 `isComposing` 会漏,要同时看 keyCode 229)。输入框只在展开时存在,`/search` 页上自己的搜索框仍是页面里唯一的那一个。
- **页面顶部透明、滚动后变毛玻璃**;往下滚收起、往上滚滑出。有下拉或抽屉开着、键盘焦点在顶栏里时不收起;键盘焦点落进一个已收起的顶栏时,顶栏立刻出现(不滑),页面不动;减弱动态效果时没有滑动动画。首页的首屏现在从页面最顶端开始、伸到透明顶栏下面,首屏内容的位置和之前一样,下面的区块一个像素都没动。
- 通知铃改成未读小圆点(数字还在无障碍名称里,鼠标悬停也看得到;Windows 对比度主题下这个点用系统高亮色画,不会跟底色一起消失);头像是 32px 圆形加下箭头,菜单内容和登出流程不变。
**手机和平板(<1024px)。** 顶栏是 ☰ · 字标 · 搜索 · 登录(或通知铃 + 头像)。断点取的是桌面顶栏放不下的宽度:最宽的形态是英文、未登录,大约要 1000px。☰ 从左侧拉出抽屉,页面压暗;抽屉是真正的模态窗口——焦点移进去、Tab 出不去、Esc / ✕ / 点暗处 / 点抽屉里的链接都会关并把焦点还给 ☰(页面原地不动),浏览器前进 / 后退换页时也会关、但焦点不拉回 ☰(已经换了一页);页面在抽屉开着时不滚动。抽屉里是同一份链接(分类展开成类型列表)、语言切换,访客还有登录 / 注册。
**登录态逻辑一行没改**:匿名 / 探测中 / 已登录三态、没有 `auth_hint` 就不发任何鉴权请求、cookie 只在 effect 里读、登出只在接口返回成功后才算数、登录链接的 `?from=` 仍在自己的 Suspense 里(不影响 `/anime/*` 的静态预渲染)。探测中显示的是和通知铃、头像**同尺寸**的占位,探测结束什么都不跳;以前头像占位的尺寸是手抄的,现在直接用头像按钮自己的样式类,不可能再对不上。
★ **顶栏其实一直没有吸顶。** `html, body { overflow-x: hidden }` 会把 `<body>` 的另一轴算成 `auto`,`<body>` 于是成了一个从不滚动的滚动容器,`position: sticky` 就粘在它里面——线上实测滚 529px 后「吸顶」的导航在 y = −529,跟着页面滚走了。`<body>` 改成 `overflow-x: clip`(裁掉同样的溢出、不建滚动容器)之后,顶栏和站上其它几处 sticky(设置页预览、后台页头、`/welcome` 的那一栏)第一次真正生效。后台页头改成粘在顶栏下面,免得被顶栏盖住;顶栏往下滚收起时它跟着上移补到最上面,不在上方留出一条滚动的页面。
其它:顶栏高度成了一个变量(`--nav-h`,桌面 64px、<1024px 56px),满屏页面、设置页预览、`/welcome` 那一栏都改读它,`html` 加了 `scroll-padding-top`,锚点和页面里被聚焦的控件不会落到顶栏下面。★ 顶栏**自己的**控件就在这条带子里,所以都带同样高度的负 `scroll-margin-top` 抵消它,脚本里的焦点移动也加了 `preventScroll`——不然聚焦它们(Esc 把焦点还给触发器、搜索框展开、关抽屉后焦点回 ☰、Tab 进顶栏)浏览器会去「露出」本来就在屏幕上的控件:吸顶的顶栏不动,页面却被滚走几百像素,关抽屉时直接回到顶部。「用简体中文浏览?」那条语言提示原来居中压在新首屏的第一行上,现在浮在顶栏下方右侧(首屏的横幅那边),手机上是顶栏下的整行,层级低于顶栏的下拉菜单。行内样式对象和那段带 `!important` 的样式字符串都换成了 CSS Module,焦点环全部是那一个蓝色 box-shadow,外加一圈透明 outline:Windows 对比度主题(强制色彩)会去掉所有 box-shadow,那时由系统色画出的 outline 就是焦点指示,搜索框在那种模式下用浏览器自己的焦点框。语言选项和其它弹层条目一样是 40px 一行。新文案 7 个键进了全部六个词典。
测试:`lib/nav/` 五个纯逻辑模块 61 条单测(滚动收起的状态机、链接清单与当前页、悬停 / 按下两种打开方式谁能关、模态的 Tab 循环、搜索地址与输入法判断);sandbox e2e 新增 `nav.spec.ts` 17 条,每条交互前先等水合。其中一组专门量页面有没有动:在页面中段打开搜索,从搜索 / 分类 / 语言 / 头像菜单按 Esc,Tab 进显示着和收起着的顶栏,用 ✕ / 点暗处 / Esc 关抽屉——之后 `scrollY` 都不变,这些断言在修之前的代码上全部失败;锚点和顶栏下面被聚焦的控件仍然落在顶栏下面。`admin.spec.ts` 量后台页头跟着顶栏上移、回位。焦点环检查(`focusRing.test.ts`)认得 `:has()` 了:`X:has(:focus-visible)` 只有画了东西才算焦点指示。之前这里写过「Playwright 的 `locator.click()` 会先把吸顶元素滚进视野,页面被拉回顶部」——不对:跳的是页面自己(上面那条 `scroll-padding-top`),没有它的页面同样的点击纹丝不动;测试现在故意用 `locator.click()`。
### 放送表页重做:整页跟着选中的那一天换颜色
`/calendar` 用首页同一套「逐番色」重写。自上而下:当季 · 标题「放送表」· 本周更新数和时间口径 → 七天标签 → 选中那天按播出时间分组的列表 → 右栏:本周柱状图、访客的登录说明或登录用户的「我追的 · 本周」、下一季新番入口。导航栏和页脚没动。
**颜色。** 整页穿选中那一天评分最高的**有色相**的番(同分取先播的;整天没有一部有色相才是中性灰——和首页首屏「有色相的排在前面」是同一个决定)。切换星期时地色、选中标签、柱子、「现在」线一起过渡 0.9s;每一行是它自己那部番的颜色。颜色字符串照旧在 TS 里拼好、写在用它的元素上。行底和选中标签是叠在地色上的白色罩层,罩层只会拉低对比度,所以在 `tone.test.ts` 里对 360 个色相单独证明;文字最浅用到 58% 白,设计稿给已播时间的 50% 在最亮的罩层上只有 4.46:1。
**列表。** 七天从接口的「今天」往后排,没有番的那天也有标签;接口失败时不画七个 0,只说一句没加载出来(这时登录用户也不显示「我追的」,免得说成「这周没有更新」;决定在 `lib/schedule/aside.ts`)。同一分钟播出的番归成一组;今天的面板里,已播和未播之间有一条呼吸的「现在 HH:MM」,一小时内的显示「N 分钟后」,午夜后的深夜档标「次日」。七个面板全部服务端渲染、未选中的 `hidden`:一周的 `/anime/<id>` 链接都在 HTML 里(原来只有当天的),切换星期不发请求。标签是完整的 tablist(方向键、Home / End,选中即聚焦),每个面板进 Tab 顺序。焦点在 Windows 高对比度(forced-colors)下也看得见:每条 `:focus-visible` 留一圈 2px 透明 outline,那个模式下由系统颜色画出来。行上的评分前有一段只给读屏的「评分」——旁边的星号是 `aria-hidden` 的,不加就只念出一个数字。
**时间。** 同首页:服务端按渲染时刻和 `Asia/Shanghai` 出字,水合后换成浏览器时钟和时区。副标题的「北京时间(UTC+8)」在浏览器不在 UTC+8 时跟着改成「你的本地时间(UTC−7)」。
**窄屏和手机。** 设计稿只有桌面画板。≤1100px 右栏的框排到列表下面、柱状图不显示(标签上已经有数量)。断点不放在 900:两栏时一行要让出 300px 的右栏,在追的番的「在追 / 未看」再占一列,901–1000 之间标题只剩几十像素。≤900px 七天标签变成一排横滑的 44px 胶囊(今天带一个点),选中的胶囊靠滚动这一排自己露出来,不用 `scrollIntoView`(它会连页面一起滚)。时间列在手机布局以上保持 96px,「次日 01:00」「Tmrw 01:00」不会挤进旁边的卡片。
**登录态。** 「我追的 · 本周」列出读者在追、这周会播的番(最多 8 行,再多链到 `/profile`),每部一个状态:未看 / 已看 / 落后 N 集 / 待更新;列表行上也标「在追 / 未看」。数据是页面在服务端带 cookie、和放送表一起并发取的在追列表,不是客户端孤岛;访客在浏览器里零请求。★ 页面因此按读者渲染:和首页一样,这页的 HTML 不能进 CDN 缓存(源站回的是 `private, no-cache, no-store`,部署前确认 CF 没有规则覆盖 `/calendar`)。
**代码。** 纯逻辑在 `lib/schedule/`(七天、按分钟分组、追番状态、时区说明、胶囊行滚动、季度、右栏放哪个框),组件在 `components/schedule/`;复用首页的地色 `.scope`、`useHomeClock`、`tone.ts`、`todaySlots` 和标签方向键逻辑。`ScheduleItem` / `ScheduleResponse` 从旧组件挪进 `lib/types.ts`,首页和放送表共用一个取数模块(`lib/schedule/fetch.ts`)。`WeeklySchedule` 没有别的使用者,删除。每一行的 view model 逐个字段列出来,不展开 API 行,也不展开首页的 `todayCard`——它们一周的每一档都进 RSC 数据。SEO 不变:`generateMetadata` 原样,`<h1>` 仍是原来的「今日新番放送日历」(视觉隐藏,可见标题是「放送表」)。新文案进了全部六个词典。
已知局限(和首页「今日更新」同源):七天按接口的服务器日(UTC)分组,UTC 以西的读者会在某天的标签下看到实际属于前一天的时间,没有「前日」标记。
测试:`lib/schedule/` 八个模块 68 条单测,`components/schedule/` 两个渲染测试 6 条(放送表失败时登录用户看到「没加载出来」而看不到「这周没有更新」、访客仍有登录说明;评分前有读屏标签),`time.test.ts` 加 4 条、`tone.test.ts` 加 8 条(罩层对比度);全量 `bun test` 2230 条通过,`tsc`、`eslint` 无问题,生产构建通过,390 / 768 / 920 / 960 / 1024 / 1440 宽度无横向溢出,601–1440 之间时间不超出时间列。sandbox e2e 新增 `schedule-hue.spec.ts` 4 条:点标签或柱子后只剩那一天的列表、页面颜色来自那一天的番;「现在」只在今天;手机上用 End 选中最后一天时胶囊行滚出它而页面不动。点击前先等水合;「页面颜色钉在今天」「不滚胶囊行」「每天都画现在」三处变异分别打红对应的用例;单测另外抓得住五处:「现在」按番数而不是按时间组算、同一分钟里播了一部就算整组已播、「我追的」按 id 排、整行 API 数据摊进 view model、放送表失败时仍画空的「我追的」。
### 首页重做:整页穿上焦点番的颜色
首页从头重写。顺序是 首屏 → 继续看(登录)/ 我的库说明(访客)→ 今日更新 → 本季高分 → 按色调逛 → 大家都在追 → 完结佳作 + 年度榜。导航栏和页脚没动。
**颜色。** 每部番用自己封面的 OKLCH 色相:首屏焦点番决定整页底色、区块短线和文字链接的颜色,切换焦点时整页 0.9s 过渡;每张卡片带自己那部番的颜色。颜色字符串在 TS 里拼好(`lib/home/tone.ts`),直接写在用它的元素上,不再用 `--poster-hue` 让 CSS 推导——一页几十个色相,DESIGN.md 陷阱一节那种「`var()` 在声明处替换、派生色静默失效」在结构上就不可能发生。品牌回落紫 `#8B5CF6` 不是这部番的颜色,当作无色相处理:渲染成中性灰,也不进「按色调逛」的任何分组。文字与背景的每一对组合在 `tone.test.ts` 里对 360 个色相穷举证明 ≥ 4.5:1。首页的有色深底是对全站 `--bg: #000` 的例外,和详情页一样在 DESIGN.md 里单独立了一节页面规范。
**首屏。** 本季前五部(有色相的排在前面——首屏第一张决定整页的颜色,无色相的封面打头会让首页一打开就是灰的),一次聚焦一部:封面模糊成整片背景光(遮罩在首屏底边前淡出,不渗进下一节)、横幅从右侧淡入、左侧小封面、标题逐字(拉丁文逐词)模糊浮现、类型、评分、放送状态(排期里有就带下一集时间)、三行简介,两个按钮——全页唯一的实心按钮「查看详情」用番剧色,「追番」是玻璃档。**没有自动轮播**:五根短条切换,每根是那部番自己的颜色,支持方向键。五张一直挂着,切换只有 CSS 过渡、不重新挂载;没聚焦过的横幅不下载。`prefers-reduced-motion` 关掉全部动画和过渡。
**时间。** 「现在」标记、「N 分钟后」、首屏的下一集时间:服务端按渲染时刻和 `Asia/Shanghai` 出字,水合后换成浏览器时钟和时区(`useSyncExternalStore`,不用 effect + setState)。时钟字符串只用 `Intl` 的数字部件拼,服务端和浏览器水合时一致。放送表接口按服务器所在日分组,午夜后的深夜档留在当天组里、标「次日」。
**各区块。** 今日更新是一条被「现在」切开的横向轨道(已播打勾、一小时内的带呼吸点,手机上已播的折进一张卡);本季高分悬停时旁边弹出纯 CSS 的信息浮层,右两列往左弹;按色调逛把本季分成七个色系,一次只显示一组,默认显示部数最多的一组(手机上那颗胶囊自动滚进视野,滚的是胶囊行本身,不带着页面动);大家都在追的大号排名数字各用那部番的颜色;完结佳作「换一批」逐张淡入。手机不显示完结佳作;「先试试播放器」只在桌面出现(`/player` 要 File System Access,手机浏览器没有)。
**数据。** `page.tsx` 把「正在追」列表和其它接口一起并发拉,不多一次往返;仍是 `force-dynamic` SSR,客户端只有界面状态。每个区块拿到的是服务端算好的精简卡片数据(`lib/home/viewModels.ts`),不是整行 API 数据。`lib/types.ts` 补上了 go-api 早就在发的字段(订阅上的横幅、强调色、番剧状态;季度行上的横幅、Bangumi 评分和简介通道)。新文案 54 个键进了全部六个词典。
**撤下的东西。** 「正在热议」和动态流离开首页(组件还在)。首页正文里通往 `/welcome` 的那张卡就在「正在热议」板块里,跟着一起撤下;`/welcome` 仍可从导航栏的「关于」和页脚进入。★ 目前没有别的页面渲染 `HotDiscussions`,所以部署后 `hot_discussions_impression`、热议点击和 welcome 卡的两条社区埋点都不会再发出,后台对应的曲线会归零——这是撤掉板块的直接结果,不是埋点坏了。`HeroCarousel`、`TrendingSection`、`CompletedGems`、`SeasonRankings`、`RankingMeta`、`WatchingEmptySwap` 没有别的使用者,删除。
追番按钮和海报角上的 + 走同一条流程:写入、带撤销的提示、未登录跳 `/login` 再回来,都抽进了 `useQuickSubscribe`,`QuickSubscribeToggle` 外观不变。一处行为变化:订阅列表还没加载完时按下去,现在是忽略,不再当成未登录。
★ **CSS Module 的加载顺序在这个分支上坑了两次,都是 `next dev` 对、生产构建错。** 两个 module 里的单类选择器优先级相同,谁赢看哪张样式表后加载,而 dev 和生产构建的加载顺序不一样。第一次是共享的卡片基础规则压过了「继续看」小卡自己的规则(小卡在生产上变成竖排)——基础规则改成 `:where()`,零优先级,谁都压得过它。第二次是首屏下第一个区块的 36px 间距在生产上丢了——修饰类挪进被它覆盖的规则所在的同一个文件。以后首页再写「覆盖别的 module」的样式,放同一个文件里,或者让被覆盖的一方用 `:where()`。
焦点环检查(`focusRing.test.ts`)改成只看 `:focus-visible` 落在被样式化元素本身上的规则:「触发器聚焦时显示提示框」那种规则不是焦点指示。
测试:`lib/home/` 七个纯逻辑模块 119 条单测;sandbox e2e 新增 `home-redesign.spec.ts` 2 条(首屏超过旧的 5 秒间隔也不自己换、短条和方向键切换后只有焦点那张对辅助技术可见、页面底色跟着焦点番变;按色调逛始终只有一组被按下),点击前先等水合。全量 `bun test` 2138 条通过,`tsc`、`eslint` 无问题,生产构建通过,桌面 / 手机 / 1024 / 768 宽度截图检查无横向溢出。
### 部署后不再从冷缓存起步;nginx 跟得上重启后换了地址的容器
- **图片优化结果跨部署保留。** next-app 的 `.next/cache/images` 挂成命名卷 `next-image-cache`:每次部署都会重建容器,之前处理过的图片会一起丢掉,部署后每张图都要重新优化一遍。只持久化 `images/`——`.next/cache` 里的数据缓存(fetch-cache)刻意不留,它不应该活过写下它的那次构建。镜像里预先建好这个目录并交给运行用户:新命名卷的属主取自镜像里挂载点那个目录,目录不存在时卷是 root 的,优化器写不进去。
- **nginx 缓存 `/_next/image`。** CDN 不缓存这类按 `Accept` 变化的响应,所以之前每次图片请求都会打到 next-app。缓存键是「地址 + 协商出的图片格式(avif / webp / 原格式)」,而不是原始 `Accept` 头;`proxy_cache_lock` 让同一张图的并发请求只回源一次;上游出错或忙时先给旧条目。上游的 `Vary` / `Cache-Control` / `Expires` 被忽略,`Set-Cookie` 不忽略:带 cookie 的响应一律不进缓存。这个 location 刻意不加 `add_header`:nginx 里一个 location 只要声明了任何 `add_header`,就不再继承 server 级的安全响应头。
- **nginx 在运行时重新解析上游地址。** 三个上游(next-app / go-api / ws-server)改为 `zone` + `server … resolve`,配 Docker 内置 DNS `resolver 127.0.0.11 valid=10s`。之前地址只在 nginx 启动时解析一次,任何一个容器重启换了 IP,nginx 会一直往旧地址发,直到 nginx 自己重启。
- **容器内存上限上调**,给缓存冷启动和重新填充时留余量:next-app 512m → 1g,go-api 200m → 512m,postgres 400m → 1g,nginx 64m → 256m。
验证:仓库里的 nginx 配置用与生产同系列的 nginx(1.31)跑 `nginx -t` 通过;`docker compose config` 通过;命名卷的属主用与运行阶段相同的最小镜像对照实测——预建目录时以运行用户写入成功,不预建时 `Permission denied`。
### 番剧中文名:旧绑定指向别的番时不再照抄,而是撤销重绑
2026 秋季榜前六里有三部续作挂着别家续作的中文名:《ブラッククローバー 第2期》显示「Love Live! 第二季」,《アオのハコ Season2》显示「卡片战斗先导者 will+Dress 第二季」,《アオアシ 第2期》显示「爱丽丝侦探局 第2期」。错的不只是标题——评分、分集名、繁体标题(OpenCC 从这个错标题转出来)都是那部番的,而且出现在可被索引的详情页上。
**源头是 0011 之前的匹配器。** 它用日文原名搜 Bangumi,结果里没有名字完全相等的就取 `list[0]`。Bangumi 的旧搜索是按词命中的,续作在自己的条目还没建之前去搜,「アオアシ 第2期」返回的第一条是只共享「第2期」三个字的《アリス探偵局 第2期》(1996)。那个匹配器早已换掉,但它留下的绑定没有 `bgm_match_source`(这列是 0011 才加的),之后的每一步都无条件信任它:V2 把条目的 name_cn 写进 `title_chinese`、评分写进 `bangumi_score`、分集名写进 `anime_episode_titles`;V3 用它重写 `title_chinese`;Bangumi 评分 sweep 每季度对当年的行再刷一遍分。现在的 V1 打分器对这三条的结论是对的(两部会绑到正确条目,一部拒绝绑定)——问题全在下游照抄。
**修法:抄之前先问一句「这是不是同一部」。** V2、V3、评分 sweep 在写入任何来自条目的数据之前,读出这一行的绑定身份;只对**没有任何东西担保过**的绑定(没有记录来源、没有人工修正、id 映射表没有点名这一对)做判断,要求两个**互相独立**的信号同时成立:
- 条目自己的 `name` 与 AniList 的三个标题(原名、罗马字、英文)都不相似(低于 `titlematch.SimilarityFloor`)。**从不比 `title_chinese`**:错绑行上它已经是错条目的 name_cn,拿它去比就是用错误验证错误;
- 条目的放送年份与 AniList 的年份(`season_year`,缺则取 `start_date` 的年)相差两年以上。
两条都成立才撤销绑定(新语句 `RepudiateLegacyBangumiBinding`),连同从它抄来的一切:`bgm_id`、`title_chinese`、评分、由这个标题转出来的 OpenCC 繁体名、`episodes_bgm*`、Bangumi 标签、按来源撤回的 Bangumi 分集名(manual 和 ddp 的那一半保留),然后把行放回 `bangumi_version = 0`,由每小时的孤儿扫描交给 V1 重绑。V1 的绑定一定记录来源,所以同一行不会第二次走到这个检查,不会循环。任何一侧缺数据都不下结论。
刻意**不**用季号做信号:`titlematch` 的季号提取还不认识「第2シリーズ」「パート2」这类写法,还会把「Lupin III」里的 III 读成第三季——拿它触发删除数据,会撤掉正确的绑定。0011 之后由 id 映射或 V1 打分器做出的绑定也不在检查范围内:它们在绑定时已经比过一次,而且 V1 比的标题更多、门槛更高。读身份失败按错误重试,绝不当作可以照抄;撤销写入失败时同样不抄。已经错了的存量行要另做一次性修复,不在这次代码里。
测试:生产上那六行(三错三对)原样做夹具,V2 / V3 / 评分 sweep 三个 worker 各跑一遍(修复前三错全红、三对全绿),判定函数每个守卫一条用例(含一年与两年的边界),SQL 语句在真 Postgres 上验证清理范围和五种拒绝(绑定已变、有来源、人工修正、id 映射点名、数据集来源的繁体名不动)。判定与接线的 15 处变异、SQL 守卫的 7 处变异全部被抓到。
### 本地库:合并之后两张卡一起消失
把同一部番的旧卡手动合并进新卡,下次打开库时两张卡连同底下所有集数一起不见了。文件都还在、索引也在,只是界面上哪里都点不到。
**成环了。** 自动去重扫描(每次进库都跑)按 `anilistId` 分组、总是把新行合并进**最早**那一行,而且读的是全部 series,包括已经被合并隐藏的。读者刚做的「旧 → 新」(新卡 `mergedFrom: [旧]`)于是被它反向再做一遍(旧卡 `mergedFrom: [新]`)。`performMerge` 只问「源是不是已经在目标的列表里」,从没问「目标是不是已经在源的卡里」,照写不误。而 `useLibrary` 的隐藏规则是「出现在任何 `mergedFrom` 里就隐藏」,环上每个成员都被别的成员列着——整个环一起消失,没有一张卡留下来装它们。
两半修法,缺一不可:
- **不再成环。** `performMerge` 在写入前检查目标是否已在源的卡里(沿 `mergedFrom` 传递闭包,含自合并),是就当作「已经是一张卡」返回 null。卡片菜单、批量合并、系列页、去重扫描四个入口都走这一处。
- **已经成环的库自己修好。** 去重扫描开头先跑 `repairMergeCycles`:每轮切掉一条在环上、且**最后写入**的 `mergedFrom` 项(按 override 行的 `updatedAt`,同时间取列表里更靠后的,因为只追加),再重新判断,直到无环。不在环上的合并一条不动。在上面那个场景里,最后写入的正是扫描那一笔,切掉它就把卡还给读者选的那一行。修过的环会让观看进度对账重新跑一次。
隐藏规则抽成了 `resolveMergedIds.ts` 的 `mergedAwayIds`,`useLibrary` 和测试用的是同一份,不是各写一份。`resolveMergedIds.ts` 里「环不可能发生」的注释是错的,改掉了。
测试:新文件 `mergeCycles.test.ts` 12 条、`mergeOps.test.ts` 4 条,`dedupeSeries.test.ts` 加 3 条(读者的旧→新合并不被反转、磁盘上已有的环被修好且不再复发、三份副本其中一份已手动合并最后只剩一张卡装全部三个),sandbox e2e 加 1 条(IndexedDB 里种一个互相合并的环,打开 `/library` 应恰好一张卡、存储里环已断、合并本身还在)。拆掉守卫、拆掉修复、把「切最后写的」改成「切最早写的」三处变异分别打红 5 / 1 / 5 条;e2e 在撤掉修复的代码上得到 0 张卡,与读者看到的一致。
### 详情页的 AniList 额度:旧行刷新不再排队,不存在的 id 记一天
`/api/anime/:id` 的 502/503 全出在**冷 id**(库里没这一行)上——有行的 id 重拉失败会退回旧行,冷 id 没有可退的。而冷 id 里绝大多数是 AniList 本身就不存在的 id,答案本该是 404。它们 502 的主因不在 AniList:全站共用一个令牌桶(2.1s 一个、burst 1),`limiter.Wait` 在预计等待超过请求剩余的 5 秒时立刻报错,队列深到第三个就有人 0ms 拿 502。占着队列的是「`cached_at` 超过 24h 就在读路径上内联重拉」——目录里近一半的行过了这条线,爬虫走一遍目录,这一半就各排一次队。
三处改动:
- **只是旧的行不排队。** 新的 `anilist.Client.DetailNoWait` 用 `limiter.Allow()`,令牌不在就返回 `ErrBudgetBusy`,什么都不发、不碰熔断器;429 不重试直接跳熔断。详情页拿到 busy 就返回已读到的旧行(Debug 日志),真失败仍记 Warn。**不完整的行**(只经列表接口写入、没拉过详情,trailer 没问过,老形状的角色/关联)照旧走排队的 `Detail`:它们每行只需一次,而从忙的额度退回去的是一张没有角色、staff、关联的骨架页,还会被缓存一小时。
- **AniList 说不存在的 id 记 24 小时。** 进程内 ristretto,只在冷 id 分支写,只认 `Media: null` 和上游 404;429、超时、其它上游错误不记,否则一次暂时的 503 会变成一天的假 404。读取顺序是缓存 → DB → 负缓存 → AniList,别的写入方把行写进库之后负缓存自动失效。
- **同一冷 id 的并发请求只打一次上游**(singleflight)。这次调用用 `context.WithoutCancel` 派生、自带 5 秒上限,发起它的请求断开不会连累一起等的请求;每个请求仍在自己的截止时间放弃。
冷 id 在 AniList 限流时仍是 503,这是有意保留的:「AniList 不可用时本地判 404」会对刚上 AniList、我们还没收录的新番答错,而 404 会被 CF 缓存;503 不会被当成结论。
顺手删了 `detail.go` 的 `refetchTimeout`:15 秒套在已带 5 秒截止时间的 ctx 上,子 ctx 活不过父 ctx,从没生效过(Sentry 里是 `duration_ms: 5001`)。`client.go` 里三处「700ms」注释改成引用 `minInterval`,`detail.go` 包注释换成新的流程图。
★ **singleflight 的 `DoChan` 在自己的 goroutine 里重新抛 panic**,`httpmw.Recoverer` 接不住——闭包里不 recover,一次 panic 就不是一个 500 而是整个 go-api 退出。现在闭包内 recover 成 500。★ 负缓存的 ristretto 显式定了尺寸(1e5 counters / MaxCost 1e4 / `IgnoreInternalCost`),实测空时 0.69MB、装满 1 万个 id 2.10MB;默认 `Config{}` 一个 Set 都没做就分配 50.6MB 堆。不开 `IgnoreInternalCost` 的话,同样的 MaxCost 只装得下约 175 条,其余被静默拒收,容量测试把这一点钉住了。
测试:`client_test.go` 4 条、新文件 `detail_budget_test.go` 13 条(两条是表驱动,共 6 个子用例),原有 stale 测试按「排队 / 不排队」分开断言(其中两条名字写「只因 cached_at 过期」、数据却缺字段,原来是碰巧通过的,补齐了)。关键行各做了变异,15/15 全部被抓到;并发用例 `-cpu=1,4` 各跑 50 轮无失败。
部署后(`3dc7ff4`):`/version.json` 为 `3dc7ff4b7d97`,源站本机和经 CF 各测一轮,耗时取 go-api 日志的 `duration_ms`。
- **不存在的 id**:第一次 308ms(问过 AniList 后 404),之后 0ms(命中负缓存)。CF 对 `/api` 是 `DYNAMIC`,第二次确实到了源站。前端 `/anime/<id>`、`/en/anime/<id>` 都答 404,不是 500。
- **10 个并发请求打同一个新的不存在 id**:全部 404。源站只有两条 250–300ms,都不到一个令牌间隔(2.1s),说明它们在同一次上游调用里;其余 8 条到得晚,0ms 命中负缓存。上游只打了一次。
- **库里没有、AniList 有的续作**:353ms 现场拉取并入库,`detail_fetched_at` 和 `trailer_checked_at` 都已打上;之后 0ms,页面 200。
- **只是旧的行**:4 条里 3 条约 330ms 刷新入库,1 条碰上令牌忙,15ms 返回旧行。★ 刚部署那几分钟,同样的请求 3/3 都是忙:`warm_season` 开机就逐页排队拿令牌,把桶占满了,跑完即恢复。验这条路径别在刚部署完的时候验。
- **不完整的行**:打了一条 `trailer_checked_at` 为空的行,走的是排队的 `Detail`,AniList 回 404,记一条 Warn 后返回旧行(200)。★ 顺带看清了这类行为什么一直补不全:它们是 AniList 已经删掉的条目(四条老番 Specials),库里还留着,每次缓存过期后被访问就再问一次。量很小,改动前也这样,但目前没有任何机制清理「我们有、AniList 已经没有」的行。
- CI 对新版本线上重跑:Playwright(prod)8 passed,Lighthouse 通过。
### 磁力搜索弹窗:预告片卡从弹窗里透出来、滚轮滚的是底下的详情页
两个 bug 一张截图。预告片卡(Netflix 那张静帧)浮在打开的磁力列表上面;滚轮放在弹窗上,动的是后面的详情页。
**透出来**是层叠上下文的事。`TorrentModal` 和 `PlayButton` 的引导弹窗都是 `DetailActions` 内联渲染的,而 `DetailActions` 在 hero 里,hero 是 `isolation: isolate`(`page.module.css`)——弹窗的 `z-index: 1000` 只在 hero 这个上下文里算数,对外整个 hero 是一层 `z-index: 0`。文档里排在 hero 后面、又是 positioned 的东西(预告片卡的 `.card` 是 `position: relative`,简介旁的追番头像也是)就按文档顺序画在它上面。修法和 `TrailerPreview` 的影院弹窗一样:`createPortal(…, document.body)`,让弹窗回到根层叠上下文。两个弹窗都不吃 `--poster-*` 变量,搬出 `.poster-scope` 没有代价。
**滚轮**是全仓四个弹窗共用的一行:`document.body.style.overflow = "hidden"`。它从来没生效过——`globals.css` 给 `html` 写了 `overflow-x: hidden`,根元素的 overflow 一旦不是 `visible`,body 的 overflow 就不再传到视口,锁的是 body 自己(它的高度就是内容高度,什么都夹不住)。无头 Chromium 探针:同一条规则下 body 锁完滚轮照样滚 600px,锁在 `html` 上是 0。新的 `lib/scrollLock.ts` 锁 `documentElement`,带引用计数(引导弹窗点「去下载」切到磁力弹窗,两把锁在同一个 commit 里交接,页面只在最后一把松开时恢复),用经典滚动条时补一份 `padding-right` 顶住消失的滚动条。四个弹窗(磁力、引导、预告片、头像裁剪)全换过来。★ **「设了 overflow: hidden」和「页面锁住了」是两件事**,检查前者永远绿,只有量 `scrollY` 才知道。
e2e 在 `anime-detail.spec.ts` 补了一条:开磁力弹窗,`elementFromPoint` 在预告片卡中心取到的必须在 `[role=dialog]` 里(可见性断言分不出画在上面还是下面),滚轮 800px 后 `scrollY` 不变,Esc 关掉后同一个滚轮页面得动。两条断言各自对着旧代码红过:不 portal 挂第一条,锁 body 挂第二条(滚了 800)。
### 别名只存中文、日文和拉丁字母:其它文字的从库里删掉
AniList 的 synonyms 大半是各个市场的译名。`anime_synonyms` 里 27,319 行有 2,858 行是西里尔、泰文、希伯来、阿拉伯、希腊、谚文和越南文——站的读者不会用这些文字去找一部番。migration 0040 按码点白名单删掉它们,写入端(`Media.SynonymSet` → `anilist.KeepSynonym`)用同一张范围表拒收,以后详情读取和 facts sweep 也写不回来。
白名单不是黑名单:拉丁到 Extended-A(ASCII、西欧重音字母、罗马字长音)、通用标点和符号、CJK 各区(跳过谚文兼容字母)、全角、emoji、CJK 扩展平面;一个码点不在里面整条不留。★ **判的是文字不是语种**——意大利文、德文这种纯拉丁字母的译名分不出来,留着;越南文靳靳靠声调符号(Latin Extended Additional 区)被挡在外面。
SQL 那份正则和 Go 那张范围表是同一件事写两遍,`synonym_scripts_pg_test.go` 拿同一组用例把两边都跑一遍,改一边不改另一边会红。★ 顺带一个坑:正则里的 `\u0000` 必须以转义原样进文件——写文件的工具把它解成了真正的 NUL 字节,lib/pq 直接报 `invalid message format`。
部署后:`anime_synonyms` 27,319 → 24,489,用外文字符反查还剩 17 行——都是部署那四分钟里写进来的。`deploy.sh` 的顺序是先跑 migration、再 build 镜像、最后换容器,旧的 go-api 在 build 窗口里照常跑,它那一轮 sweep 落在 migration 之后,5 部番的 17 个外文别名又写回去了。手工把 0040 的 DELETE 再跑一遍(幂等)→ **24,472 行,外文 0 行**。★ 凡是「删数据 + 改写入端」的 migration,以后部署完都要补跑一次 DELETE,或者等下一轮 sweep 把那几行覆盖掉。
### 详情页作品信息表退回八格:别名、制作委员会、标签、外部链接四行全撤
部署后(`762ef4e`):线上 `/anime/21` 作品信息表的 `<dt>` 是 季度 / 开播 / 状态 / 集数 / 单集时长 / 类型 / 原作 / 制作 八个,`alternateName` 三项,`sameAs` 五项未动。
上一条刚上线,看了一眼 Re:ZERO 第三季的页:别名一行是一个泰文名和一个俄文名,制作委员会是五家电视台和出版社,标签全是英文,外部链接和 hero、评分面板说的是同一件事。四行都撤,表回到固定的八格;`alternateName` 也退回三个标题——页面不显示的东西结构化数据不该声称;`sameAs` 留着,它是身份消歧信号不是页面内容。数据一样不少:DTO、JSON-LD、`detailFacts.ts` 里挑子集的逻辑都在,缺的是「这些东西该在页面哪里、给谁看」,定了再画。
### 详情页把阶段 2 的数据画出来:下集倒计时、别名、标签、制作委员会、人气
阶段 3 的第三步。0036 / 0038 两批列进库一天多了,页面上还是一个字没露。现在:
**hero 多一条「第 N 集 · 9月20日 周日 22:16 · 3 天后」。** 一个客户端叶子,不是第七个 fact:它是 hero 里唯一会在读者看着的时候变的东西。两件服务端做不对的事都放到了客户端——**钟**(`/anime/*` 是 ISR 加 CF 边缘,副本可能是几小时前的,烤进 HTML 的「3 天后」会以新闻的口气说一句悄悄错了的话,所以服务端只出集数和绝对时间,相对时间靠 `useSyncExternalStore` 对着 `Date.now` 算,每分钟刷一次)和**时区**(服务端和水合期都按 Asia/Shanghai 画,两边一致、无需 suppress,之后换成浏览器自己的时区;首页周表同一个选择)。播完就整条消失而不是写「已播出」——这个值只有行那么新,一天前的行说「第 1179 集 5 小时前播了」是把陈旧值当新闻。没季度的预告首播也走这条(AniList 对 NOT_YET_RELEASED 的 nextAiringEpisode 是第 1 集)。
**作品信息表多两个宽行:制作委员会、标签。**(别名那行上线当天就撤了,见下一条。)制作委员会是 `is_main = false` 的公司,纯文字(hub 只列主制作)。标签中文页 Bangumi 先(按票数)、AniList 后(按认同率,30% 以下不要),英文页反过来;**剧透标签永不出现**——页面没有揭示控件可以把它藏在后面;两个来源同名只出一个 chip;Bangumi 的 chip 边框带作品色,不用图例就分得出两套词汇。评分面板 AniList 那格下面多一行「480,524 人追番」——AniList 没有投票数所以进不了 AggregateRating,这个数是它有的那一个;没评分只有人气的未播番,面板也有一句真话可说。
e2e 里 `ensureAnimeDetail` 顺手补了 `detail_fetched_at` / `facts_checked_at` 两个戳:#184 之后 `isStale` 看的是前者,此前每个 fixture 行都是 stale 的,每次缓存 miss 都去 AniList 问一个不存在的 id,测试能过只因为失败回退到了已读的行。新 spec 从相关作品卡**点进去**验倒计时条——客户端路由切换是这个叶子没有服务端 HTML 可依赖的那条路。
部署后(`8bd2d42`):海贼王页源站 HTML 带「第 1179 集 · 9月20日周日 22:16」,浏览器里补出「3 天后」并换成本地时区;评分面板「750,992 人追番」;`/en/` 版「Episode 1179 · On 750,992 lists」;芙莉莲(完结)无条。CI 跑了四轮才全绿,四次都是 e2e 层的坑不是页面:fixture 先删再插在多 worker 下有窗口;**relation 行没封面会被 `isStale` 当成旧形状的行**,重拉不存在的 id 失败后回退路径不补标题,卡片无名点不到;预告片弹窗的居中断言量在 320ms 入场动画中间(失败值 14.0 / 4.7 就是 `translateY(14px)` 的起点和中途);`filter({ has })` 的内层 locator 相对外层求值。另外 `ensureAnimeDetail` 从 #184 起就没打过 `detail_fetched_at`,所有 fixture 行其实一直是 stale 的,靠失败回退才过的测试。
### 详情页的 JSON-LD 长出面包屑和人:`BreadcrumbList`、`actor`、`character`、`director`、`musicBy`
阶段 3 的第二步。`/anime/[id]` 此前只有一份 `TVSeries`,说了这是什么、几集、评分几分,没说它挂在站点的哪里,也没说谁做的。现在多一份 `BreadcrumbList`:首页 › 它所在的 hub › 它自己。hub 优先季度页(站点导航就是「首页 › 季度」,那页从 hub 存在之前就一直列着它),没季度的按放送年份进 `/year/`,再没有就是第一个有页面的类型。URL 带 locale 前缀——`/en/anime/1` 的父页是 `/en/seasonal/...`,跨语言的面包屑会告诉 Google 一张英文页的上级是中文页。最后一级不带 `item`,它就是这一页。三份词典各自出「首页」和 hub 页自己的标题,这个标题从三个 hub 页里各自的 `HEADING` 表抽到 `lib/hubs/headings.ts`,页面和面包屑共用一份。
`TVSeries` 多四个属性:`actor`(声优)、`character`(角色)、`director`(`Director` 和 `Chief Director`,不算美术监督、音响监督、演出那些带 Director 字样的)、`musicBy`(`Music`)。名字走页面同一套语言阶梯,中文站声优和监督是日文名,和页面上画的一致。**只写页面画出来的那几个**——8 个角色、10 个 staff,常量从 `page.tsx` 挪进 `detailPeople.ts` 让两边共用;Google 的规矩是别标注读者看不见的东西,DTO 里那 25 个的其余部分不进结构化数据。migration 0037 之后读到的行带 AniList id,Person 和 Organization 就各自带 `sameAs` 指到 AniList 的人物页 / 公司页;之前的行是光名字。
schema.org 验证器两个类型零错误零警告;Google 富媒体测试「路径」和「评价摘要」各检出 1 项有效内容。
部署后(`04fb265`):`/anime/154587` 出两份 `<script type="application/ld+json">`,面包屑「首页 › 2023 秋季 › 葬送的芙莉莲」,`/en/` 版的父级指 `/en/seasonal/fall/2023`,Rich Results Test 抓线上 URL 同样两项有效零错误。数 `application/ld+json` 字符串会得到 4——RSC 的 flight payload 把 script 再序列化一遍,数标签才是 2。
★ **`VideoObject` 这次没做,且做不了。** 实测把预告片嵌成 `trailer: VideoObject`,Google 报严重问题「未填写字段 uploadDate」——那是必填项,而 AniList 只给 YouTube id,oEmbed 也不给日期。要做得先有 YouTube Data API 的 key(`videos.list?part=snippet` 拿 `publishedAt`,50 个 id 一次),多一列、多一趟 sweep。等拍板。
### 三种 hub 页——按类型、按制作公司、按年份——让一万八千个详情页第一次互相链接
阶段 3 的第一步。此前详情页上的类型 chip 是 `<span>`,制作公司是一段纯文本,整个目录里没有任何一页链向不止一部番:爬虫落在一部番上,无处可去。现在 `/genre/[slug]`(18 个类型,成人那个不设页)、`/studio/[name]`(只算主制作,不算制作委员会)、`/year/[year]`(季度年份,没季度的剧场版/OVA 按放送日归年——四分之一的目录靠这条兜底)各是一页,按人气排序,真分页、真 URL、canonical 各自独立,prev/next 是爬虫走向长尾的路。详情页的 chip 和制作公司名变成链接。
后端是一个 `GET /api/anime/browse?genre=|studio=|year=`(有且只有一个键,零个是整个目录、两个是搜索,都拒绝)和一个 `GET /api/anime/hubs`(每种 hub 有哪些、各多少部,缓存一小时)。migration 0039 加三个反向索引:`anime_genres(genre)`、`anime_studios(studio) WHERE is_main`、`COALESCE(season_year, EXTRACT(YEAR FROM start_date))` 的表达式索引——查询里的表达式必须和索引逐字相同。制作公司的门槛是 5 部:一部番的 hub 是薄页面,而一部番的制作公司有几千家。
sitemap 多一份 `/sitemaps/hubs/sitemap.xml`:类型、年份、制作公司,以及 **291 个季度页**——`/seasonal/[season]/[year]` 一直渲染得出全部,此前只列了当季那一个。robots.txt 加这一行。
★ **顺手修了 TODOS 里那条 `proxy.ts` 的 bug,因为它挡在路上。** `NON_PAGE_PATH` 原来是「任何以 `.字母数字` 结尾的路径都不是页面」——那是为了不把 `/sitemap.xml` 改写到 locale 段下面,但它同时把 `/studio/J.C.STAFF`(几百部番)和所有邮箱形状的用户别名送进了内置 404。改成真实扩展名白名单,测试列出承重的那几个(两份 sitemap、robots、version.json、jassub 的 wasm、字体)和四个带点的页面路径;e2e 里那条「KNOWN DEFECT」用例翻成正向断言。
### Bangumi 的中文标签进 `anime_tags`:客户端解码了几个月、从没人读的那份
V2 拿到的 Subject 里一直带着 `tags`(标签名 + 投票数),`bangumi/client.go` 解码了它,全仓零引用。这是所有来源里唯一一份**中文**标签——对 79.6% @qq.com 的用户比 AniList 的英文 tag 更有用。现在 V2 在写完评分和简介之后顺手把它写进 `anime_tags`,`source = 'bangumi'`,投票数放 `rank`,删除按来源作用域所以碰不到 AniList 那半。
噪音底线:两票以下不存。Bangumi 标签是用户随手打的,一部番的列表尾巴是某一个人的错字或内梗,两个人同意过的才算标签。空白和重复(同一个标签名前后空格不同)也过滤。写入是尽力而为——某个标签插不进去只记一行日志,评分那次写入已经落地。
★ 这条只对**新走 V2 的行**生效。`bangumi_version` 是单向棘轮(TODOS 有那条),已经在 2 以上的一万三千行不会自己再来一遍;要给存量补中文标签,是后台 `re-enrich-ids` 或那条棘轮 TODO 的事,不是这里能顺手做的。
部署 8 小时后:`source = 'bangumi'` 的标签 2 部 16 行——上面那句不是预言,是实测。
### 标签、外部链接、制作公司的 id 和角色,以及 JSON-LD 的 `sameAs`
阶段 2 的最后一块 AniList 侧数据。migration 0038:`anime_tags`(按来源分 AniList / Bangumi,一张表,删除按来源作用域——两个来源的写入方各自替换自己的那半,谁也清不掉对方的行)、`anime_external_links`(按 URL 做主键,因为 AniList 的 `site` 是展示标签,一部番可以有两个 "Official Site")、`anime_studios` 加 `studio_id` 和 `is_main`。
★ **制作公司这次要的是全部,不只主制作。** 移植过来的文档是 `studios(isMain: true)`,制作委员会和发行方从来没进过库。现在是 `studios { edges { isMain node { id name } } }`,`is_main` 记下角色。详情页「制作」那一行和 JSON-LD 的 `productionCompany` 读的仍然只是主制作(`GetAnimeStudiosByID` 加了 `AND is_main`),行为不变;`studioDetails` 是全量带 id 的新字段。
`sameAs` 进 JSON-LD:AniList 页永远有(id 就是行的键)、Bangumi 和 MAL 页在 id 已知时、然后是 AniList 列出的官网和社交账号。流媒体页不算身份——Crunchyroll 的条目说的是「在哪看」不是「这是什么」——不进去。这是对 AnimeGO.org 那次品牌混淆最直接的消歧信号:这一页说的是三个目录各有一页的那部作品,不是一个碰巧同名的站。详情页信息栏加了「外部链接」一行,**和 `sameAs` 用同一个集合**(官网、社交、三个目录),页面和结构化数据不可能对哪些 URL 属于这部番各说各话。
`fetchChildren` 从 11 个位置返回值改成一个结构体:每加一张子表都要改三处调用点加一个测试,这次是第三回。facts sweep 的文档同步扩到 tags 和 externalLinks(它选的每一样仍然都是 AniList 独家的事实),migration 再次清零 `facts_checked_at`——理由同 0036,问题变宽了旧戳作废。Bangumi 的中文标签由 V2 写,那是下一条。
部署 8 小时后:`anime_tags` 里 AniList 标签 4,868 部 / 44,805 行,`anime_external_links` 2,062 部 / 4,614 行,都跟着 facts sweep 每小时 500 行在涨。芙莉莲那行还没轮到,线上 `sameAs` 仍是两个 URL。
### V2 不再向 Bangumi 要角色列表:那一步写的两个字段上游根本没有,写了也活不过一天
移植过来的 V2 每行多打一次 `/v0/subjects/{id}/characters`,然后按名字匹配 `anime_characters`,写 `name_cn`、`voice_actor_cn`、`voice_actor_image_url`。这一步删掉了,两个理由各自就够:
- **端点没这两个字段。** 它的角色对象是 `{id, name, type, images, relation, summary, actors}`,演员对象是 `{id, name, type, images, career, locked, short_summary}`——两层都没有 `name_cn`。所以算出来的两个值永远是 nil;唯一真会写的第三个,写的是**角色**的图进**声优**图那一列。
- **写了也留不住。** 详情重拉对 `anime_characters` 是整表删了重建,V2 写的任何东西在下一次 24h 到期时消失,而 V2 不重跑。全库 8 万多行角色,带 Bangumi 图的是零——这就是这一步从没留下过痕迹的直接证据,也是它被发现的方式。
省下的是每个富化行一次 800ms 桶的请求,那个桶用户面的弹幕匹配也在用。V2 的重试策略随之收窄:只有 subject 的错误会让作业重试,episodes 照旧尽力而为。`UpdateAnimeCharacterCN` 这条 SQL 一起删了;`bangumi.Client.Characters` 留着,它是 API 面不是死代码。中文角色名要是哪天真要,是逐角色读 `/v0/characters/{id}`,而且得挂在一个能活过详情重拉的作业上——见 TODOS。
### 角色、声优、staff 存下 AniList 的 id;每部从 8 / 10 个放到整页 25 个
`anime_characters` 和 `anime_staff` 从 0001 起只存名字和图,没存 id——`AnimeDetailQuery` 一直选着 `node { id }`,`normalize.go` 拿到手就丢。没有 id 就说不出「同一个人在另一部里」,人物页、「还配过」列表、任何往外的 join 都无从谈起。migration 0037 加 `character_id` / `voice_actor_id` / `staff_id` 三列(可空,正数 CHECK,带部分索引给反查用),normaliser 把 id 传下去,DTO 吐出来。
存量不回填:详情路径每次重拉整表替换,一行的 id 会随它的番下一次被读到时一起到(24h TTL 或后台 re-enrich),不多花一次上游请求。
`perPage` 从 8 / 10 放到 25——AniList 嵌套连接的上限,实测 `pageInfo.perPage` 到此封顶。之前一半以上的番顶在那两个数上。详情页**画的数量没变**(还是 8 个角色、10 个 staff,用常量钉住):人物网格在手机上是单列,25 行会把分集列表挤到两屏以外;「查看全部」归人物页那一步。
部署 8 小时后:带 `character_id` 的番 795 → 1,305 部(15,114 行);`staff_id` 已经有 3,205 个跨不止一部作品出现——「人物页值不值得开」的那个数,比计划里等一周提前有了答案。
★ 顺带做了一个判断并写在 TODOS 里:V2 里那段按名字匹配、写 `name_cn` 的循环是死代码(上游端点没这个字段),有了 id 之后也不值得改成「按 id upsert 保住富化列」——因为**现在没有任何富化列有东西可保**。删掉它是下一个 PR 的事,这条只加 id。
### 向 AniList 多要七个标量和一张别名表,四份文档一起要,upsert 才没有「没选就写空」的歧义
阶段 2 的第一步。`Media` 上一直有、目录里一直没有的:`synonyms`(别名)、`popularity` / `favourites`(榜单和「多少人在追」)、`idMal`(外链三件套的最后一个)、`isAdult`(成人标记,此前全站靠 `genre = 'Hentai'` 一根线)、`countryOfOrigin`、`nextAiringEpisode`(连载中的「第 N 集·几天后」)。migration 0036 加七列和 `anime_synonyms` 表。
★ **标量加进了全部四份文档,不是只加详情。** trailer 当年只在两份文档里,所以 upsert 得带一个「这次选没选」的标志,否则搜索路径会拿 nil 把存好的预告片抹掉。这次的做法是让 search / seasonal / detail / facts 四份文档都选同一组标量——多选几个标量不多一次请求,而限流预算量的是请求数不是字节数——于是 upsert 可以按字面写:AniList 说 null 就是 null。两个例外各有理由:`mal_id` COALESCE(id 知道了不会变回不知道);`is_adult` 进来时是可空的,`Media` 没解码到这个字段(这棵树里不存在,但类型允许)就保留库里的值,避免 false 盖掉 true。有测试钉住四份文档都选这组字段,少一个就红。
`nextAiringEpisode` 存成一对列并加 CHECK:有时间没集号或反过来都不是答案。它的新鲜度就是行的新鲜度(详情路径 24h TTL),消费方要拿 `airingAt` 和自己的钟比,DTO 注释写明了。`synonyms` 是子表、整组替换,和 genres 一样,只由详情路径和 facts sweep 写;列表路径不碰。
顺手把两处成人内容的口子合上:首页的「完结佳作」和「年度榜」此前从不排除成人条目(seasonal 一直排除,另外两个从来没有),现在四条查询都看 `is_adult`——seasonal 保留原来的 genre 判断在旁边,因为 Express 迁来的存量行在被任何 AniList 路径碰过之前 `is_adult` 是默认 false。本地搜索这次没动:它在 #123 改成本地之后确实丢掉了 AniList 路径的 `isAdult: false`,但搜索的成人策略属于阶段 3 的浏览页,一起定。
facts sweep 的文档同步扩到这组标量(它选的每一样都是 AniList 独家来源的事实)。★但 sweep 的戳回答的是「问过没有」,而问题变宽了:在四字段文档下打过戳的行没被问过 popularity 和别名——所以 migration 把 `facts_checked_at` 全部清零,让 sweep 按自己的节奏再走一遍,而不是留两代戳并存。
部署 8 小时后:`popularity` 非空 7,649 / 18,504;`anime_synonyms` 12,386 行覆盖 5,170 部;连载中的 253 部里 85 部已有 `next_airing_at`。其余跟 sweep 走。
### 四个字段的存量回填:不整仓 warm-all,走 50 个 id 一批的 facts sweep
上一条把写入链修通之后,剩下的问题是存量:一行只有在被人打开、且 `cached_at` 过了 24 小时的时候才会重新走一遍详情查询,没人看的行永远轮不到。按上线后头几个小时的速度,整个目录要一周,而且只盖得到被访问过的那部分。
★ **整仓 warm-all 是错的答案。** 它对每一行跑一次 `AnimeDetailQuery`——角色、staff、关联、推荐一整包——而要的只是四个标量。#169 的评分 sweep 已经给出了正确形状:`id_in` 一次 50 个 id 的窄文档,整个目录几百次请求而不是上万次。所以这条就是那个 worker 换一份文档:`MediaFactsQuery` 只选 `id / startDate / endDate / duration / source`,有测试钉住它**不能**多选任何一个字段——一份走遍全表的文档要是把标题或评分写错,没有第二个来源能救回来。
★ **节奏也照抄评分 sweep,理由同样不是「多久跑完」。** 每小时一趟、每趟 500 行(10 次请求,约 20 秒),因为这个上限保护的是和用户请求共用的那把 AniList 限流器——线上记过一次 `/api/anime/schedule` 在一趟 2,000 行的 sweep 后面等到 500。目录两天内排干;排干之后一趟只剩连载中和未播出的行,它们的戳每 30 天过期一次(完结番的日期不会再变,读一次就是最后一次)。三条 sweep 共用 `ratings` 队列的那一个 worker 槽,所以永远不会并排去撞同一把限流器。
写入用 `COALESCE`,和 upsert 同一条规则:文档选了字段、AniList 回 null,意思是「它没说」(只知道年份的日期、未知的原作),不是「把存的清掉」。`updated_at` 只在某个事实真的变了才动——它是 sitemap 报给 Google 的 lastmod,重读一遍什么都没变的行不该让整张 sitemap 重新发布;但真变了就该动,因为详情页的信息栏和 JSON-LD 的 `startDate/endDate` 正是从这几列来的。AniList 不再提供的 id(合并或删除)会被单独打戳,否则它们会永远排在每一趟的最前面。
`FuzzyDate.Whole()` 从 `internal/anime` 里那个未导出的转换函数提出来放到 `anilist` 包:sweep 在 `internal/queue`,而 `anime` 导入 `queue`,不能反过来。「年月日三段齐才算日期」这条规则现在只在一处,两个写入方各自包一层。
第八小时:`facts_checked_at` 4,000 / 18,504(0036 和 0038 各清过一次零,所以从部署算起而不是从第一次上线算起),`start_date` 非空 341 → 10,019。按这个速率整库两天。
### 放送日期、时长、原作从来没写进库:查询要了、列也在、upsert 就是没列它们
详情页信息栏的「放送 / 时长 / 原作」三行几乎总是空的,schema.org 的 `startDate` 同样缺席。查下去发现是一条从头就断的写入链:`AnimeDetailQuery` 从移植那天起就向 AniList 选了 `startDate / endDate / duration / source`,`anime_cache` 从 0001 起就有 `start_date / duration / source` 三列,而 `UpsertAnimeCache` 的 INSERT 列表里没有它们——每次 warm 拿到手,每次扔掉。库里仅有的几百个值全是 Express 时代那次一次性迁移带过来的存量,能活到今天只是因为 DO UPDATE 同样不碰这三列。
★ **验错的方式也记一笔**:8 月那份审计已经指出这条链断了,而我这次拿一部番的 API 响应看到 `startDate` 有值,就写成「已修好」。那部恰好是存量。**详情页渲染了某字段 + 一条样本返回了它,不等于写入链通了**;要看的是最近一次 upsert 之后的行。
修法:migration 0034 补上 `end_date`;upsert 写四列,DO UPDATE 用 `COALESCE(EXCLUDED.x, anime_cache.x)`——放送日一旦知道就不会变回不知道,所以列表页那种没选这几个字段的 upsert 传 NULL 进来也抹不掉已知值,不需要像 trailer 那样再带一个「选没选」标志。FuzzyDate 转 date 沿用 Express 迁移的规则:年月日三个都有才写,只有年份的留空——一个 `date` 列说不出「2011 年的某一天」,补成 1 月 1 日就是把没说过的话当事实印在页面和结构化数据里。pg 测试覆盖三步:详情 upsert 写入 → 列表 upsert 不擦除 → 第二次详情 upsert 覆盖成新值;把 COALESCE 换成裸 `EXCLUDED` 那一步 1 fail。
部署后没跑任何回填,只靠 24 小时 `cached_at` 到期的自然流量:7.6 小时里走过详情路径的行四个字段全部落地,有放送日期的行从 341 涨到 3,397,有完结日期的从 3 到 3,071(此前这一列不存在)。按这个速度存量要一周才补完,而且只覆盖被访问过的行;剩下的走一次 `id_in` 分批的 facts sweep,不整仓 warm-all。
### 「没有 studio 就当过期」让一类番在每次缓存过期时都重打一次 AniList,永远
`isStale` 从 Express 原样搬来的两条判定:studios 为空 → 过期,characters 为空 → 过期。它们想表达的是「这行只被列表查询写过、还没走过详情查询」,但对 AniList 本来就没列主制作公司、或者没有角色的番,详情查询写下的就是空集——下一次读到空集又判过期,再拉,再写空,再判过期。进程内缓存一小时一过期,这类番每小时每次访问都多付一次上游请求,而且从不终止。
改成和 `trailer_checked_at` 同一个形状:新列 `detail_fetched_at`,只有真正跑过 `AnimeDetailQuery` 的两条路径打戳,列表 upsert 保留原值;`isStale` 看戳不看子表长度。为此把 `anilist.TrailerSelection` 换成 `anilist.Document`——一个 Media 到底来自哪份 GraphQL 文档,同时决定「nil trailer 算不算答案」和「空子表算不算答案」,两个问题一个参数回答,第六个调用点不回答就编不过。`TestDocumentSelectionsMatchTheQueries` 把每个常量钉到它声称的文档文本上:从 `AnimeDetailQuery` 删掉任何一个子连接,测试红。
migration 里用旧启发式**最后一次**给存量打戳(任一详情专属子表有行的,`detail_fetched_at = cached_at`;`anime_genres` 不算,列表路径也写它),之后只认戳。漏标的行多拉一次然后被打上戳,自愈;这正是这列存在的意义。`testutil.MigrateTo` 是为了测这条 UPDATE 加的:退到 0033、种行、升到 0034、看戳——`SetupPG` 对着空库跑全部 migration,回填语句在那里没有对象。
★ **量了,而且量出来的和预期不一样。** 部署前一小时 go-api 日志里 `stale, re-fetching` 947 行、前 24 小时 14,548 行;旧判定把 6,072 行(没 studio 的)当成永远过期。部署后 7.6 小时:3,324 次重拉,落在 **3,323 个不同的 id** 上——只有 3 个 id 重复,三个都是上游那次拉取失败、下次访问再试,成功一次就停。循环的指纹(同一个 id 每小时来一次)没了;migration 一次性打戳后没戳的行从 6,072 降到 241,7.6 小时后剩 145。但**小时速率只降到 372–437,是一半而不是预想的一个量级**:剩下的每一次都是 `cached_at` 超 24 小时的正常到期重拉——那是 Express 原样搬来的设计,不是这个 bug。也就是说原来那条循环大约占了上游请求的一半,另一半从来就是 TTL。「少一个量级」是我写在验收单上的期望,实测推翻了它,数字如上。
### 预告片还是放不了:按路由去掉 COOP/COEP 在 SPA 里根本不成立,两个头整站删掉
#174 让 nginx 在 `/anime/` 路由上不发 `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy`,验收时直接打开详情页是能播的。合并四天后用户报「还是不行」,截图里 DevTools 的 Issues 面板写着 *Specify a Cross-Origin Embedder Policy to stop this frame from being blocked*——那份文档**带着** COEP。
★★ **COOP/COEP 是文档级属性,只在整页加载那一刻由响应头决定。** Next App Router 的站内跳转不发新文档请求:从首页点进 `/anime/185874`,URL 变了,文档还是首页那份,`crossOriginIsolated` 照样是 `true`,iframe 照样 `ERR_BLOCKED_BY_RESPONSE`。真 Chrome 两条路径各走一遍:硬刷新详情页 → `crossOriginIsolated=false`、能播;首页点进去 → **0 次文档请求**、`crossOriginIsolated=true`、被拦。#174 只救了搜索引擎落地和硬刷新,**上一次的验收恰好只验了这一种进入方式**。
★★ **保留这两个头的理由也是假的。** `jassubOverlay.ts` 里那段「jassub 的 pthread worker 没有 SharedArrayBuffer 会静默挂死」是 4 月写播放器时的假设,从没测过。把线上那份 `public/jassub/` 的 wasm + worker.bundle.js 单独起个页面,带头/不带头各跑一次:`ready` 分别 233ms / **147ms**,ASS 都正常画出。jassub 2.5 的 emscripten loader 在非隔离页面把 pthread 池设成 0 走单线程,README 也明说会自动降级。**我们自己的闸门在 jassub 有机会降级之前就先退回 VTT 了**——所以 e2e 沙箱(`next dev`,从来没发过这两个头)里 ASS 字幕其实一直走的是 VTT 兜底,只是没人看。
改法:三份 nginx 配置删掉 `map $uri` 和两条 `add_header`;`jassubOverlay.ts` 删掉 `crossOriginIsolated` 闸门。代价是 libass 在非隔离下单线程渲染。部署后量了:线上 `/player` 放 1080p + 字幕组侧车 `.ass`(FLsnow 赛马娘 S2 的 ED 段,双语歌词 + 特效标牌),真 Chrome 有 GPU,60 秒解码 1439 帧**掉帧 0**,主线程 rAF p99 18.4ms、没有一帧超过 40ms。同一段视频拿线上 wasm 做隔离/不隔离 A/B:真实字幕两种模式都是 0 掉帧;一个故意造的 12 行同屏卡拉OK(每音节 `\kf`+`\t`+`\blur`+`\move`)两轮之间的抖动(隔离 50/14 帧、不隔离 72/5 帧)比两种模式的差异还大,最差一轮也只占 5%。单线程的代价在真实字幕上是零。
守卫是一个 bun 测试,读 `nginx/*.conf` 断言没有 COOP/COEP 的 `add_header`,读 `jassubOverlay.ts` 断言没有 `crossOriginIsolated` 分支。放在 next-app 里而不是 e2e,因为头是 nginx 发的、`next.config.ts` 一个头都不设——对着 Next 应用本身写的任何测试在两种状态下都会绿。变异验证:把 COEP 那行加回 `default.conf`,1 fail。候选配置用线上 nginx 容器同一组挂载在 compose 网络上 `nginx -t` 过(线上那份作对照同样通过)。
★ 部署注意:线上 `nginx/default.conf` 是 `default.p9.conf` 的拷贝(`diff` 逐字节相同),所以合并后要 `cp nginx/default.p9.conf nginx/default.conf && docker compose restart nginx`——restart 不是 reload,bind mount 换了 inode。
### 一个 httptest 服务器关闭,会掐断另一个测试正在飞的请求
CI 在 #177 上挂了,挂在 #177 根本没碰的包里:`internal/bangumi` 报 `net/http: HTTP/1.x transport connection broken: http: CloseIdleConnections called`。
仓库里没有任何一处调 `CloseIdleConnections`。**标准库自己调**,在一个不容易想到的地方(`net/http/httptest/server.go` 的 `Server.Close()`,注释原文):
> Not part of httptest.Server's correctness, but assume most users of httptest.Server will be using the standard transport, so help them out and close any idle connections for them.
五个 API client 都写的 `&http.Client{...}` 不带 `Transport`,而 **nil `Transport` 意味着 `http.DefaultTransport` —— 一个对象、一个连接池、整个进程共用**。于是任何一个 httptest 服务器关闭,都会清掉进程里所有 client 的空闲连接;在一个测试全是 `t.Parallel()` 各起各服务器的包里,A 的 `defer srv.Close()` 能在 B 复用池中连接的瞬间把它掐断。
★ **四个包已经暴露,bangumi 只是先中奖的那个**:torrents 有 128 处 `t.Parallel()`、bangumi 44、anilist 30、deepseek 11。dandanplay 今天安全**只因为它的测试碰巧没写并行**——那是运气不是设计,离失效只有将来某人加的一行。所以五个一起改,留一个例外比统一处理更难记。
★★ **测试断言的是结构不是行为,这是有意的**。它要防的竞态窗口极窄(连接得已从池里取出、请求还没结束),我**没能复现**:60 轮全量 `-parallel=16`,外加一个专门造的复现(慢 handler 配上并行狂搅 `srv.Close()` 的兄弟测试),约 200 次,一次没炸。所以没有诚实的行为测试可写,而「修完就不炸了」同样不算证据——不炸本来就是常态。能钉的是让竞态不可能发生的那个属性。六个变异六次红,其中最关键的一个:把 `httpx.NewTransport` 改成返回共享对象而不是克隆,**五个包的 client 测试跟着一起红**——这才证明它们依赖的是 helper 真的克隆了,而不只是 `Transport != nil`。
`torrents` 那个故意不设的 client `Timeout` 保留(`aggregator.go:159` 写明 per-source 的 `ctx.WithTimeout` 才是权威 deadline)。
### ★★ 那次静默停摆的真因不是「没有校验」,是「校验失败了但没人知道」
接上一条 `running` 的事故。重复的四份 `*UniqueStates` 只是它的载体,不是它的原因。
**所以第一件做的事不是注册表,是让 river 的失败能被看见。** river 汇报后台失败的方式就是写一行 ERROR 然后继续跑——周期作业构造失败、插入冲突、事务错误,一律如此(`periodic_job_enqueuer.go`)。而我们的 `slog` 默认 handler 是个裸的 `JSONHandler(os.Stdout)`,那行 ERROR 到容器日志为止。`internal/obs.SentryErrorHandler` 包住任意 handler:stdout 那半字节不变,ERROR 及以上额外送 Sentry。它一次覆盖**每一种** river 后台失败,而那些是任何静态闸门都看不见的。
★ 转发限流 20/分钟,且**被抑制之后的第一条事件带上它替了多少条**——把风暴变成沉默的限流器,就是把同一个事故换一层重演。
**闸门形式换掉了,而且是被实测推翻的。** 原方案是启动时校验四个必需状态。它错在两处,都对着 river v0.37.1 的源码核实过:
- ★★ **会误杀**。`DescriptionBackfillArgs` 和 `DescriptionLlmBackfillArgs` 用的是 `UniqueOpts{ByArgs: true}`、`ByState` 为 nil,而 `validate()` 在 nil 时**提前返回**(`insert_opts.go:271`)。手写闸门会拒掉两个 river 完全接受的 kind。
- ★★ **会烂成它要防的那个形状**。那四个状态是 river 的**私有**常量 `requiredV3states`(`insert_opts.go:244`),`rivertype` 什么都不导出。手抄一份,等 river 哪天加第五个,闸门报绿而 river 报错——和这次事故一模一样,只是上面多盖了一层让人放心的假保险。
所以闸门改成**拿真 Postgres 启动生产配置、跑 river 自己的 `validate()`、断言行真的落了**。逐 kind 断言而不是看总数:`UniqueOpts` 一次只对一个 kind 失败,看总数那晚会过。
它顺带钉住另外三种「kind 悄悄不跑」,每一种都是对着活库量出来的而不是推的:
| 失败 | river 实际怎么做 |
|---|---|
| `ByState` 少 `running` | 零行 + 一条 ERROR。变异做成了**夹具**,配一个只差一个元素的健康对照,所以它不会像抄来的常量那样烂掉 |
| 队列没声明 | 插入成功、行永远停在 `available`、river **一声不吭**。这是里面最安静的一种,唯一的防线就是注册表拒绝被那样构造出来 |
| worker 没注册 | `Insert` 会以 `UnknownJobKindError` 拒绝——**但只在那条路上**,周期路径从不查 workers bundle |
以及那个被接受的代价:部署留下的孤儿 `running` 行确实会压制下一次开机入队,直到 rescuer 回收。它和上面那条配成一对——**这个窗口是真的,而拿掉 `running` 不是关掉它的办法。**
**注册表本身**:队列名、并发度、周期表收进一处,构造时校验六类错误。边界是它**读** `InsertOpts()` 来校验、绝不取代它——river 是在 Args 类型上调那个方法的,而其中一次调用发生在 `bgm_bind_idmap.go` 的绑定+入队事务里面,把静态方法换成 map 查询等于白给那条路加一个 panic 面。并发度是类型不是 int:四个 `1` 是因为第二个 worker 会弄坏什么(共享令牌桶、单槽位才不可达的 check-then-update 竞态),一个 `4` 是因为 DeepSeek 往返真的能并行,`FixedConcurrency` / `TunableConcurrency` 各自必须带理由。十个 `Periodic<Name>Job` 构造函数删光,编译器逼着改完每一个调用点。
**pause 从只能停 V3 变成八条队列都能停能看**。白名单是注册表减去 `default`——那条队列同时装着 V1、V2、warm_season、orphan_scan,`control.go` 当年把接口收窄到 V3 就是为了防它,约束没变只是实现搬了家;river 的 `"*"` 通配同样被拒。顺带修掉一个:`QueueCtrl` 为 nil 时 `PauseHeal` 会返回 `200 {"paused":true}` 却什么也没暂停。当年的注释说这是「照 Express 的内存布尔翻转语义」——话没错但不相干,**Express 里那个标志本身就是机制**,翻了就真停了;这里机制是 river 的。而这几个接口正是凌晨止血的那根杆子,按下去得到一个自信的成功和一条还在跑的队列。现在返回 503。
★★ **覆盖率查出来的缺口,恰好和这次事故同形**:所有新配置都测了,唯独没测 **`main()` 有没有真的用上它**。`main()` 里绝大部分错了都会大声失败(DSN 坏了直接退出、路由没挂就是 404、handler 漏了编译不过),只有两处会静默——摘掉 Sentry 转发器则日志一模一样而 Sentry 从此失聪;删掉 `Queues` 或 `PeriodicJobs` 则 `queue.Boot` 回落到 `{default: 1}`,八条队列没有 producer、一条 sweep 都不再排期。两处都抽出来测了,logger 那条**走真实 handler 链断言 ERROR 确实到达**而不是断言类型——中间插进一个新 handler 能通过类型检查却把 record 吞掉。
★ 全程 48 个变异 48 次红,其中 **6 个一开始活了下来**,每一个都是测试写弱了而非代码本来就对:父 handler 的 attr slice 没有余量所以共享底层数组根本不会 alias;只覆盖了两条 resolve 循环里的一条;以为关掉了唯一性其实 `ByState` 单独仍在做 key;还有一个是编译失败而不是测试失败,那什么也证明不了,重做成了能编译的版本。
★ **外部评审说对了一半**:它指出 `bgm_bind` 的 `MaxWorkers: 1` 陈述了一个进程外 CLI 写入方会打破的正确性不变量。属实,但更准的说法早就写在 TODOS 里——那条不变量不是「离假只有一个 `--apply` 标志」,它**已经假了 541 次**,其中一部分还是合法的(Bangumi 有时用一个 subject 覆盖 AniList 拆成两季的内容)。单槽位买到的是「这个 sweep 不会让这个数字变大」,理由字符串按这个改了。
### ★★ 一次部署能让 sweep 静默停一小时,而「拿掉 running」把一小时变成了永久
**上半段(成立)**:三次部署之后 river 里 `anilist_ratings`、`bangumi_ratings`、`hant_backfill` 各有一条作业卡在 `running`,**59 分钟**,期间零条 sweep 日志。原因是 `UniqueOpts.ByState` 里有 `running`,而 **`running` 不代表有 pass 在跑** —— 它代表「上次写这行时它在跑」,部署直接杀进程不改状态,river 只能靠 rescuer 回收(默认一小时)。这一小时里那行和真实 pass 无法区分,开机的 `RunOnStart` 入队被**部署刚杀掉的那次 pass 的尸体**压制。
★★ **它没有症状**:被压制的 sweep 什么都不产出,而这也正是「已经追平」的样子。
**下半段(我修错了,并且上了生产)**:拿掉 `running` 看起来是显而易见的修法。**它是错的。** river 的 `UniqueOpts.validate` **强制要求** available / pending / running / scheduled 四个状态都在;少任何一个,`PeriodicJobEnqueuer` 对这个 kind **整个失败** —— 只在日志里留一行 ERROR,服务照常启动、照常服务流量、**再也不排队这个 sweep**。于是一小时的压制变成了永久停摆,直到下一次部署。
★★★ **这条约束就写在我读过并引用过的那段注释里**(`hantBackfillUniqueStates`:「available/pending/running/scheduled are required by river」)。读了、在 PR 里引了、然后照样删掉了其中一个。
★★ **测试也没抓住,而且是因为断言的东西不对**:那版测试断言集合「有 4 个元素」,而坏掉的集合**恰好也是 4 个** —— 它留下了可选的 `retryable`,删掉的是必需的 `running`。**数数不是那个性质,成员资格才是。** 新的 `TestRatingsUniqueStatesMatchRiver` 逐个断言 river 要求的四个状态在场,变异验证过。
★ 变异验证本身也犯过一次同样形状的错:第一次的变异脚本按状态序列做文本替换,而 `hantBackfillUniqueStates` 的顺序和 ratings **完全一样**,于是替换命中了文件里更靠前的那个块,ratings 根本没被改。测试报绿是对的,是探针错了。重做时把变异限定在 `ratingsUniqueStates` 的块内,并**分别打印「ratings 块已改」和「hant 块未动」**才继续。
**那一小时的窗口保留,接受它**:它靠 rescuer 自愈,而且 `hantBackfillUniqueStates` 一直是同一个形状。TODOS 里记了真正能消除它的做法 —— 干脆不要唯一性,靠 `MaxWorkers=1` 加读取时间戳,因为候选资格就是那个戳,紧接着的第二次 pass 只会往下取下一批,本来就不是重复劳动。
`ratings` 队列的 `MaxWorkers` 从 2 改回 **1** 保留:它是让并发 pass 不可达的那个东西,代价在批量降到 500 之后也小了(AniList ~21 秒、Bangumi ~4 分钟,串行每小时约占一个槽位五分钟)。
### 详情页可以看预告片了,而且不是把 YouTube 播放器搬进关键路径
迁移 0032(#167)把预告片元数据存进了目录,但**在此之前没有任何界面读它**——那次改动交付的是一列数据,不是一个功能。这次补上呈现层。
页面默认只加载一张缩略图。真正的 iframe 是**点击之后**才在对话框里创建的,所以 YouTube 的播放器脚本、cookie 和体积不进详情页的首屏——详情页是这个站 SEO 的主力页面,它的关键路径不该为一个大多数人不会点的东西付费。
所有 YouTube URL 的构造收在同一个校验边界后面(`asYouTubeTrailer`),11 位 id 的正则和 Go 侧、和迁移 0032 的 CHECK 是同一条规则。**客户端仍然校验一遍,尽管 Go 侧已经拦过**——理由不是不信任后端,是**混合版本部署窗口**:id 会被插进 iframe 的 URL,而这个窗口里前后端版本可以不一致,fail closed 比信任上游便宜。
CSP 的 `frame-src` 从 `'none'` 放开到 **`https://www.youtube-nocookie.com` 一个域**,不是 `youtube.com`,更不是通配。
三语文案齐全,`e2e/specs/sandbox/anime-detail.spec.ts` 补了 217 行覆盖。
### 预告片放不了:拦它的是我们自己的跨源隔离
CSP 那行 `frame-src https://www.youtube-nocookie.com` 是对的,线上也确实生效了。真正拦住 iframe 的是另外两个头:`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: credentialless`。
**`credentialless` 对子资源放宽,对 iframe 不放宽。** 跨源 iframe 必须自己**强制**带 COEP,而 YouTube 的 embed 只带 `Cross-Origin-Embedder-Policy-Report-Only`(外加一个正确的 `Cross-Origin-Resource-Policy: cross-origin`)—— report-only 不算数。
2026-09-08 用真浏览器对着生产量的:详情页 `crossOriginIsolated === true`,youtube-nocookie 的请求 `net::ERR_BLOCKED_BY_RESPONSE`。★ **顺带一个会骗人的信号:那个 iframe 的 `onload` 照样触发了** —— 在错误页上触发的。判据是失败的那个请求,不是 onload。
这两个头不能删:播放器要靠跨源隔离拿 SharedArrayBuffer 跑 jassub(`.ass` 字幕),没有就退化成纯文本 VTT。所以**隔离改成按路由决定**:`map $uri` 让详情页(`/anime/`、`/en/anime/`、`/zh-Hant/anime/`,zh 是裸前缀)不发这两个头,其余路由一律照旧。详情页是唯一嵌预告片的路由,也是唯一从来不需要 SharedArrayBuffer 的路由。
nginx 对空值的 `add_header` 会整条略过;就算哪天这个行为变了,空的 COOP/COEP 不是合法策略、浏览器会退回 unsafe-none —— 同样的结果,所以这条改动不会 fail closed。
三份 nginx 配置都改了,并在 VPS 上用一次性容器 `nginx -t` 验过语法再部署(配置写坏会让整站起不来,不能等 `restart nginx` 才发现)。
### sweep 占住共享限速器的那 130 秒,让 /schedule 超时了
限流修好之后的第一次干净 pass(`batches:40 rowsWritten:2000 rowsFailed:0`)跑了 **130 秒**,而生产在这 130 秒里记下一条 `/api/anime/schedule` 500、耗时 19.568s。
**我先前对影响的判断少算了一档。** 「sweep 是串行的,所以用户最坏多等一个 `minInterval`」对**单个**请求成立,对**一次要发 N 个请求的端点**不成立 —— `/schedule` 会翻页,每一页都排在 sweep 的一个请求后面,于是每页实际 ~4.2s,而它的预算 `scheduleQueryTimeout` 是 20s(照着旧的 700ms 间隔定的)。五页就出界。
`anilistRatingsBatch` 2000 → 500。**这个常量约束的从来不是「回填多快跑完」,而是「一次 pass 能占住共享限速器多久」** —— 注释现在把这句写在那里,因为下一个想加速回填的人会直接盯着前者。窗口 130s → 33s(每小时 3.6% → 0.9%)。代价只有**一次性**回填的墙钟:约 10 小时变约 37 小时;回填完进入稳态每季只剩 458 行,这个数不再起作用。
没有提高 `scheduleQueryTimeout`。让用户等 30 秒不是修复,是把症状藏起来 —— 窗口之外这个端点冷启只要 4.7 秒。
### ★ 我们对 AniList 一直快 2.8 倍,是这次 sweep 把它顶出来的
`minInterval` 从这个客户端存在起就是 700ms(≈85 req/min),注释写着「well under AniList's documented 90 req/min cap」。**那个 90 是旧的。** 从生产源站读它自己的响应头:
x-ratelimit-limit: 30
一直超额 2.8 倍,而且一直没炸——因为普通流量很少把 85/min **持续**打到窗口耗尽。
评分 sweep 会。Referer 修好之后的第一次生产 pass:`batches:16 rowsWritten:800 rowsFailed:1200`,16 批之后剩下 24 批全是 429。
**这件事的严重性不在 sweep 身上。** 超额度不会让页面变慢,会让页面失败:一个耗尽重试预算的 429 会让熔断器跳闸,而那个熔断器挂在**每一次用户侧 AniList 调用共用的同一个客户端**上。也就是说 sweep 打爆额度,代价由 search、时间表、未缓存详情页一起付,持续一个冷却周期。
现在 `minInterval` 是**从额度推导出来的**而不是记下来的:`rateLimitPerMinute = 30` 是一个具名常量,`minInterval = 60s/30 × 21/20 = 2.1s`。多出来的那 5% 余量是因为额度是个**窗口不是速率**,卡着 2000ms 走等于不给两边时钟的抖动留任何余地。
守卫是 `TestClient_ThrottleFitsTheBudget`,它检的是算术不是计时:把 700ms 放回去,它会带着被违反的那个数字变红。原来那条 `TestClient_Throttle_700ms` 只断言「限速器确实等了」——那条性质在超额 2.8 倍时同样成立,所以它从来挡不住这件事。
顺带记一条不明显但让人放心的推论:sweep 是**串行**发请求的,所以任一时刻限速队列里最多只有它的一个请求,用户请求最坏排队 2.1 秒而不是 84 秒。如果哪天有人把 sweep 改成并发,这个结论就没了。
### 评分是被读过一次的数,不是一个事实
AniList 那半原本连人数都没有。目录里存着 AniList 的 `average_score` 和 Bangumi 的 `bangumi_score` / `bangumi_votes`,于是两个来源没法比:一个 84 分背后是四十个人还是四万个人,在表里长得一模一样,谁也说不出它和 Bangumi 的 18,023 该怎么放在一起看。
AniList 的 GraphQL 里没有「评分人数」这个标量,有的是 `stats.scoreDistribution` 这个按十分档的直方图——各档人数相加才是打过分的人数。`popularity` 不是它:那数的是把作品加进列表的人,大部分没打分。
### ★ 但真正坏掉的不是缺一列,是那些数从来没被重读过
一行的评分只在第一次富化时被写入,此后没有任何东西会再去读它。四条写 `average_score` 的 AniList 路径全是请求驱动的,Bangumi 的分数和票数由 V2 的尾巴写下,而 V2 每行一辈子只跑一次。
2026-09-07 在生产库上量出来的形状没有第二种解释——2026 四个季度的 Bangumi 平均票数:
夏季(当时正在播) 8 最高 257
春季 262 最高 8,855
冬季 1,270 最高 18,720
正在播的那一季不可能比它前面那一季冷清。每一行都是在番剧公布时被读过一次然后冻在那里,这三个数唯一测量的是**各季的行碰巧是多久以前被富化的**。
所以这次加的不是一列,是**读取时间戳**——两个,AniList 和 Bangumi 各一个。写成时间戳而不是布尔,理由和迁移 0032 的 `trailer_checked_at` 是同一条:布尔只答得了「问过吗」,答不了「什么时候问的」和「哪些行该再问一遍」,而第三个才是承重的那个。有了戳,「谁到期了」是一句 WHERE,「问过了,答案确实是零」也不再和「从没问过」长得一样。
节奏:**当年及以后**的行每季度重读一次;**其余的行读一次就不再读**。后半句是有意的,也正是戳买来的东西——以后想给存量行排一个更慢的周期,改的是那句 WHERE,每行最后一次读的时间已经记在那里了。
### 两条 sweep 而不是一条
AniList 那条按 id 批量取,一次请求 50 行(`Page(media: id_in: [...])`),全目录约 370 次请求;Bangumi 那条一行一次 subject 请求,走 `/match` 和弹幕查询共用的那个 800ms 令牌桶,同样的目录是一万三千次。折成一条,一个卡住的 Bangumi 会把 AniList 的刷新一起扣住,而两边的批量上限也没法各自设——那正是「半分钟跑完」和「跑四分钟」的差别。
顺带补上了 TODOS 里预告片那条记着的能力:「按 id 批量补一个 AniList 字段」的查询常量和客户端方法现在存在了,下一个要给存量行补的字段可以直接用,不用再想一遍。它也够得到按季度取数够不着的那批——目录里 4,500 行没有季度。
### 三处会静默出错的地方,都钉在 SQL 里而不是调用方
`average_score` 走 COALESCE:这条 sweep 一次走完全表,AniList 返回空值时保留已存的分数,而请求驱动的那四条路径照旧无条件覆盖。同一个空值在两处含义不同——那四条各自回答的是刚被点开的一行,这条错一次就是整个目录一起错,而且没有第二个来源能把分数找回来。
`updated_at` 只在数字真的变了时才推进。它是 sitemap 报给 Google 的 lastmod,一次季度扫全表如果每行都盖一遍,等于什么都没变却重新发布了整份 sitemap。
`anilist_score_votes` 的 CHECK 拒绝没有观测记录的人数,也拒绝负数。得由列来拒绝而不是调用方:sweep 会吞掉单行的写入失败,一个调用方放过去、约束挡下来的值,在生产上唯一的症状是**那一行悄悄不再刷新**。
三条都做了变异验证:把条件 `updated_at` 改成无条件、去掉 COALESCE、把存量行也改成季度重读,对应的集成测试各自变红。另外两条行为上的——AniList 没返回的 id 不盖戳(会让它们排在此后每一次 pass 的最前面,永远占着同样的名额),以及批次失败时盖戳(一次 AniList 故障就会把整个目录静默标记成已读)——同样验证过。
### ★★ AniList 从来没有停摆,是我们少发了一个请求头
上面这段最初写的是「AniList 的 API 现在是关的」,并据此把部署推后。**那是错的。**
`graphql.anilist.co` 对**没有 `Referer` 头**的请求返回 403,body 是一句 GraphQL 形状的
`The AniList API has been temporarily disabled due to severe stability issues.`
—— 一句描述上游故障、把读者支去找状态页、而且不成立的话。同一秒、同一个 IP、同一条查询,带上 Referer 就正常返回数据。
这个门只测「有没有这个头」,不测内容:`https://anilist.co/`、`https://example.com/`、连字符串 `abc` 都能过;`Origin` 不接受;浏览器 UA 不带 Referer 照样 403。而 `internal/anilist/client.go` 的 `do()` 只设了 `Content-Type` 和 `Accept`。
代价是三天:生产上 search、schedule、seasonal 冷启动、以及任何未缓存的详情页全程返 502/500(单个容器窗口内 1,948 条 `anilist upstream: 403`),而这三天里我们以为自己在等一个上游恢复。这句错话还进了 README、TODOS 和 PR #169 的正文——都已一并改正。
修法是两行:`Referer` 填我们自己的域名(比浏览器给的信息更准确),外加一个能表明身份的 `User-Agent`(它单独不开门,但匿名的 `Go-http-client/1.1` 正是反滥用过滤要抓的东西,而这个仓库对 AniList 的负载历史值得被抓)。真正回应对方「stability」诉求的是限速,而 `minInterval` 本来就是 700ms/请求(约 85/min,在其文档的 90/min 之下)。
**教训不是「加个头」,是「上游给的错误信息可以是错的」。** 判定「外部挂了」时,两个不同 IP 拿到同一句官方文案**不足以**支撑结论——那只说明两边触发了同一条规则,没说明那条规则是什么。真正花了两分钟就问出答案的动作是:换一组请求头再问一次。
配套的测试是 `TestClient_SendsRefererAndUserAgent` 和 `TestClient_EveryQueryPathCarriesTheHeaders`。这个文件里其他所有测试驱动的假服务器都不看请求头,所以删掉那两行 `Header.Set` 整个套件照样全绿,而生产会整片 502——这两条测试是那条边上唯一的栏杆。
部署顺序:先跑迁移 0033,再部署 API。不需要手动触发任何东西,两条 sweep 都是 `RunOnStart`。
### 一个空的预告片字段有两种意思,缓存必须记得是哪一种
目录里新增预告片元数据(迁移 0032):季度、年度、精选三个列表返回 `trailerId` / `trailerSite`,详情返回 `trailer` 对象。只接受 YouTube 的 11 位视频 id,且这条规则写在列的 CHECK 约束上而不只是调用方的校验里——存进来的是 id 和站点,不是任意的嵌入地址。
难的部分不是取值,是空值。AniList 返回的空预告片有两种完全不同的含义:查询里选了 `trailer` 而它是空的,那是「这部番确实没有预告片」;查询里压根没选这个字段,那只是「没问过」。把后者当成答案,一次搜索就会把已经存好的预告片抹掉。
有五条路径会把 AniList 的数据写进目录缓存,它们用的不是同一条查询。原先靠每个调用点自己记得补一行赋值来区分,而四个该补的地方只补了两个——正是这个仓库在分集标题的来源列上栽过的同一种坑:不变量只活在约定里,数据库的 CHECK 抓不到,新增第六个调用点时会静默漏掉。现在「这次查询选没选 trailer」是归一化函数的一个参数,类型定义在发出查询的那个包里,不回答就编译不过;另有一条测试直接断言三个查询常量的选择面,把类型的说法和 GraphQL 文档的实际内容钉在一起。
记录用的是时间戳而不是布尔标记。布尔只能回答「问过了吗」,答不了「什么时候问的」,更答不了「哪些行该重新问一遍」——而后者是真实需求:开播前还没发预告片的新番,和当时站点不支持的条目,都被存成了「问过,没有」,没有时间戳就再也找不出它们。
顺带把 CI 的带标签编译门从单个目录放宽到整个模块。原先它只盯 `test/integration/`,于是写在被测代码旁边的带标签测试两个 job 都编译不到——既不在不带标签的那次,也不在按目录限定的这次。这个仓库为同一个机制静默丢过整整两个月的测试覆盖。
新列上线时全库是空的,而列表接口不会自己去补——只有季度预热和详情页访问会写。所以部署顺序是先跑迁移 0032、再部署 API,然后打一次 `POST /api/admin/warm-all?startYear=1940`。参数不能省:那个端点的缺省起始年份是 2014,而目录里的条目最早到 1940。顺序也不能反:先预热会把整批行的 `cached_at` 推到当前时间而查询时间戳仍是空,那正是这次要堵的那个窗口。没有季度的条目(剧场版 / OVA / 特典)预热取不到,它们在详情页第一次被访问时补齐。
---
## [3.32.0] - 2026-09-06
### bangumi 侧从来不撤回它不再持有的行
一条 upsert 只说「这个来源现在有什么」,说不了「它以前有、现在没有了」。dandanplay 那条写入路径从写下来就补上了这半——upsert、撤回本次没覆盖到的本源行、删掉撤回之后空掉的行、盖戳,一个事务。**bangumi 这条只做 upsert。** 于是一份变短了的列表会把旧尾巴留在新行下面,在表里和已经确认过的标题长得一模一样;上一批那种行是靠手写 `DELETE` 清掉的,因为没有任何写入方够得到它们。
接上撤回是容易的那一半。要想清楚的是随它一起的守卫。
### ★★ 守卫是窗口,不是阈值
bangumi 的列表**经常是不完整而不是变短了的**。持有 bangumi 来源行的番里,有一批本来就比自己这一季的集数还少,另有几百部中间是有洞的——**稀疏是这份数据的正常形状**。所以「这次没提到它」不能等于「删掉它」,而查询本身带的那个 `cardinality(kept) > 0` 看不出区别:两种情况都非空。
第一直觉是比行数:新的比现存的少就不撤。这个方向错在两处。
一,**它会否掉这件事本身要修的东西**。一部 4 集的 ONA 绑到一个 3528 集的 subject 上,修好之后缩水 99%,而那个缩水**就是修复**。
二,**这在本仓库是个新习语**。翻了一遍现有的守卫——source 标签的等级、`bangumi_version` 的 `GREATEST`、`title_hant` 的 manual 保护、描述回填的空位限定——**比的全是等级或集合,没有一个比行数**。
所以问一行的问题不是「有多少」,而是「这一集有没有可能属于这个条目」:
| 这一行 | 判定 |
|---|---|
| 不在本次列表里,**且超出本季集数** | 撤回。网格只渲染 1..episodes,写入方也不会再产生它 |
| 不在本次列表里,但**落在本季之内** | 加回保护集,并计数上报 |
| 条目没有集数 | 一行都不撤 |
最后一行不是边角情况,它是 `episodes_bgm` 写入的**全部**人口(那个作业的候选条件就是 `ac.episodes IS NULL`,推导集数正是它的工作),所以那个写入方被有意留在只 upsert 的路径上。房子里的对应先例是 `MarkEpisodesBgmAttempted`:只有 `rejected` 会清掉已存的值,`undecided` / `empty` / `error` 一律不动——**缺席证据不是反面证据**。
上报出来的 `rowsShielded` 是值得盯的那个数:它说的是 bangumi 抓回来比我们已有的还少这件事多久发生一次,而这个数决定了要不要给它建一条人工复核队列。
### 撤回走自己的事务
它跟在 upsert 之后而不是把 upsert 包进来,所以两个调用方依赖的 best-effort 契约没变:失败只赔撤回不赔标题,行照常盖戳——不盖戳的行会回到每一批的队头,为了重试一次可选的清理再花掉两次 bangumi 请求。撤回和删除这一对倒是必须原子:只撤不删会留下两列皆空且没有来源的行,而以后任何一次撤回都够不到它们,因为找它们的 `WHERE` 匹配的正是它刚刚抹掉的那个来源。
### 落地当天几乎什么都不会做,这是有意的
拿生产数据抽了 40 部番做模拟,逐个真去 bgm.tv 抓列表、跑真实的归一化:**撤回 1 行,新增 335 行。** 现在全库没有任何一行落在自己这一季的集数之外——上一轮的清理清掉了,而 #156 堵住了产生新的那条路。**这是保险,不是修复**:下一次尾巴出现时,代价是一次扫描而不是一条手写 `DELETE`。
### ★★ 一条变异活了下来,因为那条语句根本没被执行过
六个变异,第一轮活了一个:把撤回拿到的保护集换成原始抓取集。原因不是断言弱——是 `withdraw == 0` 时函数提前返回,而当时**每条测试都只有一个判定**(要么只有尾巴、要么只有空洞),谁都走不到那条语句。补了「同一部番同时有尾巴和空洞」这一格才杀掉。
★ **一条语句需要两种判定同时出现才会被执行时,只有单一判定的测试集不构成覆盖。**
另有一个变异只被单元测试杀掉、集成测试毫无反应,那本身是个发现:**撤回集完全由窗口决定,跟这次抓回什么无关**(保护集已经被窗口截到本季之内,所以超出窗口的行永远不在里面)。这条独立性是特性——降级的抓取只能把撤回缩到零,扩不大它。原来的注释把抓取集说得比它实际承重的更重要,已经改掉。
---
### 记下这个集号用的是哪套编号
产品里每一个本地集号都诞生在同一个函数里,而那个函数**此前一个测试都没有**,并且把一个关键事实丢掉了:它读到的数字,是本季自己的 1..N,还是整个系列连续数下来的。
两者作为数值无法区分。字幕组写 `[03]`、`第03話`、` - 03 `,对第二季的第三集和整部的第三集产生的是同一个字符串,而它到底指哪个是那个组的习惯,不写在文件名里。站点已经为猜这件事付过账:**曾有两万多行分集标题落在自己这一季的集数之外**,全部出自「这个数字看起来像本季的,那就是本季的」。
`parseEpisodeMeta` 现在返回 `numberSpace`,并且默认承认自己不知道。只有两种东西算证据:
| 文件名 | 集号 | 编号空间 | 依据 |
|---|---|---|---|
| `[S02E03]` | 3 | `perSeason` | 这套写法在构造上就把季和集分开了 |
| `[總第67][第03話]` | 3 | `perSeason` | 文件名同时给了两套数,我们读的是另一套 |
| `[總第67]` | 67 | `absolute` | 我们读到的就是它声明的那个系列级数字 |
| `[12]` | 12 | `unknown` | 最主流的那种写法,什么也证明不了 |
| `第二季 [第87話]` | 87 | `unknown` | 季度标记说的是属于哪一季,不是从哪儿开始数 |
| `One Piece - 1091` | 1091 | `unknown` | 它**事实上**是绝对集号,但文件名从没这么说 |
最后一行就是纪律本身:**猜对了的猜测仍然是猜测。缺失一律读作 unknown,绝不默认成 perSeason**——这和 `episodeOffset` 那条「0 是答案,undefined 不是」是同一条规矩的另一半。
为此把原来内联在函数里的正则表数组提成了模块常量,每一条带上「它能证明什么」。顺序、正则、分辨率黑名单、去技术标签后的兜底全部未动,函数签名也没动,所以没有任何调用方需要跟着改。
**字段目前只携带不消费。** 会用到它的三个消费方——网格的偏移推断、弹幕匹配请求、观看进度的换算——今天各自从 `episodeOffset` 加 `totalEpisodes` 去推,换掉推断来源是另一件带回归的事。现在先让证据和数字待在一起,免得将来要从一个没人留下的文件名里重新推一遍。
★ 顺带给这个模块补了**它有史以来第一批测试**,21 条,其中 9 条是给既有行为织的回归网——重构一个没人测过的函数,正是那种会悄悄出错的重构。其中一条断言是我自己写错了而代码是对的:`parseSeason` 有意只在明显的季尾位置认罗马数字,所以 `Show II [03]` 返回 null。现在两个方向都钉住了,反例用的是 `FF VII Remake`。
---
### 把对应关系的基数写进类型里
本地集号和上游分集之间的对应关系,**两个方向都不是一对一**,而 `map[集号]单个值` 说不出这件事。
**一对多是沉默的那个。** 两条上游记录可以带同一个集号——一个播出版一个导演剪辑版,或者一个重新编号过的条目——而第一趟匹配命中之后就 `break`。第二条从此不可达,且没有任何地方记下它存在过。下游每一个消费方读到的都是第一条,而且**看起来都是对的**:弹幕轨道、分集标题、「已匹配 N 集」的计数,以及匹配错了之后用户会去用的那个手动选择器的预选项。
**多对一是另一个方向,而它通常是真的、不是坏的。** 一个文件夹里同一集有两个文件、分别按本季和按系列编号(1 和 29),那就**确实**是两个本地文件对一集上游。
去重是那种一看就对的修法,而它是错的:非库播放路径的选集条就是从这张映射里建的(没有条目的文件会被跳过),去掉第二条映射会让那个文件的 chip 直接消失——**拿「弹幕放错」换「文件不见了」,是更差的交易。**
所以改的不是匹配。`BuildEpisodeLinks` 保留胜出那一级的全部候选,`BuildEpisodeMap` 把它压平成 wire 和播放器一直在用的那个一集一条的形状,而**压平从一个控制流关键字变成了一个有名字、有测试的步骤**。三个级别仍然是优先级而不是合并:精确数字匹配和「在列表里排第几」不是两个平等的意见。
行为逐字未变,JSON 契约没动,前端零改动。**既有的十条测试一字未改并且仍然全过——这才是「行为没变」的证明**,而不是提交信息里的一句声称。
之所以要赶在逐集映射层存在**之前**做:那一层的全部意义就是承载单个整数装不下的关系,而让它流经一个会静默只留第一条的函数,等于在入口处又压扁一次。
### 另外
- 本地 `internal/dandanplay` 的容器化测试会间歇性报 `pull access denied for animego-postgres`——**注意少了 `:dev` 标签**,而 `testutil` 里传的明明是带标签的。重跑即绿。它会把 `go test ./...` 整个染红,排查时别当成自己的回归。
- `VERSION` 停在 3.22.0,与本文件的版本号早已不同步。本次未动。
---
## [3.31.0] - 2026-09-06
### 把「只被 CI 跑过一次」的代码拿去做变异测试,六个变异全活了下来
昨天 #156/#157/#158 三个 PR 的测试都只有 CI 的一次通过。本地 Docker 起来之后把它们全部做了一遍变异,共 21 个变异:
| 目标 | 变异数 | 结果 |
|---|---|---|
| `windowTitles`(纯函数) | 6 | 全部被杀 |
| `#157` 无标题行过滤 | 2 | 全部被杀 |
| `admin ReEnrichIDs` | 7 | 全部被杀 |
| **`sweepOne` 的路由 / 兜底 / 盖戳** | **6** | **全部存活** |
★★ **纯函数覆盖得最好,而「这一行走哪条路」一条都没覆盖。** `windowTitles` 有自己的表驱动测试;它周围的三个决定——这一行该问哪个上游、兜底到底进不进得去、什么都没产出的行有没有被盖戳——一个断言都没有。CI 全绿对这三件事什么都没说。
存活的六个:`!mapSpeaks` 取反、删掉「ddp 条目指向别的番」那条臂、空列表时不兜底直接结束、rejected 不盖戳、empty 不盖戳、越界时退回拒绝而不是开窗。
### 这些测试只能写在一个地方
`sweepOne` 未导出,外部测试包够不到;worker 故意持有具体的 `*dbgen.Queries` 和 pool(写入是和 CLI 共用的四语句事务),内存 double 顶不了数据库那一半;而放在 `internal/queue` 下加 build tag 的话**永远不会被执行**——CI 的集成步骤只扫 `./test/integration/...`,正是那个步骤自己注释里记着的「整个包两个月没编译过」的腐烂方式。
剩下的形状只有一种:**驱动导出的 `Work()` 跑真实的一遍扫描**,只假造两个上游客户端,结果从数据库里读回来。六条测试各自钉一个决定,逐条验证能杀掉对应的变异——A7 被两条杀(它同时反转了两个方向的含义)。
### ★ 我自己写错了一个变异,它「存活」但什么也没说明
给 `noBinding` 那段写的第一个变异是把
```
if x { a; continue }
```
拆成
```
if x { a }
if x { continue }
```
——**语义完全等价**。它当然「存活」,而这个存活不是覆盖率缺口,是我的变异写错了。改成「`noBinding++` 整行删掉」之后立刻被杀。
★ **变异测试的报告里,「存活」有两种含义**:断言不够,或者变异根本没改变行为。只数存活个数会把后者读成前者。
### 另外
`episode_titles_releasing.go` 一处注释把测量日期写成了 2026-09-25(未来),改回 2026-09-05。
---
## [3.30.0] - 2026-09-06
### ★★ 修完 500 之后,同一条 e2e 又红了——这次是「看到第 3 集」被并发写回 0
#159 修掉 `POST /api/subscriptions` 的 500 之后,`library-watch-sync.spec.ts:274` 仍然失败,但形态变了:订阅建出来了、状态是 `watching`,**`currentEpisode` 是 0,不是 3**。那次运行的服务日志里**一个 500 都没有**(修生效了),有的是两次几乎同时的 `PUT /api/subscriptions/900201/episodes`,双双 200。
### 快照问题的第二种形态:坏掉的不是行数,是那个数
`MarkEpisodesWatched` 对自己的快照是很小心的——`watched` 特意 UNION 了 `inserted`,注释写明「plain SELECT 看不见本语句刚写的行」。但它只补得上**自己**那一半:
```
target SELECT ... FOR UPDATE ← 两个 PUT 在这里串行
inserted INSERT ... ON CONFLICT DO NOTHING
watched SELECT FROM episode_watches ∪ inserted ← 仍然走自己的快照
recomputed UPDATE ... SET current_episode = MAX(watched)
```
后到的那次在 `FOR UPDATE` 上等,等完之后**继续用开始等之前取的那个快照**——赢家写进 `episode_watches` 的三行在它眼里不存在。它自己要插的第 1 集撞键、`inserted` 为空、`watched` 为空,于是 `MAX(空集) = 0` 被写进 `current_episode`。
★★ **集合确实只增不减,倒退的是从集合派生出来的那个数。** 这条查询的注释用大写字母写着 IT UNIONS. IT NEVER REPLACES —— 说的是集合,破的是数字。开两个标签页就够了。
### 改法:拿一个不受快照约束的值兜底
`s.current_episode` 是这条语句里唯一不受快照约束的值——READ COMMITTED 下 UPDATE 会**对并发写方提交后的最新行版本重新求值**。所以两个 mark 查询改成
```sql
SET current_episode = GREATEST(s.current_episode, (SELECT COALESCE(MAX(...)) FROM watched))
```
这正是它自己那条契约(只增不减)套用到派生值上。降低仍然只发生在 `UnmarkEpisodeWatched`,那条**故意不加**下界——加了就变成删不掉。
先确认过没有语义冲突:migration 0024 把 `1..current_episode` 回填进了集合,`current_episode = MAX(集合)` 从那一刻起处处成立,`GREATEST` 只在读不全时才与原式不同——也就是只在竞态里。
### 测试同样是把竞态摁住
一个事务写 1/2/3 挂起不提交;另一个 goroutine 推第 1 集,撞在行锁上;主 goroutine 轮询 `pg_stat_activity` 确认真堵住了才提交。修前 **3 → 0**,与线上完全一致;修后 3。单集入口是另一条查询,单独一条测试——**只回滚其中一条,另一条的测试照样绿**,两个变异各自被自己那条杀掉。
### ★ 未修,写进注释而不是糊过去
`UnmarkEpisodeWatched` 有镜像的暴露面:取消标记若与「标记更高集」并发,会算出一个低于真实集合的值。**不加下界是故意的**(那会让取消标记无法生效),理由三条写在查询注释里:需要同一个人同时在两处一标一取消;集合本身没丢;下一次任何写入都会重算回来。
---
## [3.29.0] - 2026-09-06
### ★★ 那个「大概是 flake」的 500,是一条真的并发缺陷
上一轮合 PR#158 时看错了 check 名字——两个 Playwright workflow 只有一个是 PR 本地栈——在 sandbox 红着的情况下合了,当时把它记成「套件不稳」。回去把服务日志翻出来,同一毫秒里三个 `POST /api/subscriptions` 重叠,一个 201,一个 500:
```
13:55:45.175 POST /api/subscriptions 201
13:55:45.291 WARN upsert subscription failed cause="no rows in result set"
13:55:45.292 POST /api/subscriptions 500
```
★ **数有几个绿的,不算验 CI。** 逐个念出 check 的名字和它的结论才算。
### 机理:`ON CONFLICT DO NOTHING` 的结果,同一条语句读不回来
`InsertSubscriptionIfAbsent` 原本是 `DO NOTHING` 加一条 `UNION ALL` 读回臂。撞键时 PostgreSQL 会让这条语句**等**对方事务的 speculative token,但语句的快照是**开始等之前**取的:对方提交后,插入臂没有行,而读回臂同样看不见那条刚提交的行——它比快照新。零行,`:one` 把零行变成 `pgx.ErrNoRows`,处理器只能报 500。
先在裸 SQL 里把它复现出来再动代码:同一条查询,**顺序执行返回 1 行,重叠执行返回 0 行**。重叠是唯一的自变量。
改法是让冲突臂拿行锁而不是读快照——`DO UPDATE SET status = subscriptions.status`,把存着的值写回它自己。这是个构造上的 no-op:status / current_episode / score / last_watched_at / created_at / updated_at 全部保持原样,§4 decision 3「status 只由人改」照旧成立;它存在只是为了给 `RETURNING` 一行可还。整条 `UNION ALL` 因此消失,SQL 比原来短。
### 让一个竞态可测:等到 Postgres 说它真的堵住了
回归测试把这个竞态**摁住不动**:一个事务插入后挂起不提交,处理器在自己的 goroutine 里发同一个请求撞上去,主 goroutine 轮询 `pg_stat_activity` 直到出现 `wait_event_type = 'Lock'` 且语句文本里带这条查询的名字(sqlc 会把 `-- name:` 头一起发出去,所以改了查询体也还认得),确认真堵住了才提交。
★ **提交早了就什么也没测到**——那只是两次顺序插入,而顺序执行本来就是好的。
修前跑:500,错误串和线上一字不差。修后:201,且返回的是对方那行的 `dropped` 状态。
变异四条:冲突臂改 `EXCLUDED.status` → 状态断言挂;冲突臂改成产不出行 → 主断言挂。★ 还有一条**变异开猛了反而问不出话**:给冲突臂加 `WHERE false` 会让顺序路径也返回零行,于是「去掉等待守卫是否还能抓到」这个问题它答不了——最后是拿裸 SQL 的顺序/重叠对照回答的。
### 同一个写法在仓库里有三处
按症状搜只会找到一处,按**写法**搜(`DO NOTHING` + `UNION ALL` 读回 + `:one`)找到三处:
| 查询 | 有人调吗 | 撞上时的用户可见结果 |
|---|---|---|
| `InsertSubscriptionIfAbsent` | 有 | 500 |
| `CreatePendingReport` | 有 | **404「举报对象不存在」** |
| `InsertNotificationDedupe` | 无 | — |
举报那条更阴:处理器本来就把 `ErrNoRows` 当成「目标没了」映射成 404,于是并发举报同一目标的人被告知对象不存在,而他自己的举报正躺在审核队列里。
★ 它**不能**用同样的 SQL 改法:`reports` 上是两条**部分**唯一索引(评论一条、用户一条),而 `DO UPDATE` 只能推断其中一条。所以改在处理器:空结果重试一次。重试跑在新快照上,撞键的能读回那条 pending 举报;目标真的不存在则仍然空,仍然 404。两种含义就此分开,各有一条不需要容器的单测;变异(去掉重试、重试两次)都被调用次数断言抓住。
第三条没有任何调用方,只在注释里被提到过一次。**不改动没人调用的查询**,但把这颗雷写进它的注释:谁要接它,先看 subscriptions 那条的长版说明。
### ★★ CI 自己的诊断说反了,而我写的第二版判据同样是错的
同一次 main 上的 sandbox 失败是另一回事——`/seasonal/spring/2026` 预热 404,是已知的 Turbopack「`[lang]` 下嵌套动态段整段不编译」。守卫打印的判据说「缺 `generate-params` + 编译时间接近零 = dev-server 缺陷;有 `generate-params` + 几百 ms = 真回归」,这次日志两条都不成立:`/anime/0` 无 `generate-params` 而路由完全正常,`/seasonal` 第一次是 650ms 并不接近零。
于是我改成「看这条路由有没有属于它的 `○ Compiling <段>` 行」——**然后本 PR 自己的失败把这一版也证伪了**:那次运行整份 dev 日志一条 `○ Compiling` 都没有,而 `/` 和 `/welcome` 都正常编译并返回 200。本地起 dev 复核,同样一条都不打印。再补一刀:**真产品 404 的重复请求也会掉到 ~100ms**(`/anime/0` 第 2/3/4 次分别 137/153/101ms),所以「重试稳定在几十毫秒」也分不开两者。
★★ **迄今试过的每一条「看日志形状」的判据都有反例。** 守卫文案因此改成不再断言任何签名,只给出一分钟内能出结论的动作:**切到这个 commit,本地 `npx next dev`,打同一条路由**。季度页会吞掉 API 失败,不用起后端。本地能出 200 就是 (1),本地也 404 就是 (2) 且你已经拿到了复现。
本 PR 就是这么判的:本地 `/seasonal/spring/2026` 返回 200(850ms,首次编译),`/anime/0` 返回 404,与线上表现一致——沙箱那次红的不是这次改动。
### 判断
服务端的幂等创建现在真的幂等了,包括重叠调用。客户端为什么会对同一部番同时发出两次创建没有一起改——契约修好之后那是个浪费,不是缺陷。
---
## [3.28.0] - 2026-09-05
### ★★ 一集配错标题有三条互不重叠的路,我一开始以为只有一条
从「每十行分集标题就有一行落在自己那一季的集数之外」查起(**21,001 / 203,019 行,涉及 960 部**),本以为是集号归一化那个 `offset := floor(min Sort) - 1` 的锅——它和 PR#108 从库网格里拔掉的 `shift = lowest - 1` 是同一个假设。
★ **形状相似不等于成因相同。** 那个减法总把最小集映射成 1,**不可能让序列冲出上界**;冲出上界的是 bgm 的一个 subject 覆盖范围大于一个 AniList 条目。缺的一直是上界校验,不是那个减法。认出一个熟悉的反模式之后,仍然要单独证明它和眼前的现象有因果关系。
### 路 1:上界是窗口,不是过滤器
按有没有 PREQUEL 边把 960 部拆开,两种处理方式的代价完全不对称:
| 分组 | 数量 | 含义 |
|---|---|---|
| 无 PREQUEL | 693 | 第一季,前缀就是本季,溢出的是尾巴 |
| 有 PREQUEL | 267 | 续季,**槽位 1..N 现在装的是上一季的标题** |
一刀切拒绝会为了修 267 部而毁掉 693 部本来正确的标题。所以 `normalizeEpisodeTitles` 改成三分支:shifted 合身则原样保留;溢出且 offset 已知则取 `[offset+1, offset+total]` 这个窗口;溢出且 offset 未知则一行不写。★ `known:false` 不是 `offset:0`,在没人确认过的原点上取前缀,产出的是一个**渲染得像正确答案的猜测**。
`total <= 0` 直接放行不设界——`ListEpisodesBgmCandidates` 的 `WHERE ac.episodes IS NULL` 保证了 episodes_bgm 那个调用方永远走这条,它的工作恰恰是从这份列表推导集数。
★ **变异测试里「上界差一」第一轮活了下来**,因为唯一相关的那条测试传了已知 offset,走窗口分支返回同样结果。补一条「恰好填满 + offset 未知必须写入」才抓住。一条测试覆盖到某行代码,不等于它能区分那行代码的变体。
### 路 2:一个 subject 描述一个作品,而模糊匹配那条门没有这条守卫
聚合数字有个盲区:这一整轮的数全部派生自同一个谓词 `episode > episodes`,于是**「编号没超界但对应错了」的情况一条都不会出现在任何一张表里**。它是在举具体例子时才暴露的——JoJo 石之海 Part 2 的详情页显示的是 Part 1 的分集标题,而它 `max_ep 12 ≤ episodes 26`,上界一次都不会触发。
查全之后:**541 个 subject 被两个以上 anilist_id 共用**,牵涉 1,161 行、22,019 条分集标题。★★ **其中 `bgm_match_source='id_map'` 的是 0 个** —— `BindBgmIdsFromIdMap` 从写下来就有「已被占用就不绑」的 `NOT EXISTS` 守卫,`UpdateBangumiV1`(模糊匹配那条路)没有。这个不对称就是全部碰撞的来源。
`UpdateBangumiV1` 现在先查 `CountAnimeBoundToBgmID`,占用则 **park 成 `needs-review`** 而不是跳过——留在 version 0 的行会经孤儿扫描回来、每一轮再撞一次。SQL 里另加 `NOT EXISTS` 兜住预检关不掉的竞态,所以零行写入的日志从 `stale_skip` 改成 `stale_or_taken`:它不再只有一个原因。读失败返回错误而不是继续:**一次读不到,不是可以绑定的许可**。
数据侧清理后:共用 subject **541 → 77**,共用条目 **1,161 → 163**,越界行 **21,001 → 12,208**。
### ★★ 同一个病犯了两次:换了数据源,没换「相信单一源」这个习惯
**第一次**,把 `bgm_id_map` 的 schema 注释「Authoritative」当成了证明。拿 dandanplay 复核后,264 个可裁决 subject 里 **id_map 错了 26 个(约 10%)**,主导模式是「剧场版的 subject 被指到 TV 系列」。救我的不是判断力,是两条出于别的理由加的保守过滤——剧场版通常 1 集 1 行,`rows > episodes` 为假,正好被排除。**是巧合,不是设计。**
**第二次**,转头把 dandanplay 当成了新的权威。★★ `bangumi/bgmtv/{id}` 是**反向**查询,而 dandanplay 自己有多个条目共用同一个 `bangumiUrl`,反查只返回其中任意一个。它对 subject 18692 说是 anilist 501,bgm.tv 的地面真相是 `ドラえもん / 2005-04-15` = 8687。我照着 park 了 Doraemon (2005),又照着把一个本来判对的条目回滚成了错的。**正向(给定 AniList id 找 bangumiUrl)可靠,反向不可靠。**
最终靠 bgm.tv 自己的 `name` / `date` / `eps` / `platform` 四个独立信号交叉才收敛:对全部 549 个已 park 条目做权威审计,**492 判对、10 可能判错、9 打平**,按硬规则纠正了 11 条。
★ 一处不可逆损失:有一个条目是临时用单条命令备份的,**只备了 `anime_cache` 没备标题行**,它被 park 时删掉的 100 行恢复不了。批量流程每次都是两张表一起备的,加急操作漏了。
### 路 4:RELEASING 补写扫描空转了很久,而数据一直在上游
三次相隔六小时的扫描报的是同一组数:
```
candidates 123 accepted 0 rejected 77 empty 46
candidates 123 accepted 0 rejected 77 empty 46
```
对当时到期的 132 行按扫描自己的判定顺序分类:
| 归宿 | 数量 |
|---|---|
| dandanplay **有**该 subject 但不发布 AniList 交叉链 | 68 |
| dandanplay 没有该 subject | 35 |
| 列表比本季宽 | 2 |
| 本该写入成功 | 27 |
dandanplay 的交叉链是这个扫描唯一能自己做的身份检查,所以缺链就终止。而**缺链是常态不是边角**:103 / 132。bgm.tv 对其中约一半有带名字的正片(抽 18 个,9 个有),而 `bangumi_v2` 路径本来就在用它。
三处修改:**(1)** ddp 无法背书时改问 bgm.tv,身份闸门复用 episodes_bgm 那边的 `evaluateBinding`,`undecided` 仍然不算通过——换的是上游,不是证据门槛。**(2)** `rejected` 和 `empty` 现在也盖 `episode_titles_at`:两者原来都不打戳,而候选查询 `NULLS FIRST`,于是这些行永远排在队首、能服务的行排在它们后面。★★ 这是 migration 0015 和 0023 各自记录过的同一个 stall 的**第三扇门**:「我们什么都没决定」必须和「我们从没看过」可区分。**(3)** 过宽的列表改取窗口而不是整体拒绝,用的是和 `normalizeEpisodeTitles` 同一个 `[offset+1, offset+total]`,且都经由 `episodeBound` 取数——两个写入方不可能对「一季从哪开始」有分歧。
★ 测量本身也翻过一次车:第一版统计脚本 grep 的 `episode_type` 在 `/v0/episodes` 的响应里根本不存在,于是 18 个样本全返回 0——**看起来像「bgm.tv 也没数据」这个干净结论**。用已知有 13 集的 subject 校准才发现。今天第三次出现同一形状(`bool_or` 全 NULL 返回 NULL、标签配对校验器给内容稀烂的文件放行、grep 错字段):**校验器回答的是它自己的问题,不是我的问题。**
### 仍然不能说「都绑对了」
已绑定 12,997 个里,**4,664 个没有任何来源记录**(占 36%),任何审计都够不到;`id_map` 6,422、`fuzzy_high` 1,910。未绑定 5,048 个(2,473 从未尝试、2,542 明确拒绝)。仍有 77 个 subject 被共用,**其中 21 个来自 id_map 自身的重复**——新守卫堵的是模糊匹配那条门,堵不住这一类。
⚠️ 兜底与盖戳**没有测试**:那个 worker 持有具体的 `*dbgen.Queries`(写入是共享事务),需要集成套件的用例,本轮未补。
---
## [3.27.0] - 2026-09-05
### ★★ 一轮 4,000 次的预算,2,730 次会花在昨天已经回答过的行上
今天这轮分集标题回填第一次启动,200 行只写出 **1** 行,花掉 196 次请求。
`--resume` 的名字比它做的事宽:`episode_titles_at` **只在写标题的同一个事务里打戳**,所以一行「上游没有」或「没有任何来源为这个绑定背书」的行从来不打戳,于是永远留在候选列表的头部,每一轮都被重新问一遍。这个默认本身是对的——重问一个空 subject 只花一次请求,而漏掉一行没写过的会静默丢掉它——但**它的代价不是常数**。
实测:5,436 个候选里 **2,730 个排在 09-04 那轮停下的位置之前**,而它们昨天已经全被判成 `NO_TITLES / UNDECIDED / DDP_REBIND / SCOPE_MISMATCH`。这些判决不会隔夜改变,那 200 行 1 次写入就是证据。而这份预算和线上弹幕共用同一个功能组——白烧的不只是自己的一轮。
★ **断点是反推出来的,不是猜的。** 把 09-04 那轮看到的候选集重建出来(今天的候选 ∪ 那轮打过戳的行 = 6,910 行),它最后一次写入落在 `anilist_id 141932`,排在第 **4,132** 位——正好是它 4,000 次请求预算耗尽的位置。
`--start-after-id` 因此是**预算工具不是正确性工具**:被它跳过的行仍然是候选、仍然没打戳,不带这个 flag 的一轮照样会走到它们。边界用 `<=` 而不是 `<`,因为传进去的 N 是上一轮**够到过**的最后一个 id,那一行已经被问过了。真正的修法是把「问过」和「写过」分开记,那需要一列和一个 migration。
### 分集标题:2,709 行从没被问过的行,2,628 次请求跑完
在预算内**完整收尾**——`SKIPPED` 0(整条尾巴一次跑干净),没有中止,`QUOTA_SPENT` 和 `FETCH_FAIL` 各 0。
| 分类 | 数量 | 占比 |
|---|---|---|
| **WRITTEN** | **1,296** | 47.8% |
| NO_TITLES | 969 | 35.8% |
| UNDECIDED | 265 | 9.8% |
| DDP_REBIND | 159 | 5.9% |
| SCOPE_MISMATCH | 20 | 0.7% |
| QUOTA_SPENT / FETCH_FAIL / MAP_CONFLICT / SKIPPED | 0 | — |
写入率 **47.8%**,对照重走老路的 **0.5%**——同一份预算,同一个上游,差别只在问的是谁。
★★ **「titles written: 15,441」不等于新增了 15,441 条标题。** 那个计数器数的是**被 upsert 碰到的行**:`UpsertEpisodeTitleSourced` 的 `DO UPDATE` 分支没有 WHERE,所以一次冲突不管 CASE 最后换没换值都记一行。库里能核对到的变化是:分集标题 218,319 → **219,359**(+1,040 条新行)、有中文名的 90,294 → **92,621**(+2,327)、有任何标题的番剧 12,082 → **12,150**(+68)、完成打戳的 8,092 → **9,390**(+1,296 部)。其余一万多次是在已有行上更新或复述同一个值——我用的两个聚合数(行数、非空中文名数)分不出这两者。⚠️ 同一个口径问题对 3.26.0 那条「新写入 17,646 条」同样成立。
★ `SCOPE_MISMATCH` 20 次仍是守卫在干活:抽 6 个样本分别是条目 13 集 / 上游列表到 49 集、12→26、23→47、12→28、1→3、24→72。
★★ **「从没被问过」的存量到此清零。** 剩下的 4,138 个候选**全部至少被问过一次**:约 2,489 个 `NO_TITLES`(dandanplay 索引里没有这个 subject)、约 1,021 个 `UNDECIDED`、约 575 个 `DDP_REBIND`、约 42 个 `SCOPE_MISMATCH`。所以「明天再跑一轮」这个动作**已经没有对象了**——再跑一轮是花 4,138 次请求把昨天的答案再听一遍。这三类各自是绑定质量问题、不是回填进度问题。
---
## [3.26.0] - 2026-09-04
### ★★ 同一个字段既报「我拒绝」也报「我没有」,而我只认了前一半
上一版把 dandanplay 的 `errorCode` 接了出来,判据是「非零即拒绝」。部署十分钟后线上暴露了它的另一半:
```
HTTP/1.1 200 OK
{"bangumi":null,"errorCode":7,"success":false,"errorMessage":"无法找到指定的资源"}
```
**code 7 是「我没有这条」—— 那是一个答案,不是拒绝。** 而对一个大部分条目 dandanplay 根本没收录的目录来说,它是**常态**。于是 `/api/dandanplay/episodes/{id}` 对这类番返回 500,后台 RELEASING sweep 的日志被 `episode_titles fetch failed` 灌满,十分钟 34 条,每一条都是正常答案。同一窗口内配额错误**零次** —— 上一版想抓的东西一次没触发,触发的全是误伤。
讽刺的是上一版的 commit message 里专门写了「用 `!Success` 会把这个修复变成同一批调用的全面中断」并避开了它,然后一头撞进它的兄弟坑。**两个坑的形状一模一样:把一个承载多种含义的字段当成二值信号。**
改对的判据不是「成功还是失败」,而是**这个码在描述「那个资源」还是在描述「我们能不能问」**:
| 码 | 是什么 | 结果 |
|---|---|---|
| 429 配额 | 拒绝 | `ErrQuotaExceeded` |
| 7 未找到 | 答案 | `(nil, nil)`,静默,和以前一样 |
| 其余 | 未编目 | `(nil, nil)` + 一条带码和原文的 WARN |
★ **未编目改成失败开放,是有意反转上一版。** 一个从没见过的码,是「关于资源的另一个事实」的可能性远大于「一种新的拒绝方式」,而这个客户端在读者前面:猜错方向猜成「拒绝」的代价是线上 500,猜成「答案」的代价只是一行日志。**而日志正是原始缺陷真正缺的东西** —— 它返回 nil 且毫无信号。
★★ **两条测试断言的是日志而不是返回值**,因为两个分支都返回 `(nil, nil)`,行为分不出来。这不是事后聪明:第一版测试在把 code 7 的编目条目删掉之后**照样通过**,是变异测试抓出来的 —— 一条声称在测常量、实际在测默认分支的测试。
★ 用户面零影响。按状态码字段核对,部署后 dandanplay 路由上唯一那条 500 的 UA 是 `curl/7.71.1`,也就是我自己的验收探针。**救回来的不是测试而是「部署后主动逐个端点打一遍」** —— 冒烟脚本只打 `/`、`/api/health` 和一个详情页,这条路径它一个都不碰。
### 一个测试为了够到九行纯函数,把 `next/image` 拖进了共享模块缓存
`next-app (bun test)` 在**一个前端文件都没改**的分支上连挂两次,而 main 在逐字节相同的 next-app 树上是绿的。
`continueWatchingTotal.test.ts` 测的是 `resolveWatchingTotal`,两个数字的九行算术。它 import 了 `ContinueWatching` 组件,而组件拉 `FadeImage`、`FadeImage` 拉 `next/image`,后者的 `deployment-id` 模块**在 import 时**执行:
```js
if (typeof window !== 'undefined') {
deploymentId = document.documentElement.dataset.dplId;
}
```
bun:test 全套共用一个进程和一份模块缓存,而这个套件里有三个文件会在运行时往全局塞 `window`(`pendingSubscribe`、`SubscriptionSetProvider`、`api.buildHeaders`)。于是这个 import 会不会炸,取决于**「别的文件第一次拉进 Next 的那一刻,恰好是谁在跑」** —— 套件里没有任何东西控制这个顺序。这就是它专挑不相干的 diff 发作的原因。
★ 我第一次判成随机 flake 让重跑,重跑照样挂 —— **「连挂两次」本身就是「这不是随机」的证据**,我却又找了一遍理由。修法是把纯函数挪到已经存在的、不含 Next 的 `continueWatchingState.ts`。
验证是前后对照而不是等 CI 变绿:在 `window` 存在、`document` 不存在的进程里,旧的 import 形状 0 pass / 1 fail 并复现同一条 `ReferenceError`,新的 5 pass / 0 fail。
### 分集标题又跑了一轮:4,000 次请求换 17,646 条标题
在 `--limit 4000` 的预算边界上正常收尾 —— **没有中止、零 FETCH_FAIL、零 QUOTA_SPENT**。
新写入 **17,646 条**分集标题(其中中文 **10,636** 条),新覆盖 **1,384 部**番剧(6,708 → 8,092),撤回 0。剩余候选 6,804 → 5,420,等下一个额度日。
| 分类 | 数量 | 占比 |
|---|---|---|
| SKIPPED(预算外) | 2,706 | 39.8% |
| NO_TITLES | 1,520 | 22.3% |
| **WRITTEN** | **1,384** | 20.3% |
| UNDECIDED | 756 | 11.1% |
| DDP_REBIND | 416 | 6.1% |
| SCOPE_MISMATCH | 22 | 0.3% |
| FETCH_FAIL / QUOTA_SPENT / MAP_CONFLICT | 0 | — |
★ **`SCOPE_MISMATCH` 22 次是守卫在工作,不是故障。** 这 22 部的 dandanplay 分集列表长度超过条目自身的两倍,写进去会把整个系列的集号错位盖到一季上。
★ **`FETCH_FAIL` 为 0 是上面那个 code 7 修复的验收。** 用修复前的二进制跑,这 1,520 个 `NO_TITLES` 会有相当一部分变成 `FETCH_FAIL`,报告直接失真 —— 而失真的方向恰好是「看起来上游坏了」,最容易让人停下一轮本来该跑完的批。
★ `UNDECIDED` 756 + `DDP_REBIND` 416 = 1,172 行拿到了上游数据却**故意没写**:前者是没有任何独立来源为这个绑定背书,后者是 dandanplay 说这个 subject 属于另一个 anilist 条目。只报告不动手。
---
## [3.25.0] - 2026-09-04
### ★★ 上游用 HTTP 200 告诉我们它拒绝了,而我们把拒绝读成了「没有数据」
dandanplay 把应用层错误放在 200 的响应体里:
```
HTTP/1.1 200 OK
{"success":false,"errorCode":429,"errorMessage":"已达到接口调用配额上限"}
```
payload 字段直接缺席。而客户端只对 `resp.StatusCode` 分支,全仓从头到尾**没有任何地方解析过 `success` / `errorCode` / `errorMessage`**。于是 200 落进解码分支,`bangumi` 解成 nil,`fetchEpisodes` 返回 `(nil, nil)` —— 这个包里每一个调用方都把它读作「上游没有这个 id 的数据」。
配额耗尽因此和「条目本来就是空的」完全同形,而三个使用方各自从中得出了一个不同的错误结论:
- **播放器**告诉用户「这部番没有弹幕」,而那部番是有弹幕的;`/match` 还会把这个判决缓存 10 分钟
- **分集标题回填**把该行记为「没有标题」—— 那正是它的空结果熔断器用来累积的信号,于是「配额耗尽的一轮」和「跑过一批没标题的番」产生的证据一模一样
- **交叉链接探针**记下「dandanplay 没有为这个条目发布 AniList 链接」,那恰好是唯一会让一行不可绑的结论,然后把它当成测量结果报了出来
第三个就是这次发现它的方式,而且是**能量化的**:2026-09-04 一次探针花掉 103 次详情调用,dandanplay 后台的月度计数只动了 1。每一次被拒绝的调用都被记成了一条发现。
同一时刻分组验证过:`/api/v2/search/anime` 答 `success:true`,而 `/api/v2/bangumi/{id}` 和 `/api/v2/bangumi/bgmtv/{id}` 都答 200 + `errorCode 429` —— 因为**额度是按功能组算的**,只有番剧详情组被跑光。所以这件事既不能从别的调用是否健康推断,也不能从它自己的 HTTP 状态码推断。
修法是给四个信封结构共享一个 `apiStatus`,每次解码后跑 `checkStatus`,返回 `ErrQuotaExceeded` 或 `ErrUpstreamRejected`。★ 判据用的是 `errorCode != 0` 而**不是** `!Success`:有些端点在正常响应里根本不带 `success` 字段,而 Go 里缺席的 bool 就是 false —— 用 `!Success` 会把这个「修静默失败」变成「同一批调用全线中断」。有一条测试专门盯着这个反向风险。
拒绝**不在进程内重试**(日额度要等上游自己的日界,在一次作业的退避窗口里重试只是白花共享预算),也**不进缓存**(缓存一次拒绝会让它活得比拒绝本身还久)。
### 修好客户端反而会让回填变笨,所以两件事必须一起做
配额拒绝以前是 `(nil, nil)` → 记成 `NO_TITLES` → 熔断器累积 → 最终中止。理由错了,结果对了。
客户端修好之后拒绝变成 error → 落进 `FETCH_FAIL` —— 而 `nextEmptyStreak` **对任何非 NO_TITLES 的分类都清零**。熔断器从此再也不会触发,一轮跑批会对剩下每一行都问一遍、被拒一遍,每行 800ms,把同一个事实学上几千次。
所以配额耗尽现在是它自己的分类,第一次出现就中止。它不是 `FETCH_FAIL` 的一种口味,因为它是唯一一个**在讲这一轮而不是讲这一行**的结论:剩下每一行都会得到同样的答案。停下来不损失任何东西——`episode_titles_at` 只在写标题的同一个事务里打戳,没被读到的行不打戳,下次 `--resume` 仍是候选。
判断函数被抽出来是为了能被测试够到,而测试**驱动的是真客户端打真的那个 200 响应体**,不是手搓的 sentinel。sentinel 出门时被包了两层(`checkStatus` 一层、`do()` 一层),一个只对裸 sentinel 生效的匹配器能编译、能通过基于同样假设写的测试,然后把生产上每一次拒绝都判成 `FETCH_FAIL`。
### 探针只测得到目录的头部,而这张表的头部已经被证明不具代表性
交叉链接探针存在的意义是产出一个数——map 沉默的行里 dandanplay 能绑上多少——而这个数会被乘以约 4,400 去做决策。在此之前唯一能控制它成本的是 `--limit`,那会让它在一个按 `anilist_id` 排序的查询里中途停下。于是每一次跑得起的探测都只看了目录的头部。
这个偏差**在同一张表、同一个排序、同一批人口(未绑定行)上是量过的**:id-map 那一轮在头部测到 34.3% 有 map 佐证,而中段各带是 69–78%。差了大约一倍。`anilist_id` 大致按时间排,而被测的东西(dandanplay 的收录、以及它发不发布 AniList 链接)完全有理由随作品年代变化。摘要里其实早就印着一句「把这个外推当成一个区间」的免责声明——这一版把那句话换成了一个真数。
`-probe-sample N` 在整个有序集合上等距取 N 行。用等距而不是随机:这个排序没有周期性可以让步长产生混叠,不用多查一次库,而且可复现——同样的 N 对同样的目录取到同样的行,两次跑批可以对比而不只是平均。
★ 索引用的是比例式 `i*len/want` 而不是固定步长,而**这是测试抓出来的**:4463/300 截断成 14,300 步 × 14 停在 4186,无论样本多大,最后 277 行永远取不到。那是同一个头尾偏差换到另一头,而且样本越小它越大。
### dandanplay 能补的 bgm_id 有多少:6.8%,而天花板是 24.2%
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 次请求。
**决定做 sweep。** 成本落在两个不同的额度组上:约 4,465 次搜索走搜索组(该组常年闲置),约 1,518 次详情走番剧详情组(一天配额的 30%)。一天跑完、约 305 个新绑定、一次性。机会捕获对这批几乎必然为零——四分之三的行 dandanplay 索引里根本没有,用户也不会去播这些冷门条目。
★ 命中位置直方图是 `#1 → 76, #2 → 4, #3 → 0`:候选上限从 3 降到 2 零代价。
★★ **75.8% 的 NO_SEARCH_HIT 和匹配算法无关**,它是这条路的天花板。dandanplay 索引里没有这些条目,再好的匹配器也够不到四分之三——「用 dandanplay 补 bgm_id」的上界是 24.2%,不是 100%。
---
## [3.24.0] - 2026-09-03
### 两个接口对同一个 subject 给出不同答案,而流水线两边各用一个
V1 绑定 bgm_id 走的是**老搜索接口** `/search/subject/{关键词}`,V2 读取 subject 走的是 **v0 接口** `/v0/subjects/{id}`。这两个接口对匿名调用者的可见范围并不一样:老接口照常返回 R18 条目,v0 接口把它们藏起来、答 404。我们两边都不带 Authorization 头(客户端就没设,部署里也没有 Bangumi token),于是每一部 R18 作品都能在 V1 绑定成功,然后在 V2 永远 404。
不是推断,是同一台机器同一分钟里打出来的:老搜索接口按关键词返回 subject 63029,`/v0/subjects/63029` 同时答 404。随机抽 59 个卡住的 bgm_id,59 个全 404;抽 3 个已经完成过 V2 的绑定,3 个全 200。
顺带纠正一条记在 TODOS 里的旧结论。之前抽样 12 个也得到 12/12 的 404,据此判断这批是「`fuzzy_high` 绑到了不存在的 subject id」,理由是 404 响应体写着 resource has been removed。那句话是 Bangumi 的固定文案,原文是 *can't be found in the database **or** has been removed*,它并不声称条目被删了。拿当时被点名的那个 id 去老搜索接口查,`207567 僧侶と交わる色欲の夜に…` 好端端地在结果里——另外两个抽样 id 也是。**绑定是对的,只是我们读不到**。这个区别决定了修法方向完全相反:按旧结论应该解绑,按事实必须保留。
### ★★「永久跳过」跳过的是写入,不是任务
V2 判定 404 为永久失败、`return nil` 不重试,这个判断是对的——匿名请求重试一万次也不会变成登录请求。错的是它**什么都没写**。
行留在 `bangumi_version = 1`,而 1 是系统里没有任何生产者会去捞的值:orphan scan 取 0,heal-CN 取 2,V1 的几个生产者也全取 0。于是这些行同时具备两个性质——**任何 sweep 都够不到,后台却一直把它们算作待办**。后台那个「Re-enrich v1」按钮做的事,正是把已经证明跑不完的 V2 作业再入队一遍:请求花掉了,行一动不动,计数一点不降,按钮永远点不完。
到定位时有 811 行躺在这里,753 行是 OVA,最早的从 2026-06-04 起就没动过。判据不用猜:这 811 行的 `bangumi_score` **全是 NULL**,而这个字段只有 V2 会写,所以那个 NULL 就是「V2 一次都没完成过」的证据。
现在 V2 在这个 404 上先写终态再跳过。同一个按钮因此从「重放失败」变成「收敛」:它入队的每一行,要么完成 V2,要么永久离开 version 1。
### 终态为什么是 version 3 加一个时间戳,而不是一个 flag
只改 `bangumi_version` 就能让行离开流水线——写 3 即可,这个 schema 里 3 早就不是「第三阶段跑过了」而是「这行能走的路走到头了」,`PromoteAnimeToV3` 给的正是 V3 永远碰不到的行。但 v3 也住着一万六千多行健康数据,所以单靠它,行不是离开了流水线而是**离开了视野**,「我们手里有多少个读不到的绑定」当场变成无法回答的问题。而这个问题是有决策挂在后面的——它就是要不要去弄一个 Bangumi token 的全部理由。
不加列的替代写法 `bangumi_version >= 3 AND bgm_id IS NOT NULL AND bangumi_score IS NULL` 今天碰巧只匹配 37 行,但它从构造上就是错的:一个存在、可读、只是没有评分的 subject 会留下一模一样的 NULL,把「读不到」和「没东西可读」混成一个。
`admin_flag` 也装不下,理由 migration 0023 面对同样的选择时已经写过一遍:它的 CHECK 只认 `needs-review` 和 `manually-corrected`,是一个后台已经在读的两值人工流程标记,往里加机器状态等于把两个无关的状态机塞进一列,并且破坏所有现有读取方的穷尽假设。
所以是一个独立的列,而且是时间戳不是枚举——只有一种结果要记,一个成员的枚举是把事实伪装成词表。NULL 表示「从没被观察为不可读」,这是每一行从没被问过时的诚实默认值;有值表示「在那一刻,`/v0/subjects/{bgm_id}` 说找不到」,这是这次观察能支撑的最强说法。上游的答案可能变,时间戳顺便说明我们这份副本有多旧,也让将来的复查天然有游标。
`UpdateBangumiV2` 在每次成功读到 subject 时清掉这个戳——**没有回到 NULL 的路径的标记,早晚会变成谎话**。
### GREATEST 不是保险,是刚发生过的事
写终态那条语句用 `GREATEST(bangumi_version, 3)` 而不是直接赋 3,因为它有两个入口:V1 链过来的 version-1 行(3 是升级),和 id-map sweep 链过来的老 version-3 行(本来就是终态,不能动)。
直接赋值今天也能跑对,但同一个文件里的邻居 `UpdateBangumiV2` 就是直接赋 2 的——而这一版之前,id-map sweep 绑定的行链式跑完 V2,正是被那句话**从 3 拽回了 2**,一次 750 行。棘轮不是防御性编程,是照着刚踩过的坑写的。
同一条语句还钉住了绑定(`AND bgm_id = 我们探测的那个 id`),和 `UpdateDescriptionCn`、`UpsertEpisodeTitleSourced` 一个道理:这次 404 是关于**一个** subject 的判决,若在取数据和写入之间有人改了绑定,这个判决就会落到一个根本没被探测过的 subject 头上。绑定移动了则影响 0 行,worker 把这个 0 记进日志而不是当成写成功。
`bgm_id` 一律保留。绑定没错,只是匿名读不到;清掉它等于丢掉一份有 token 就立刻有价值的工作,还会把这个 subject 当成无主的重新交回给 id-map sweep。
### migration 故意不回填
那 811 行完全可以在 migration 里一条 UPDATE 全标掉,而这样做是错的:**判断一个 subject 读不读得到,唯一诚实的办法是去问,而 migration 问不了**。59 个样本给出的上限是大约 5%——小,但不是零。从样本推广到全体,会把本该能富化的行标成不可读。
所以它们走修好之后的 worker 排干,每行一次真实请求。这正是「Re-enrich v1」按钮一直在做的事,只是从这一版起它终于是解药而不是重演。
### 后台多了一行不能点的数字
`subjectUnreadable` 计数挂在 v3 那个图例点上,和已有的「其中 N 无中文」并排。
这条 bar 的前提是「上面每个数字都能被复现、也都能被处理」,而这个数字故意破坏了后半句——除了换一个带认证的客户端,没有任何操作能改变它。渲染它仍然是两害相权:这些行终态停在 v3,没有自己的一行就和完整富化过的行长得一模一样,而「我们有多少个读不到的绑定」会在唯一展示所有富化数字的地方消失。
有一条测试专门断言这个数字**不出现在任何 `<button>` 里**。给它配个按钮,就是把刚修掉的缺陷原样造一遍:一个把上游已经拒绝过的作业重新入队的控件。
---
## [3.23.0] - 2026-09-03
### 番剧详情页开始有每集的标题
此前分集标题只有 Bangumi 一个来源,覆盖到一半就停了。这一版把 dandanplay 接成第二个来源,并把两个来源的冲突规则写死在数据库里:每个字段单独记来源,优先级 `manual > ddp > bangumi`,低优先级的写入不会覆盖高优先级的值。
覆盖率 53.8% → 67.6%,中文标题 28.6% → 31.3%。有分集标题的番剧从 11,205 部到 11,315 部。
### ★★ 一个空列可以「占着」一个字段,让它永远填不上
来源列是这一版加的,而旧的写入语句不写来源。两者同时在线的那四个小时里,`episodes_bgm` 那个 sweep 跑了 60 次,留下 2,010 行**值是空的、来源却写着 `ddp`** 的记录。
这不是无害的脏数据。写入语句是拿**来源列**比优先级的,所以一个空列挂着 `ddp` 标签,读起来就是「这个字段已经被 ddp 占了」,于是每一个优先级不高于 ddp 的自动写入者都会被它挡回去——1,966 个集位因此变成永久填不上,而页面上它们只是空白。
修法分两半:写入路径统一收口到带来源的那条语句(旧的直接删掉,留着就还会有人用),存量用 migration 0030 双向修复——有标签没有值的,撤销标签;有值没有标签的,标成 `bangumi`(能产生这个形状的只有三个写入者,全都读 Bangumi,所以这是事实不是猜测)。
生产上执行后孤儿行 2,010 → 0。
### `btrim()` 不去全角空格
集成测试写到一半发现的:`btrim(text)` 不带第二个参数时**只去 ASCII 空格**。制表符、换行、以及 **U+3000 全角空格**全部原样通过 `NULLIF`,落成一行渲染出来是空白、却又按上面那条规则占住了字段的记录。
现在显式给了字符集 `E' \t\n\r '`。
### ★★ 熔断器一次都没能触发
上一版给回填加了熔断:连续 200 行拿不到任何标题就中止,避免上游停摆后继续空烧请求。它不工作。
实测:dandanplay 在约 4,700 次请求处停止应答,跑批又空走了 **350 行**没有中止。
原因是连空计数只看行的分类。**8,747 个候选里有 718 行(8.2%)与另一行共用同一个 bgm subject**——因为目录里有 541 个 subject 被多行持有。客户端对分集响应有 24 小时进程内缓存,第二个共用者直接从内存拿到真数据,判成「拿到了但拒绝采用」,旧规则把这当成上游还活着,于是**平均每 12 行就把计数清零一次**。
可那个缓存响应根本没碰过上游。
新规则一句话:**只有真正走到网络的行才能移动这个计数**,两个方向都是。没问过上游的行保持中立——它既不指控也不背书。同一个区分顺带修掉另一个错:缓存命中此前被算进了「本次请求数」,虚高了运行自报的开销。
为什么原来的测试没抓到:判定式 `shouldAbortOnEmptyStreak` 从上线第一天就有单测而且一直是对的;出错的是**喂给它的那个数**,它当时躺在循环里,一个测试都没有。
### ★★ 回填把线上弹幕功能打瘫了一天
dandanplay 的额度是**按功能组、按天**算的。而 `/api/v2/bangumi/*` 归在「番剧详情」组——那也正是 `/match` 取分集列表用的组,两个 Phase 都是。
所以一次不设上限的回填不是自己跑到停就完了:**它会把线上弹幕功能一起带下线到当天结束**。
这是实测的,不是推演。跑批把该组跑到 5,000/5,000 之后,线上对 `bgmId=291171` 返回「Anime not found on dandanplay」——而那个 subject 正是同一轮跑批两小时前刚成功读过分集标题的。
`--limit` 的默认值从 0(无上限)改成 4,500,留一成给线上。月额度是日额度的 24 倍且远未用尽,所以唯一稀缺的资源是「天」,、长回填的正确形态是跨天分批。
### 一部四集的 ONA 挂着 3,528 个分集标题
`CountMain` 从写下来那天就带着这个信号,注释里说阈值应该等分布出来再定,、别拍脑袋。分布出来了。
按**比值**(写入的最高集号 ÷ 目录集数)分档:
```
比值 番剧 越界行 绑定被 id-map 确认 平均目录集数
<=1.10 122 183 89% 29.1
1.10-1.50 95 423 75% 17.1
1.50-2.00 198 1,829 63% 10.9
>2.00 475 17,880 41% 4.2
```
按绝对溢出量分不开的两拨东西,比值一下就分开了。≤1.10 一档 89% 由独立映射确认、平均 29 集,、那是目录少算一两集——**标题是对的**,按绝对量切会把它们一起误伤。>2.00 一档平均目录集数 4.2,占全部损坏的 88%。
最后一列排除了显而易见的修法:那 475 部里有 **195 部的绑定被 id-map 确认**。所以这不是绑错,解绑是错的——Bangumi 合法地用一个 subject 覆盖 AniList 拆成正片和短片的内容,绑定是对的,、只是集列表比它指向的那一行宽。
守卫因此**整条拒绝而不是截断**:属于整个系列的列表,它的第 1 集是系列的第 1 集,头和尾一样不可信。目录集数未知的 2.9% 放行——没有分母就没有判据。
### 那些 Bangumi id 早就在库里,只是没人去用
826 部番剧没有绑定 bgm 条目,所以拿不到中文名、评分、简介、分集标题。而答案早就躺在 `bgm_id_map` 里——V1 富化流程第一步就查这张表而且无条件信任它,但那条语句带着 `bangumi_version = 0`,只处理「从没富化过」的行。这些行停在 version 3,是更早年代批量设的状态。
答案有了,也没有任何代码路径会回头去用。跟分集标题那次是同一个棘轮:每部番只走一次流水线,之后数据源变好也够不着了。
新的 sweep 就是这个缺失的入口。信任依据是免费量出来的:已绑定且这张表也有记录的行里,**3,769 行是更早的流水线独立绑的,这张表 100% 一致,零分歧**。
守卫有三道,而且都是必需的——`anime_cache.bgm_id` **没有唯一索引**(已经有 541 个 subject 被多行持有,加不上),所以 SQL 是唯一防线:
- 同批次内两行争一个 subject 的,两个都拒(数据修改语句看不见自己的写入,没有这条它们会双双通过检查然后双双绑上)
- subject 已被别的行占用的,拒
- 写入时再核对一次 `bgm_id IS NULL`
一次绑完 791 行,剩的 35 条正是守卫该拒的 33 + 2。全程重复 subject 数稳在 541,一个没多。
### 791 条绑定逐条核对:1 条错绑,而且只有年份和集数抓得到
绑完之后逐条对着 Bangumi 上的条目核了一遍——日文原名、开播年份、集数,三项都来自 AniList 那条独立的数据链,所以是真的第二意见而不是自证。
874/940 通过,零抓取失败。查出 1 条错绑:
```
我们(AniList 111137) 绑到的 subject(bgm 320824)
标题 WAVE!!~サーフィンやっぺ!!~ WAVE!!~サーフィンやっぺ!!~
格式 MOVIE TV
年份 2020 2021
集数 3 12
```
标题**逐字相同**,相似度 1.00。**光靠标题相似度永远查不出这条**——是 2020 年的剧场版三部曲被绑到 2021 年的 12 集 TV 版上,TV 版的中文名、评分、简介都挂在了剧场版页面上。抓到它的是年份和集数这两个佐证信号。
### ★★ 没有任何办法告诉 sweep「这条别碰」
解绑那条错绑的时候才发现:清掉 `bgm_id` 正是让这一行成为候选的唯一条件,所以**这次纠正等于把它直接递给了下一轮 sweep**。
而且绕不过去——后台的编辑接口写不了 null 的 bgm_id(`null` 会被当成「没有字段要更新」),删 `bgm_id_map` 那行也没用,那张表每次启动都会被清空后从内置文件重灌。
修法是让候选集排除 `admin_flag = 'manually-corrected'`。这两个 flag 值在这里方向相反,、而这正是它们分成两个值的意义:**`needs-review` 是一个问题,map 有资格回答;`manually-corrected` 是已经给出的答案。**
顺带纠正了一处注释:写入语句里保留 `manually-corrected` 的那个分支说保护在那儿——不在,而且新位置更强。保留标记却把行重新绑回去,等于保留了一次刚被自己撤销的纠正的审计记录。
### 首页:每日更新提到了正在热议下面
原顺序是 正在热议 → 大家都在追 → 我的在追 → 本周更新,现在本周更新排第二。放送日历是这一页上唯一每天都在变的板块,它上面的两个列表都是常青内容。
---
## [3.22.0] - 2026-08-29
### 番剧详情页:每部番有了自己的颜色 —— 但只借色相角,不借颜色
`anime_cache.poster_accent` 是从封面图取样出来的原色。此前它被直接交给 `color:` 用(`.hero-relation-chip:hover`),也就是说 hover 后那行字好不好认,**取决于这部番的封面碰巧是什么颜色**。
实测 6 个真实封面色对纯黑的对比度:
```
#7caf62 绿 8.17:1 ✓
#d4a017 金 8.84:1 ✓
#e45d35 橙 5.92:1 ✓
#8b5cf6 紫 4.96:1 ✓
#2e6fbf 深蓝 4.14:1 ✗ 低于 WCAG 4.5:1
#c2185b 玫红 3.58:1 ✗ 低于 WCAG 4.5:1
```
跨度 2.5 倍,六个里两个不合格。根因不是取样算法 —— 生产库 16,547 部有色相的番里,**72.1% 偏暖**(黄 32.1% + 橙 27.5% + 红 12.5%)。暖色在黑底上亮,冷色暗,而"直接拿画面颜色当 UI 颜色"这件事本身**没有下界**。
所以新的做法是:只从封面拿 **OKLCH 色相角**,明度和彩度由设计系统固定。每部番仍然是自己的颜色,但落在一条保证可读的带子里。同样 6 部重新推导后是 7.45–7.81:1,跨度 1.05 倍。
### ★★ 写在 `:root` 里的派生色是死的 —— 六部番全渲染成同一个紫
第一版是这样写的:
```css
:root { --poster-hue: 292.7; --poster-tone: oklch(76% .085 var(--poster-hue)); }
```
看起来完全正确。它不工作。
**自定义属性的 `var()` 替换发生在声明它的元素上,不是使用它的元素上。** `--poster-tone` 在 `:root` 上就已经用 `:root` 的色相算完了,继承下去的是一个算死的紫色 —— 子元素再怎么改 `--poster-hue` 都没用。
这个失效没有任何征兆:样式表是对的,markup 是对的,CSS 变量确实在 HTML 里,代码审查看不出来。是在真实浏览器里逐个量像素时才发现六部不同颜色的番吐出同一个 `#b2a7e3`。
修法是 `globals.css` 的 `.poster-scope`:把三个 tone **重新声明在写 `--poster-hue` 的同一个元素上**。`HeroAccent` 同时输出 class 和行内 hue,两者是一个机制,拆开任何一半都退回上面那个静默失效 —— 所以这条写进了 SSR 测试。
推论已写进 DESIGN.md:**永远不要在比 `--poster-hue` 更靠上的选择器上组装 `--poster-tone*`。**
### 76% 这个明度是穷举证明的
不是挑的,也不是从几个样本归纳的。全部 360 个色相跑一遍,`oklch(76% 0.085 h)` 在最严的 `--bg-elevated` 上最低 6.22:1,零例外;能过 4.5:1 的最小明度是 68%,所以留了 8 个百分点余量。
这条证明在 `lib/oklch.test.ts` 里是**可执行的测试** —— 改 L 或 C,它会直接点名哪些色相破了。同一个文件还反向锁住 `--poster-tone-low`:断言它必须**低于** 3:1,因为一旦有人把它调亮到能当文字用,它就会被当文字用,而它不是按那个标准管的。
### 这套数学此前没有任何测试
`sampleCoverAccent.ts` 的头注释解释过为什么把 OKLab 矩阵内联在里面:「legacy 那边的 parity tests 已经钉住了这套数学」。
那个 `client/` 目录早就不在仓库里了。所以那些测试从它离开那天起就没再跑过,而这套数学在 next-app 侧的唯一入口需要 canvas,本身无法单测。
抽成 `lib/oklch.ts` 之后补了 21 个测试(含 OKLab 往返、色相归一化、灰度拒绝),并做了变异验证:降明度、抬灰度阈值、破坏一个矩阵系数、去掉 0–360 归一化、解析失败返回 0° 而不是 null —— 5 个变异全部被抓到。`sampleCoverAccent.ts` 同时从 350 行减到 214 行。
### 顺带修掉两处「不可点击的东西涂着操作蓝」
DESIGN.md 写了两遍:`--accent` 只给主操作,「可点击 = `--accent`,这条没有例外」。而格式徽章("TV")和四个区块标题都是 **`<span>` / `<h2>`**,点不了,却都涂着 iOS Blue。它们现在走 poster 色阶 —— 徽章不再假装自己是按钮,hero 也在一个克制的位置上有了这部番的身份色。
### 区块顺序重排,并补上「作品信息」表
顺序原本是「简介 → 关联作品 → 角色 → 制作 → 集数 → 推荐」,现在是「简介 → 作品信息 → 集数 → 角色 → 制作 → 关联 → 推荐」。
**集数从第 6 位提到第 3 位**:那是回访者唯一来找的东西。**关联作品从第 2 位降到第 6 位**:它是导航离开本页的入口,放在第二位等于在人看到任何内容之前就把人送走。
新增的「作品信息」是一张 `<dl>` 表(季度/开播/状态/集数/单集时长/类型/原作/制作)。它的存在让 hero 那句 facts 从 11 项砍到 6 项 —— 类型、季度、制作这些不适合塞进一句话:没有标签,读者得自己推断「TV」是类型、「MADHOUSE」是制作;有了标签,那句话就不再是一句话。
没有值的行照样渲染,画一个破折号。字段缺失本身就是信息,而一张每部番形状都不一样的表,比一张有洞的表更难跨页扫读。
### 集数列表:方格网格 → 行式列表
原来是 `minmax(88px, 1fr)` 的数字方格。**88px 只装得下大约四个汉字**,所以每一集的标题都渲染成省略号 —— 区分第 14 集和第 15 集的唯一信息,一直是看不见的。而集标题正是回访者来这个区块的原因。
现在每行是「圆圈 · 补零集号 · 标题 · 日文原题 · 评论数」,四列自适应,1440px 下四列、手机上一列,不需要断点。
- 勾选标记从 ✓ 字形换成固定 16px 的圆圈。旧的 ✓ 比给它预留的盒子高 1px,所以格子翻成已看的那一刻整个网格会跳
- 「最远看到」从底部横条换成行首的左脊
- 讨论入口从钉在右上角的 24×20 贴纸变成行尾的按钮 —— 贴纸读起来像徽章,不像控件
- 区块标题原本是 13px 大写字母加 2px 字距,而页面上其他标题都是 17px 常规大小写,导致集数区块看起来和它上下的区块不是同一类东西。现已统一
### ★ 字体:三个网页字体加起来,一个中日韩字形都没有
Sora、DM Sans、JetBrains Mono 全都以 `subsets: ["latin"]` 加载。所以站上**每一个中文字**都是由回落链渲染的,而那一环之前是 `system-ui` —— macOS 上是 PingFang,Windows 上是 Segoe UI(中日韩覆盖是个按安装情况而定的问题),Linux 上什么都可能。
新增 `--font-cjk` 显式列出 PingFang SC / Hiragino Sans GB / Source Han Sans SC / Noto Sans CJK SC / Microsoft YaHei。在 Mac 上这不是视觉改动(system-ui 本来就解析到这儿);它是在 system-ui 解析不到的平台上,阻止页面回落到衬线体或一个字形度量对不上的字体。
拉丁字体仍排在最前:它们没有中日韩字形可以抢,浏览器会逐字回落 —— 这就是为什么一条声明能同时服务 “MADHOUSE” 和「葬送的芙莉莲」。
另加 `--font-jp`(Hiragino Sans / Yu Gothic / Noto Sans JP),用于日文原名和集数原题。汉字在日文和简体中文里字形不同(直 骨 令 等),日文标题用中文字体排出来能读,但对认得出区别的人是明显别扭的。
### ★ 又一次双词典漂移
把集数提示里的「点一下方格」改成「点一下某一集」(方格已经不存在了),只改了 `zh.ts`,页面纹丝不动。EpisodesGrid 是客户端组件,走 `t()` 读的是 `zh-spa.js`。
`spaDictCoverage` 是绿的 —— 它检查 key 存不存在,不检查值是否一致。两边的 key 都在,只是值不同。
### Hero 重做成整幅背景图 —— 这是换设计,不是调样式
原来的 hero 是「固定高度的横幅条 + 下方内容 + 封面跨在接缝上」。现在是「整幅背景图 + 内容压在图上、底部对齐」。
改动前后(1440px):
```
改前 改后 demo
banner 400px 556px 556px
操作行 y799 y535 y510 ← 从简介之后提到 facts 正下方
简介 y586 y657 (hero 外独立区块)
```
四个互相咬合的几何值变成两个:横幅高度和负 pull 存在的意义就是制造「内容压在图上」的重叠,而 `align-items: flex-end` 白送这个效果。
- 遮罩从「40% 起、到底 0.95 黑」改成「66% 起、到底纯页面色」。原来那条把最暗的一段压在画面中部 —— 也就是角色的脸上 —— 去保护根本不在那儿的文字
- `object-position: center 38%`:这个盒子比 1900×400 高得多,裁剪主要发生在竖直方向,而动画主视觉的脸都在上三分之一
- 简介移出 hero,成为自己的区块,右侧带 AniList / Bangumi 评分卡(带分带色和进度条)。正文是全页最长的一段文字,压在图片上既难读,又逼着遮罩必须暗到能盖住画面
### ★ 改了三条 e2e 断言,逐条说明理由
放宽守卫是守卫烂掉的标准路径,所以三条都写清了「它原本保护什么,现在还保护不保护」。
1. **`banner height === 400` → `=== 556`**。它保护的是「手机端的改动不许波及桌面」。这次是**有意**改桌面,数值随之更新。顺带改成四舍五入再比较:38.6vw 落在 555.828125,`toBe()` 一个浮点会把断言钉死在当前视口的亚像素上。
2. **「封面必须跨在横幅下沿」→「封面必须完全在图内」**。这条**方向反了**,因为它保护的东西不存在了:原注释说那个重叠是「hero 的整个视觉理念」,而那是针对「横幅条 + 负 margin 拉上来」那套设计说的。现在图是整个 hero 的背景,没有接缝可跨;封面越过下沿反而意味着内容溢出了 hero —— 那才是这里值得抓的缺陷。
3. **`synopsis y < 422` → `y < 844 - 300`**。把界限从坐标改成**「有多少正文可读」**,因为那才是要守的属性,而满足它的坐标取决于布局。简介移出 hero 花掉约 90px:最差的一部(20 字标题配七个分类)从 416 退到 490,常见的在 407。300px 是九到十行中文。原来那个缺陷(y=799,只剩 45px 可见)依然被这条挡下。
移动端 hero 下限设 340px 而不是「尽可能小」:内容底部对齐,下限等于内容高度就意味着一点画面都露不出来,标题会直接贴在导航栏下沿 —— 实测过 280px,就是那个样子。
### 补两个词典 key
`detail.scores` / `detail.votes`(评分面板的 aria-label 和「人评分」)。三个语言的 `.ts` 都加了;这两个只在服务端组件里用,不走 `t()`,所以不涉及 `-spa.js`。
### Hero:七颗药丸 + 一整行分类药丸,收成一句话
hero 此前把三条并列的信息带堆在标题下面:一行七颗徽章药丸(分数、BGM 分数、格式、状态、集数、季度、Bangumi 链接)、一行制作信息、再一行分类药丸。它们现在是一句用 `·` 分隔的话。
药丸是错的容器。这些每一项都是**读一次的事实** —— 一个分数、一个格式、一个集数 —— 没有一个是控件;而一个填充的圆角矩形正是本站表示「这个能按」的语汇。十四颗堆三层还吃掉简介上方约 90px 的竖直空间。
标题同时从 `clamp(22px, 4vw, 36px)` 提到 `clamp(28px, 4.6vw, 58px)`。这是 hero 里唯一的尺度对比,而它之所以立得住,正是因为下面那行不再和它抢注意力。
实测 @390px,简介的起始位置:
```
改前 改后
葬送的芙莉莲 491 → 333
Re:Zero 第四季(最差) ~625 → 416
排球少年(字段少) 374 → 315
```
随机 16 部样本:最小 318、中位 337、最大 367,**没有一部越过半屏线**。
### 分数保留了分带色,但不再有药丸
`scoreTextStyle()` —— 只出前景色。e2e 断言相应改成「背景要么透明,要么必须同带」。
这条断言原本钉的是**一致性**,不是药丸的存在:线上那个缺陷是 87 分渲染成 `rgb(48,209,88)` 绿字配 `rgba(255,159,10,0.12)` 琥珀底,前景和背景各说各的。没有背景时,能和前景不一致的颜色数量是零。
放宽断言正是守卫烂掉的常见方式,所以做了变异验证:把 `scoreTextStyle` 改成永远返回高分带(30 分也涂绿)→ 红;把不同带的背景加回去(重现线上原 bug)→ 红。两个变异都先确认写进了文件才跑。
### ★ 两个只有实测才会发现的静默失效
**媒体查询整段无效。** 移动端的字号压缩写在了 `.title` / `.facts` 的定义**之前**,同特异性下后面的规则赢,所以整个 `@media (max-width: 600px)` 块被覆盖。它的症状是**没有症状** —— 加之前和加之后,手机上量出来的数字一字不差。移到文件末尾才生效。
**换行后每行开头挂着一个孤立的分隔点。** 分隔符用 `::before` 画在后一项上,而伪元素是独立的 flex item,桌面单行时完全正确,手机上换到四行就每行行首多一个 `·`。改成画在前一项的 `::after` 上,它就待在前一项的盒子里,跟它一起换行。
### 顺带
- 删掉 17 个不再有引用的类,`page.module.css` 428 → 308 行
- `.factsLink` 的焦点环用 DESIGN.md 规定的那一个
### 详情页下半部分:从 42 处内联样式搬进 CSS
hero 以下四个区块(关系、角色、制作人员、推荐)此前是 42 个内联 style 对象、约 200 条声明。内联样式**写不了 hover、写不了焦点环、写不了媒体查询**,而且它赢过所有样式表规则 —— 所以那四个区块此前既没有交互态,也没有任何断点,而且任何人针对它们写的 CSS 都是静默失效的。
- **角色 & 声优**:头像 58×76 → 64×86,列宽下限 400px;两半镜像排列,两张脸在外侧、名字在中间相对 —— 两个左对齐的相同块读起来像四列表格,镜像后才读得出「配对」
- **制作人员**:头像 36 → 44px,并且真的显示出来了
- **关系**:从带边框的横条卡改成海报网格
- **推荐**:保留横向滚动条(那是代码注释里明确选择过的),但滚动条不再被 `scrollbar-width: none` 藏起来 —— 一个不声明自己能滚的滚动容器
- 全部去框:只留发丝线和 hover 抬升,不再是一屏的矩形
顺带修了一个真 bug:推荐卡的分数原本硬编码 `#30d158`,**55 分和 90 分涂着同样的"好"绿**。现在走 `scoreScrimStyle()`,实测 ★8.5/★8.1 绿、★7.4 橙。
还修了一个变量遮蔽:`StaffSectionView` 的 map 参数叫 `s`,把同名的 CSS module 导入整个遮蔽掉了,所以那段代码里没有任何 class 是够得着的。
### 集数网格
- 「已看」从一层 12% 绿底变成三个叠加信号:渐变面、绿描边、绿数字
- 讨论入口从钉在右上角的 24×20 贴纸变成整条底栏,并把评论数当作真正的信息显示(4.4 倍点击面积,但仍未达 DESIGN.md 的 44px 下限 —— 达标要么每格再高 20px,要么上分页,这里明说而不是半修)
- 「最远看到」从底部 2px 横条改成 3px 左脊(`inset box-shadow`,跟着圆角走,少一个 DOM 节点)
- 移动端 ≤600px 每行 4 格而非 3 格
**修了一个焦点指示器被裁掉的缺陷**:toggle 上的 `outline` + `outline-offset` 配了一个 `overflow: hidden` 的外壳 —— overflow 会裁剪后代的 outline,所以键盘用户几乎看不到焦点在哪。而且原来的焦点样式藏在组件注入的 `<style>` 字符串里,`focusRing.test.ts` **扫不到它**。现在两条规则都在 module 文件里,走 DESIGN.md 规定的那一个环。
**还修了一处布局跳动**:勾号的 `minHeight: 14` 比字形行盒少 1px,所以登录探测落地、格子翻成「已看」时整个网格会跳。实测 71/72 → 78/78。
### 有一处颜色被实测推翻了
集数格最初也用 poster 色阶染「展开」态。在真实色相下渲染后发现:色相 ~135° 时展开的格子**看起来像"已看"**,~25° 时**看起来像"报错"**。这个网格里每一格都在用颜色编码状态,而一个可以落在任何角度的色相没法同时当状态用。展开态退回 `var(--accent)`,`--poster-tone` 在这个组件里只剩一处 —— 区块标题,那是标识不是状态。
### 验证
- 7 部真实番剧端到端实测,色相 18.1° / 53.4° / 84.2° / 135.8° / 204.0° / 236.0° / 350.2°,每一部的 `--poster-hue` 都精确等于其封面实际色相,徽章对比度 7.45–7.81:1,7/7 达标
- 生产构建产物里 `oklch(76% 0.085 var(--poster-hue))` 原样保留,`.poster-scope` 未被哈希
- ISR 闸门 PASS(`dynamicParams` / prerender-manifest 均未变),hero 几何一寸未动(e2e 钉死桌面 banner 400px、cover 210×300)
- 单测 1828 通过 0 失败
---
## [3.21.0] - 2026-08-29
### 新增 — 热议栏里那张推广卡,从来没有人数过
首页「正在热议」把三种东西铺进同一个网格:本地库引导条、一张置顶的 `/welcome` 推广卡,以及 N 张讨论卡。**只有讨论卡带 `onNavigate`。** 于是这个栏目能回答「有多少人点开了讨论」,却回答不了「有多少人点开了那张坐在同一个网格里、长着同一个卡片形状、就在讨论卡上面一格的推广卡」。
后一个数字对「这个位置还适合放什么」这件事更有用,而它一直没被记录。
### ★★ 补一行埋点是错的 —— 因为现成的分母不是这张卡的分母
顺手的做法是:给卡片加上 `onNavigate`,除以我们已经在记的曝光数。**那样算出来的率是高的。**
`hot_discussions_impression` 由 `trackHotDiscussionsImpressionOnce` 发出,而它在 `itemCount < 1` 时提前返回。`/welcome` 卡在那个条件**之外** —— 有没有讨论可显示,它都渲染。所以每一次空列表渲染,卡片都在屏上、都能点,而曝光**一条都不记**。拿这个数当分母,等于把恰好这些渲染排除在外,把点击率按「列表多久空一次」的比例抬高。
而这个率,正是这次埋点存在的全部理由。
所以新增的是成对的两个事件:`welcome_card_impression` 在挂载时发出、不带任何数量门,与 `welcome_card_open` 自成一对。
顺带得到一个以前没有任何东西记录的数:**两个曝光数之差,就是热议列表多久空一次。**
### ★★ 迁移动两条 CHECK,不是一条
0020 把 `community_engagement_target_chk` 写成了一个**封闭的两分支 OR**,每一支都把自己的 `event_type` 写死在里面:
```sql
(event_type = 'hot_discussions_impression' AND anilist_id = 0 AND episode = 0)
OR
(event_type = 'discussion_open' AND anilist_id > 0 AND episode > 0)
```
一个只加进 `community_engagement_event_type_chk` 的新事件,会通过那条检查,然后**被这一条拒掉** —— 它哪一支都不匹配。故障形态是:请求过了 Go 白名单,在插入时炸成约束违反,也就是一个 500,而且是在客户端已经部署、已经在发事件之后才出现。
0028 把分支改成**按 target 形状分组**而不是按事件分组:一支给不指向任何番剧的事件,一支给唯一指向番剧的那个。**它仍然是封闭的** —— 打错的 `event_type` 依旧哪一支都不匹配、依旧被拒 —— 但以后再加一个无目标事件,是 IN 列表里的一行,不是第三条 OR 分支。
### ★ 测试夹具里那两个率,故意设成不同的数
`0.25` 和 `0.5`。如果 handler 把分母交叉了(拿 welcome 的点击去除以热议的曝光),算出来是 `0.5` —— 一个看起来完全合理的数字。两个率若相等,这个变异就是活的。
### 验证 — 四个变异,每一个都先证明打进去了才看红
| 变异 | 结果 |
|---|---|
| 给 welcome 曝光装回 `itemCount` 门 | 前端 5 条红 |
| 把 welcome 的去重键并回热议的键 | 去重独立性那条红 |
| 迁移里只放宽 `event_type_chk` | 500,`violates check constraint "community_engagement_target_chk"` |
| handler 里交叉分母 | 拿到 0.5,期望 0.25 |
第三个变异复现出的,正是上一节描述的那个故障 —— 不是推理出来的,是打在真 Postgres 上跑出来的。
### ★ 回滚里那句 DELETE 是承重的
`ADD CONSTRAINT ... CHECK` 会校验存量行。回滚时若还留着新事件类型的行,收窄回去的 `event_type_chk` 会被它们违反,**整个回滚中止** —— 落在既不是 0027 也不是 0028 的形状上。所以行先删。
拿真 Postgres 实测过:带着新事件数据回滚会正确清掉它们、保留旧数据、把两条约束还原成 0020 的原文;把那句 DELETE 去掉,则如预期以 `is violated by some row` 中止。
这是**丢数据**,不是清理:回滚会扔掉 0028 生效期间收集的全部推广卡计数,而且没有 per-user 行可以重建。文件里写明了这一点。
### 后台
多一张卡:推广卡点击率,提示行给出它自己的分子分母。**不要跨这两对取比值** —— 代码注释、SQL 注释、Go 结构体注释和后台组件注释里各写了一遍,因为交叉之后算出来的是个合理数字,不是个报错。
---
## [3.20.2] - 2026-08-29
### 修复 — 沙箱把故障打印出来,然后原样放行
沙箱 e2e 套件间歇性变红有几个月了,一直记在案上叫「查不出原因的 flake」。**原因不是查不出来 —— 预热步骤在每一次失败的运行里都把证据打出来了。**
在 dev server 下,**每一条带有嵌套在 `[lang]` 底下的动态段的路由,会整轮都解析不出来** —— `[lang]/anime/[id]`、`[lang]/reset-password/[token]`、`[lang]/u/[username]`、`[lang]/seasonal/[season]/[year]`。`[lang]` 是唯一动态段的路由照常服务,`[lang]` 之外的动态路由(`/sitemaps/anime/sitemap/[id].xml`)也照常。照失败那次的 next-dev.log 数:三段路径 **0 成功 / 19 次**。
两个 diff 完全不重叠的 PR(#127、#128)第一次尝试**挂在同样的九条 spec、同样的行号上**,而且都在没改任何代码的重跑里变绿。就是这个配对排除掉了两边的 diff。
### ★★ 同一个 404,一个是产品的答案,一个是路由器的答案
两个签名把故障态和健康态分开:
- 每一个 200 都带一个 `generate-params` 相位;每一个 404 都没有。
- 同一条 `/anime/0`,两种耗时:
```
健康:404,942ms(next.js 870ms)—— 段编译了,页面在 anilistId <= 0 上主动 404
故障:404, 42ms(next.js 5ms)—— 从没编译过,404 来自路由器
```
**状态码相同,来源不同。只读状态码分不出这两者 —— 这正是它活了这么久的原因。**
### ★★ 一整套否定断言,对全面停摆是结构性失明的
预热步骤用一条规则判十个裸路径:**只要不是 5xx 就算编译过了**。`warmed /reset-password/warmup -> 404` 这一行**印在日志里、在两次失败的运行里都印了** —— 然后被跳过,因为 404 不是 5xx。那个页面不调任何 API、也没有 `notFound()` 分支,健康的栈里根本不可能 404。翻 14 次历史运行,这一行出现过 3 次,**这 3 次尝试全部失败**。
spec 也抓不到。`not-found-status.spec.ts` 断言的是「不存在的 id 要答 404」,所以一次**每条动态路由都 404** 的运行完美满足它 —— 它的十条断言**因为错误的理由保持了绿色**。整个目录都在 404 的时候,这个文件里唯一变红的那条,是配对的那个**肯定**用例。
### 变更 — 每条预热路由自己声明该答什么
不匹配就在日志还读得动的地方把 job 弄挂。有两件旧规则表达不了的事因此成立:
- `/library` 和 `/admin` 断言 307 —— 此前没有任何东西检查它们,跳转要是退化成 200,预热会说「编译过了」然后走人。
- `/anime/0` 断言 404,但**被明确记成「它本身不具诊断性」**,因为它在健康态下也答 404。
这一类路由的**肯定探针**放进 `globalSetup`,紧接在 `ensureAnimeDetail` 种下 id 21 之后。这个位置两个方向都承重:更早是真的没有那行,而 `loadDetail` 是带 `revalidate: 60` 取的,所以行存在之后 Next 还会把那个 404 端出来整整一分钟。
它的失败信息比检查本身还长,是故意的:`globalSetup` 抛异常发生在浏览器登录之前,`.auth/user.json` 于是从没写出来,后面每一条 spec 都报「storage state 文件不存在」。**那条更响、重复、而且是错的报错,才是读者会去追的那条**,所以这条信息先排除种子、再排除鉴权、点名真正的条件,最后直说:别跟着日志里的下一条错误走。
**重试和断言是一件事,不是两件。** 只在 5xx 上重试、断言另做,会让一次「再问一次就好了」的临时 404 直接挂掉;合成一件事的代价是有界的 —— 在第一条真正答错的路由上烧掉 30 秒然后退出,仍然是一分钟左右就挂,而不是让套件跑满三分钟、再报九条看不懂的 spec。
### 测试 — 变异验证
打的是**从 workflow 文件里抠出来的那段循环原文**,不是照着重打一遍的副本,由一个回放真实录到的状态码的 stub server 驱动:
| 状态 | 本次守卫 | main 上的守卫 |
|---|---|---|
| 健康 | exit 0 | exit 0 |
| `/reset-password/warmup` = 404 | exit 1 | exit 0 ← 就是这个 bug |
| `/library` = 200(跳转坏了) | exit 1 | exit 0 ← 一样漏 |
### ★ 第二条变异的价值不在那个红绿
它逮住的是我第一版把「嵌套动态段」那句提示**硬编码进了失败行** —— 于是一次 `/library` 上的跳转退化,会被告知「去读一读打包器的问题」。提示现在只在状态码确实是 404 时才出现。
### 更正 — 把这个 flake 归因给 AniList 限流是错的
TODOS.md 新增三条,其中一条是更正:已有的那条 AniList 限流笔记声称自己**多半就是这个 flake 的成因**,它不是 —— 症状是 404 不是 500;受害路由里有从不调用 AniList 的(`/reset-password/[token]` 就是);那次失败的 `/anime/*` 请求 42ms 就返回了、其中 5ms 在 next.js 里,什么都没到过第三方。**那个缺陷本身是真的、可独立复现,错的只是归因。**
同一句错误的因果判断也写在 `not-found-status.spec.ts` 的注释里,一并删掉,换成这个文件最需要读者知道的那句:在那个状态下,这里每一条否定断言都会通过,而且是因为错误的理由。
另外,`TODOS.md` 里躺着一行裸的 diff3 冲突标记 `||||||| 6b31a3e` —— 3.19.1 那次带进来的,在一个公开仓库跟踪的文件里,渲染成两条条目之间的字面垃圾。一行,删掉。
---
## [3.20.1] - 2026-08-29
### 修复 — 打一个字搜一次:一个五字标题走了四趟服务端往返
`/search` 的输入框防抖 400ms,然后靠**往 Server Component 推一个新 URL**来出结果。打线上实测,一个字一个字地打 `进击的巨人`:一个五字标题**四趟完整的服务端往返**,而且路上有三件事是错的。
1. **从来没有过 pending 态。** `router.push` 跑在 React transition 里,所以旧结果就那么原样杵着,直到新 payload 落地。那段等待读起来像卡死,不像「正在做事」。
2. **字会被吃掉。** 页面在每次响应上都拿服务端的 `q` 重新给输入框播种(`useEffect(() => setQ(initialQ), [initialQ])`),所以打字快过一次往返时,**更早那次按键的回包会把输入框倒回去**。探针打的是 `进击的巨人`,URL 最后停在 `进击的人` —— 那个 `巨` 是被**覆盖**掉的,不是被丢掉的。
3. **没算上输入法。** Chrome 对一次组合输入的**每个字母**都会触发 `input`(也就是 React 的 `onChange`),所以同一个标题顺带还搜了 `j`、`jin`、`jinj`、`jinji`。这些按定义在目录里什么都匹配不到,于是每一个都穿过本地查询、出到 AniList —— 正是这个项目被限流的那个上游。线上实测:`/api/anime/search?q=jinji` 返回 total 0。
### 变更 — 查询搬进浏览器
`SearchExperience` 自己 fetch `/api/anime/search`,就地替换结果,用 `history.replaceState` 保住 URL 可分享,中止被顶替掉的请求,并把本次会话内答过的查询留着,让退格不花钱。组合输入期间**不排任何调度**,直到那个字被提交;`onBlur` 是泄压阀 —— 万一 `compositionend` 没来,也不至于把这个页面剩下的生命里的搜索静默关掉。
防抖定在 **1000ms**。驱动一个真的拼音输入法打 `进击的巨人`,三种速度下每个标题的请求数:
| 每字之间的停顿 | 400ms 时 | 1000ms 时 |
|---|---|---|
| 150ms(偏快) | 1 | 1 |
| 400ms(正常) | 1 | 1 |
| 800ms(偏慢) | 4 | 1 |
回车、搜索按钮、类型标签都跳过这段等待。
### ★ 分页和它是同一个 bug,不是另一个
防抖要问的是「控件是不是已经和屏幕上的东西不一样了」,而输入框**只可能表达第 1 页**。拿整个 query 去比,就让每一次身处第 2 页都看起来像一次「待生效的、要回第 1 页」的改动 —— 点了下一页的读者,**一秒钟之后被静默送回第 1 页**。现在比的是 terms;分页链接仍然是真的 `<a>`,中键还能开新标签。
### ★★ 水合之前打的字,进了 DOM 没进 state
写这条 spec 时顺手写的探针**六次里有三次报告「功能坏了」**,而功能是好的。
React 会给它水合过的节点挂上 `__reactFiber$…` 开头的键,**在这些键出现之前,一次按键落进 DOM 就再也到不了 state**。`next dev` 是按需编译的,所以冷路由上这个窗口有好几秒宽。这条 spec 因此有一个 `hydrated()` 前置:等到 `input[name="q"]` 上出现 `__react` 前缀的键才开始打字。它不是伪装成 sleep 的偏执 —— **任何会打字的 e2e 都要先等它**,否则失败的样子和「功能真坏了」一模一样。
同一件事也是这次给表单补上 `method=get`、locale 感知的 `action` 和 `name="q"` 的理由:这条路由是流式的,所以存在一个「输入框已经在屏幕上、React 还没水合它」的窗口,在那个窗口里按回车,现在是一次普通导航,而不是什么都不发生。
Server Component 仍然渲染第一批结果,所以一条分享出去的链接、一个爬虫,拿到的和以前一样。
### 测试
`e2e/specs/sandbox/search-typing.spec.ts` 断言的是**请求条数和输入框里剩下什么,不是结果**。这是故意的:它因此不需要种任何行,挂的时候也是因为它名字里那件事,而不是因为某个 fixture 漂了。
- 输入法那条用 CDP 的 `Input.imeSetComposition` 驱动一次真的拼音组合,每个字母之后停 500ms(比修复前的 400ms 防抖长)。它带一条**反空洞断言**:组合提交之后必须搜到一次 —— 没有这条,一个**什么都不搜**的实现也会通过。
- `replaceState` 那条断言 `history.length` 不增长,否则「后退」会变成打字过程的慢放重播,而不是搜索的出口。
- 分页那组是 `serial` 的:整个套件 `fullyParallel`,不锁的话每个领到用例的 worker 都会各自 `beforeAll` 种一遍,而第一个 `afterAll` 会把行从别人脚下删掉 —— **那个现象读起来和这条 spec 要抓的产品 bug 一模一样。**
---
## [3.20.0] - 2026-08-29
### 新增 — 弹幕速度三档,全在慢的那一端,默认最慢
插件自带五档(穿屏 10 / 7.5 / 5 / 2.5 / 1 秒),只给其中三档起了名字,把 5 放在正中,而播放器一直把那个 5 写死。一集弹幕密的时候,5 秒不够把一行读完它就走了。
换成三档,全部落在慢的那一端,默认最慢:
```
极慢 10s 缓慢 8s 适中 6.5s
▲ 默认
```
每一档都比原来写死的 5 慢,而且每一档都有名字 —— 插件把 7.5 和 2.5 标成 `hide: true`,所以旧控件是**三个名字压在五个位置上**,中间两格无名。
### ★★ 天花板是 10,而且不是我们的
`artplayer-plugin-danmuku` 在 `config()` 里夹:
```js
this.option.speed = clamp(this.option.speed, 1, 10);
```
所以一个「看起来更慢」的档位不会更慢地渲染,它会**静默变成 10** —— 实测 `config({speed: 15})` 和 `config({speed: 12})` 读回来都是 10。
最难受的是它从界面上完全看不出来:滑条靠 `option.speed` 与档位值**精确相等**去找手柄,于是一个超范围的档位会塌到持有 10 的那一格上,**手柄停在错误的标签下面**。原本想要的「极慢 15s / 缓慢 10s」因此做不出来 —— 那两档会变成同一个值。`speedLadderIsRenderable()` 和它的测试把这条钉死,因为下一个想要「更慢」的人,第一反应就是填一个更大的数字(我就是)。
### ★ 存量值回落到默认,不是往梯度上夹
这个选择和字幕大小、倍速一样会持久化。但控件在**插件自己的配置面板**里,不是 artplayer 的设置菜单,我们没有任何 onChange 可以挂 —— 唯一的信号是 `artplayerPluginDanmuku:config`,而它**对插件的每一次配置变更都触发**,包括每次切集我们递过去的那份弹幕列表。用一个 ref 把它收敛成「每次真变更写一次」,`writeDanmakuSpeed` 拒绝任何不在梯度上的值。
读的一侧,不在梯度上的存量值**回落到默认**,而不是往梯度上夹。这次改动之前播过番的人,要么什么都没存、要么存着旧的 5,两者都没有对应的滑条位置 —— `findIndex` 返回 -1,手柄无处可停。
### ★ 验证必须驱动真控件
hover 齿轮,点轨道上中间那一格。未存任何值时打开是 `极慢/10s`,点一下变 `缓慢/8s` 并写入 `"8"`,重开播放器回到 `缓慢`。
**直接调 `config()` 会改速度但标签不刷新** —— 标签是滑条自己的 `onChange` 重绘的。我第一次就是这么测的,读起来和产品 bug 一模一样。
### 已知未闭合
插件的配置面板是硬编码中文(按类型屏蔽 / 滚动 / 顶部 …),所以档位名用中文才与周围一致,而 `/en` 下那个面板本来就是中文。
---
## [3.19.2] - 2026-08-29
### 修复 — Chrome 把画面调灰了,而 JavaScript 看不见这件事
上报是「网站看起来比我本地播放器发白」。不是播放器的渲染 bug,也不是文件的问题:Chrome 合成 `<video>` —— 而且只对 `<video>` —— 会过一次传递函数转换,**把中间调抬起来,两端钉死**。
拿 16 级灰阶楔子对着 ffmpeg 的解码测,用的是一个**完全不带色彩标记**的文件(无 `colr` atom、VUI unspecified —— Baha / ANi 的 WEB-DL 就是这个形状):
```
源 ffmpeg Chrome 源 ffmpeg Chrome
0 0 0 136 136 147
17 17 16 170 170 179
51 51 58 204 203 210
102 102 114 255 255 255
```
平均绝对误差 7 级,峰值 +12。两端锁死、中间被压平 —— 这正是「发灰」读起来的样子。mpv、IINA、VLC 不做这个转换,所以同一个文件在它们那里更有对比。
### ★★ 它藏得这么好,是因为从 JS 里怎么看都是对的
解码本身没问题 —— 用 `canvas.drawImage(video)` 把帧读回来,和 ffmpeg 差在 2 级以内。**只有合成后的输出被移位了**,所以每一种从 JavaScript 检查像素的办法都会告诉你画面很好。
只能靠**截屏**抓,而且要在同一张截图里放一块同名义色值的 CSS 色块当对照 —— 色块出来是精确值,这才证明位移是视频专有的,而不是截图路径本身的。
### ★★ 表是实测的,不是推导的
解析的 BT.709 → sRGB 模型预测暗部会被大幅抬起,**而 Chrome 并没有这么做** —— 模型说 17 → 32,Chrome 把它留在 16。所以拿一个 `feFunc type="gamma"` 的指数去拟合中间调,必然把底端压过头:1.136 是最好的幂拟合,它仍然把 17 压成了 10。
提交的表是**实测曲线的逆**。放回 Chrome 里跑,楔子的平均绝对误差回到 0.31 级、暗部那一级完好;在上报的那个文件上,整帧落在 ffmpeg 的 1 级以内:
```
ffmpeg 参考 122.8 / 108.9 / 85.4 饱和 37.5%
修复前 133.7 / 118.4 / 92.6 饱和 36.8%
修复后 124.4 / 109.2 / 84.5 饱和 37.9%
```
### 播放代价测不出来
1920×1080、4000 条弹幕、ASS 字幕层开着,交替跑 6 次 20 秒:**每一次丢帧都是 0**,开不开滤镜都一样。迟到帧的计数随运行顺序单调上升而与滤镜无关(有一次不开滤镜的跑分比开滤镜的还差),所以那个漂移是机器负载,不是这次的修正。
### ★ 两处一碰就坏的细节
- **全屏那条规则现在要同时写两个滤镜。** `filter` 是**单个属性**,只留 `opacity(0.999)` 会让修正**只在全屏下**消失。`opacity()` 保留而不是当成冗余删掉:它是阻止硬件 overlay 提升的东西,也就是当年修字幕闪烁(#24)的东西,而「只留 `url()` 滤镜是否也挡得住」没有重新验证过。
- **`color-interpolation-filters="sRGB"`。** SVG 的默认值是 `linearRGB`,那会把这张表用在完全错误的色彩空间里。
### 范围:只给 `<video>`
jassub 的字幕 canvas 和弹幕层是我们自己在 sRGB 里画的,从来没走过 Chrome 的视频路径 —— 给它们也上修正,等于把这次消掉的误差重新引进去。
Chrome-only 是**决定**,不是特性检测:Safari 和 Firefox 对视频传递函数的处理方式不同,而我们没有测过它们。
### 测试
`videoTransferFilter.test.ts` 钉住三件事:id 在两个文件之间一致、全屏规则里两个滤镜都在、表的形状。一个目标不存在的 `filter: url(#id)` 会**不报任何错**地把视频原样渲染出来,所以任意一侧改名都会是无声的。
---
## [3.19.1] - 2026-08-29
### 修复 — 三条声称「结束会话」的路径,每条都留下了一份还能用的凭证
登出、密码重置、管理员改密码,三条路径都声称结束一个会话。**每条都以不同的机制留下了一份还能用的凭证,而仓库里没有任何一条测试看得见其中任何一条。**
**登出跑在 `RequireAuth` 后面。** 一个闲置超过 access TTL(默认 15 分钟)的标签页,会在中间件那里拿到 401,handler 根本不执行 —— 没有 Clear-Cookie,没有数据库写入 —— 而浏览器手上那个 refresh cookie 还在,`proxy.ts` 会在**下一次导航**就把它花掉。用户看到的是一个登出了的界面,然后被静默地重新登了回去。这不是竞态:闲置超过那个时长,每次都发生。现在登出改成**从 refresh cookie 认证**,并且在任何东西可能失败之前就无条件清 cookie。
### ★★ `fetch` 对 429 是 resolve,不是 reject
上面那个修复自己带着一个洞,是评审里发现的。
路由前面的限流是**从中间件**答 429 的 —— 在 handler 写出任何 Clear-Cookie 头之前。而 Navbar 只 `await` 了那个 fetch,没有查 `res.ok`:**`fetch` 在 429 上是 resolve 的**,所以 catch 分支一次都没进过,`setUser(null)` 照常执行。同一个 bug,换了个触发条件,而且在多人共用同一个出口地址时是日常可达的。
现在登出不再和刷新共用同一份限流预算;客户端也拒绝在服务端没有确认清掉 cookie 的情况下,把导航栏画成登出态。那句写着「剩下唯一的缺口是请求根本没送到」的注释,**错的方式和它替换掉的那条注释一模一样:它断言了一个路由层并不提供的保证。**
### 轮换没有 compare-and-swap
Refresh 是读行 → 签两个 JWT → **无条件**写回。一次落在这个窗口里的登出,会被那次在途的轮换覆盖掉,然后把一个还活着的 refresh token 交回去:**用户按了登出,然后还登着。**
`RotateRefreshToken` 现在是 `:one ... RETURNING username, role` 加 `AND refresh_token = @expected`,顺带把顺利路径从 SELECT → 签名 → UPDATE 收成一条语句。
### ★★ 签名有效 ≠ 这个 token 还活着
登出自己那次写是**不带范围的**。它认证的是一个**签名**,而签名在整个 refresh TTL 里都有效 —— 包括服务端早就撤销掉它所签的那个 token 之后。于是一份**已死的凭证**(一个旧浏览器 profile、一份过期备份)可以被反复重放到 `/logout`,把它的前主人挡在会话之外。
`ClearRefreshTokenIfMatches` 把这次写限定在调用方**还能证明自己持有**的那个会话上。`previous_refresh_token` 也在匹配范围里,这样一个刚刚轮换过的标签页仍然登得掉自己。
### ★ 改密码的语句只清了三列里的一列 —— 而它的后果是 500,不是漏了个 token
`refresh_token`、`previous_refresh_token`、`refresh_rotated_at` 是**一份状态**:Refresh handler 在轮换后 30 秒内会回落到那个宽限位。只把第一列置空,行就落在了读取侧没预期过的形状上,宽限分支会解引用一个 nil 的 `*refresh_token` —— 任何一次改密码之后的 30 秒内,经 Recoverer 变成 500。
**这一条要说准,因为直觉的猜法是错的**:它从来没有发出过 token,那次 panic 发生在 response writer 之前。**是可用性问题,不是凭证问题。** 三条语句现在都清三列,读取侧的 nil 守卫留着。
### 顺带修的三件先于本分支存在的问题
同一轮评审找出来的:
- `proxy.ts` 在它自己那次 SSR refresh 上**没有把真实客户端 IP 转发下去**,于是全站每一次服务端 refresh 都塌进同一个桶里。桶抽干的时候,用户被渲染成未登录。`lib/api.ts:buildHeaders` 早就这么做了,它的注释描述的正是这个失败,只是调用点一直没跟上。
- 管理员改密码端点**按字节数**校验,而注册**按 rune 数**(go-playground `min=6`),所以 `密码密码` 被公开表单拒绝、却被管理员路径接受。一个只有管理员能用的端点成了全站设置密码最弱的地方,方向是反的。
- `UpdateBangumiV1` 无条件写,所以一个陈旧的重复 river job 可以覆盖掉已经被 v2/v3 富化过、或人工纠正过的绑定。现在是 `:execrows` 且带 `bangumi_version = 0` 守卫,被拒绝的写打 `stale_skip`,而不是接着链 V2。
### 已知未闭合 —— 「改密码会让所有已有会话失效」这句话,代码做不到
go-api 和 next-app 里都有注释这么写。**它们做不到**,而这是查过的、不是推的:access token 是无状态 JWT,签发之后服务端对它不再有发言权,所以诚实的上限就是它自己的有效期,注释现在照实写。
没有任何面向用户的文案许过这个诺 —— 被这句注释误导的,是**正在处置一次入侵、一边照着管理员路径操作的运维**。主动撤销这条不在本次范围内,记在 TODOS.md 里。
### 测试 —— 这一类缺陷对现有测试是结构性不可见的
handler 的 fake 只记录参数,**看不见一条 SQL 字符串到底写了什么**,所以这次的每一处改动对它们都是结构上不可见的。
- `test/integration/user_session_columns_test.go` 打一个真 Postgres 读实际的列,包括一次**故意把两个参数写反**的 CAS 调用 —— 两个参数都是 `*string`,所以调反了照样编译、照样通过每一个 fake。
- `internal/db/session_columns_test.go` 扫 SQL 源码,让规则覆盖到**还没写出来的语句**。它拒绝猜:一个碰了会话列的 CTE 或 `UPDATE ... FROM` 会挂,而不是被跳过;任何一次它无法证明写进去的是活 token 的 `refresh_token` 写入,都必须显式声明。它自己的**解析器自检**逮住了第一版是瞎的 —— `SET` 是**行首**,而它找的是带前导空格的 `" SET "`。
- `internal/admin/set_password_test.go` 覆盖了一个此前一条测试都没有的端点,而 `fakeUserDB` 的接线一直都在。
上面每一条规则和断言都做了变异验证:把源码里的改动反过来,确认测试确实变红。SQL 扫描的四个盲点就是这么找出来并补上的。
---
## [3.19.0] - 2026-08-29
### 新增 — 输入简体,也能搜到只有繁体标题的那 1,262 部
目录里有 1,262 行只有繁体标题、没有简体标题,所以打 `进击的巨人` 找不到《進擊的巨人》。成因在上游而且很具体:简体标题来自 Bangumi 富化,而这批行的 `bgm_id` 覆盖率只有 **9.4%**,全目录是 **70.8%** —— 根本没有 subject 可以读出一个简体名来。它们也不冷门:315 部是 TV,里面有进击的巨人最终季、排球少年、BLEACH 千年血战篇、药屋少女、冰海战记。
现在搜索把关键词过一遍随包的 OpenCC `s2twp` 表,把结果作为**一条额外谓词**去匹配 `title_hant`。**不落库,也不改任何显示** —— 这正是要这么做的全部理由:把转换出来的标题回填进 `title_chinese`,等于把机器输出直接推上每一个 SEO 面,而迁移 0022 建 `title_hant_seo` 就是为了拦这件事;`title_chinese` 没有这道闸,因为它**就是**页面 title 和 JSON-LD 读的那个字段。
### ★ 一条谓词,不是五条
折出来的 pattern 是繁体文本,繁体文本只可能命中繁体列。把它复制到另外四列上,是四次**结构上不可能返回行**的索引扫描。而当关键词没有可折的东西时,调用方传的就是它本来那个 pattern,于是这条谓词**塌成一条重复项,而不是塌成一个两边都得记着的分支**。
### ★★ 它到底管多少用 —— 实测,不是假设
在 5,160 行**同时**有简体标题和一个**权威**繁体标题(`title_hant_source <> 'opencc'`)的数据上,把简体标题折过去,看它是不是存着的繁体标题的子串(也就是这条谓词做的事):
| | 命中率 |
|---|---|
| 折叠后 | 40.7% |
| 不折叠 | 7.4% |
大约五倍半的触达,**不是「解决了」**。
剩下的 59% 不是转换问题,任何一张转换表都修不了:台湾和大陆很多时候就是用不同的名字发行。2,237 条 miss 和存着的标题**长度都不一样**,也就是干脆是另一个译名 —— 海賊王 对 航海王、三眼小子 对 三眼神童、鋼鐵奇兵 对 金屬對決。够到那些要一张别名表。TODOS.md 收的是这条**带着这组测量数字**,不是一句猜测。
### ★ 折叠器是构造参数,不是 setter
setter 会被忘掉,而忘掉它就等于把目录静默地退回上面那个状态 —— 没有报错,没有日志行。
降级路径也实跑过:把 `HANT_DATA_DIR` 指到一个不存在的地方启 go-api,它 2 秒内起来并健康,打一行 `folding disabled`,照常提供搜索,而 e2e 带着自己的诊断信息变红。这既是这条特性的变异测试,也是「数据集缺失不是一次故障」的证明。
### ★ `s2twp` 不是纯字形转换 —— 这个坑记进了 e2e
`wp` 后缀是**台湾用词替换**,所以 `折叠` 出来是 `摺疊`,不是 `折疊`。拿这种词搭的测试探针,会在特性完全正常的时候挂掉 —— 花了两轮才看出来。
### 验证
- 打生产表,只读:`进击的巨人` 从 12 行变 18 行,**新增的六行全部是只有繁体标题的行**
- 本地整栈走 HTTP 端到端,探针种成**只有折叠能够到**的样子 —— 它的简体列和查询没有任何共同字
- 顺带:`hant.NewConverterFromDir`,让一个只需要转换文本的调用方不必加载 609KB 它用不上的 resolver 数据集,文件名也留在那个记着它为什么存在的包里
---
## [3.18.3] - 2026-08-28
### 修复 — /zh-Hant/search 的 18 张卡片里有 12 张写着简体
线上实测:`/zh-Hant/search` 的 18 张结果卡里 12 张显示简体标题。首页、季度页、日历、详情页全是对的 —— 分别数了 54、20、14、22 张卡,一张没错。
繁体这条链路没有任何东西是坏的。`lib/formatters.ts` 里的 zh-Hant 梯子从迁移 0022 起第一顺位就读 `titleHant`,go-api 也从那时起就在返回这个字段:`GetAnimeByAnilistIDs` 选了它,AniList 路径和本地目录路径都走这同一条查询。
差别在于**其它页面是把整行原样透传给 `<AnimeCard>` 的**,所以字段到不到,跟哪个 TypeScript 类型提没提它无关 —— `pickTitle` 在运行时按字符串键读。只有 `/search` 是逐字段手搭卡片对象,而 `titleHant` 不在那张单子上。少一个可选属性不是类型错误,每个文件单独看都很合理,而简体标题对繁体读者也读得懂 —— 没有任何一环会把它顶出来。
### ★ 它是被 #123 揭出来的,不是被 #123 弄坏的
在 #123 之前,繁体查询根本查不出东西,也就没有卡片可以渲染错。**一个修复看起来揭出了一个新 bug 的时候,值得先问它是不是本来就在那儿。**
### ★ 梯子要靠探测拿到,不能靠解析
`cardDataTitles.test.ts` 不去解析 `TITLE_LADDER` 那个私有常量,而是**拿探针问 `pickTitle` 自己**:抄常量的测试会和坏实现一起点头,跟和好实现点头一样痛快。拿到梯子之后它断言两件事 —— `AnimeCardData` 声明了任何一条梯子会读的字段,以及每一处手搭的卡片字面量都把它们赋上了。两半都做了变异验证:删接口字段、删构造点字段,各自产出一条指名道姓的失败。
### 测试
`e2e/specs/sandbox/search.spec.ts` 是沙箱套件里**第一条搜索 spec** —— 这也正是同一个页面上能同时住着两个缺陷的原因。它断言渲染出来的文字,那是单测够不到的那一半:数据一直是对的,是页面在送往卡片的路上把字段扔了。同样做了变异验证。
e2e 种一个简繁标题**互不相同**的探针(`ensureAnimeCached` 为此长出 `titleHant` 字段),每个 locale 都断言两个方向 —— 期望的字形在、另一个字形不在。只断言「在」,一张两种都渲染的卡会通过;只断言「不在」,一张空卡也会通过。`/en` 也覆盖了:它的梯子**故意停在 romaji**,不往下掉到中文,而证明这一点的正是一个没有英文标题的探针。
本地验证:1756 条单测通过、tsc 干净、lint ratchet 通过,受这次改动影响的五条 spec(search、not-found-status、robots-posture、anime-detail、locale-routing)85/85。`library-*` 那几条在这个 worktree 里挂,但**把改动 stash 掉之后挂得一模一样**,所以那是这个环境的空库和全新浏览器 profile,不是这里的事。
---
## [3.18.2] - 2026-08-28
### 修复 — 中文搜不到,不是因为缺简繁折叠,是因为压根不查本地库
`/api/anime/search` 是个纯 AniList 代理:把关键词转发给 AniList 的 GraphQL,把返回的东西 upsert 进库,再把那些行读回来取富化列。AniList 索引 romaji、English、日文和 synonyms。**它不索引中文标题。**
于是一个用中文搜的读者拿到的,是 AniList 碰巧收了哪个 synonym —— 这就是为什么失败看起来是随机的、而不是系统性的。改动前的生产实测:
```
進擊的巨人 0 条 anime_cache 的 title_hant 里有它 (16498)
鬼滅之刃 0 条 anime_cache 的 title_hant 里有它 (101922)
葬送的芙莉莲 0 条 anime_cache 的 title_chinese 里有它 (154587)
葬送的芙莉蓮 1 条 繁体形式搜得到,简体搜不到
进击的巨人 1 条 同一个查询的日文写法搜出 20 条
```
`title_chinese` 和 `title_hant` 这两列在库里都填得很满。**而没有一条是从搜索够得到的。**
现在搜索先查本地目录,只在本地对这个关键词一无所有时才落到 AniList —— 那正是自愈的那一种情形:AniList 回答,已有的 upsert 把行归档,下一次搜同一个词就是本地的了。带 genre 过滤仍然直接走 AniList,因为本地查询没有 genre 谓词,拿它回答等于静默忽略掉过滤条件。
顺带的收益是大部分搜索流量离开了 AniList:一串未缓存的查询本来可能打光配额,然后以「AniList rate limited」的形式呈给读者。
### ★ 「本地没有」和「数据库不舒服」要有相反的响应
`runLocal` 在目录里没有这个关键词时返回 `(nil, nil)` 而**不是**错误 —— 那是 fall through 的信号。查询真的失败时返回错误,但也不致命:AniList 那条路本身就是一个完整答案,本地失败只让这一个请求丢掉中文匹配,不丢掉请求本身。
而「翻过头的页」仍然返回一个页面而不是 nil:总数一旦非零,本地目录就拥有这个答案,一个 3 页结果的第 9 页是空的,那才是正确的分页 —— 在那里 fall through 会把 AniList 的第 9 页(完全另一堆东西)递给读者。
### 新增 — 迁移 0027:`title_hant` 的 `gin_trgm_ops` 索引
0002 给另外四个标题列都建了,0022 加 `title_hant` 时没建,而且 0022 自己就点名了这个缺口:
> "a reader who types 進擊的巨人 will not match the title_chinese trigram index […] that is a search change, not a locale change"
这次就是那个 search change。
用普通 `CREATE INDEX` 而不是 `CONCURRENTLY`:在一个会回滚的事务里对着生产表计时,建索引耗时 **270ms**(3.2MB 索引)—— 那就是 `anime_cache` 的写入要等的时间。而 `CONCURRENTLY` 不能跑在事务块里,golang-migrate 恰恰是把多语句文件当一个隐式事务交给 PostgreSQL 的。在这里伸手去拿它,等于拿 270ms 的锁去换一个可能直接把部署搞挂的迁移。0002 那四个兄弟索引也是普通的,同一个理由。
trigram 是逐字符的,所以对 CJK 有效:两个字的查询照样产出补齐的 trigram。一个字的查询可能不产出,会退化成对整张表的顺序扫描 —— 这是可接受的,也是没有为它单设守卫的原因。
### ★ 排序:AniList 那条路从来就没排过序
排序是精确匹配 → 前缀 → 子串 → `average_score`。`GetAnimeByAnilistIDs` 按评分排,所以搜「进击的巨人」第一条答的是「第三季 Part.2」。本地这条路在那次重读之后用 `reorderByIDs` 把自己决定的顺序恢复回来。
### ★ 通配符必须在进 ILIKE 之前转义
不转义的话,**搜「100%」会匹配表里的每一行**,搜「_」匹配每一个单字标题。转义顺序有讲究:**先转反斜杠** —— 放在后面会把这个函数自己刚加进去的反斜杠再转一遍。
### ★ 单测用的是 fake,证明不了 SQL
所以验证走到单测之外:
- 查询对着生产表只读跑过:上面那三条死查询各回 5 行,精确匹配在第一位
- `EXPLAIN ANALYZE` 确认干活的就是新索引(五个索引的 BitmapOr,`title_hant` 贡献 18 行,另外四个各贡献 0 行),2.3ms
- 本地全栈端到端组装:繁体和简体都解析得出来;搜「%」返回的是标题里真含一个百分号的那一行,而不是本地栈里的每一行
- 两个变异都咬得住:去掉 `reorderByIDs` 挂排序测试,去掉转义挂 5 条
- 一个故意写坏的 0027 会让集成套件失败 —— 说明迁移闸门是真的在应用它
### ★★ 顺带修掉的 e2e flake,根因是一个真实的产品缺陷
#122 加的那个 spec 在紧接着的下一个 PR 上就被 CI 抓到了:`expected a redirect, got 404`,外加一条 `/en answers 404` 重试才过。本地三次里挂两次。两个独立原因,都是我自己的。
**第一个是 fixture。** 一个别名用户在 `beforeAll` 里种下、`afterAll` 里删掉,生命周期横跨整个文件 —— 而重试会重跑 hook,serial 组的 `afterAll` 可能和后一次尝试的 `beforeAll` 交错。与其钉死到底是哪种交错造成的,现在每个测试自己创建、自己删除,用测试名和 worker 命名。单个测试之外的任何东西都观察不到那一行,于是这一类失败是**消失了**,不是被收窄了。
配套加了一道前置检查,因为 **fixture 缺失和跳转坏掉回的都是 404,没有这道检查它们就是同一种红。** 它先断言 go-api 解析得出这个 handle,并在失败信息里把话说清楚。(这道检查自己的第一版发的是 `%40`,对着一个完好无损的 fixture 挂了 —— Go 路由匹配的是字面 `@`,这正是 `canonicalHandle.ts` 的 `encodePathSegment` 要把它还回去的原因。恰好就是这道检查存在的理由,所以现在理由写进了代码。)
**第二个原因更有意思,它是真实的产品缺陷而不是测试 bug。** go-api 没有负缓存,缓存未命中加 `pgx.ErrNoRows` 会直接走到 AniList(`detail.go:423`)。**每一个未收录 id 的请求都会开一次实时的第三方调用**,而 AniList 限流时页面回的是 500 不是 404。本地加压复现过:`Error [ApiError]: AniList rate limited`,紧跟着 `GET /anime/999999999 500`。CI 那次重试几乎可以肯定就是这个。
八个测试各自在开这样一次调用。现在改用 id `0` —— `anime/[id]/page.tsx:1203` 在任何 fetch 之前就用**同一个** `notFound()` 拒掉它,所以它们仍然在检验这个套件存在的意义,而不必离开进程。留一个测试继续用大 id 覆盖查表那条路,答案是 500 时大声 skip 而不是失败。
**那个 500 没有被糊过去**:它连同「对爬虫来说它比 404 更糟」的理由写进了 TODOS.md,并附一条「不要为了让 CI 变绿把这些断言放宽成『404 或 500』」。
结果:连续 4 次干净,`--repeat-each=3` 下 57 条全过;此前是 3 次里挂 2 次。运行时间从约 25s 降到约 11s,因为套件不再等 AniList。
### 已知未闭合 — 剩下的简繁缺口,以及一个被自己推翻的方案
57 行有简体标题没有繁体,1,262 行反过来。这 1,319 行仍然只答应它被存成的那个字形。
TODOS.md 里原本的提议是把 t2s 结果回填进 `title_chinese`。查过那 1,262 行之后,这个建议在**两个方向上都是错的**。
先是原因很具体,不是长尾:`title_chinese` 只来自 Bangumi 富化,而这批行的 bgm_id 覆盖率是 **9.4%**,全目录是 **70.8%** —— 没有映射就没有 subject 可以读出简体标题。AniList 的 synonyms 反而带了一个繁体的,被 0022 的回填归进了 `title_hant`(1,262 行里 1,248 行来自 anilist,来自 opencc 的是 0 行 —— 后者按构造总有一个简体原文)。它们的 `bangumi_version` 一律是 3:**富化跑完了,什么也没找到。** 标题本身说明了原因 —— 几乎全是续作和篇章,正是标题匹配最弱的地方。而且它们并不冷门:315 部是 TV,包括进击的巨人、排球少年、BLEACH、药屋少女和海盗战记。
然后是回填方向没算进去的两笔成本:
- **没有 t2s 表。** 仓库只 vendor 了 `opencc-s2twp.txt`(简→繁,53,607 行),反方向需要自己的生成器和自己的 vendor 数据。
- **`title_chinese` 没有 SEO 闸门。** 0022 专门把 `title_hant_seo` 建成生成列,就是为了把 `source='opencc'` 的值挡在搜索结果之外。`title_chinese` 既没有 source 列也没有等价物 —— **它本身就是页面标题、卡片标题和 JSON-LD 读的那个值。** 往里写机器输出等于绕开 0022 立起来的纪律,而要把纪律恢复回来意味着一个 source 列、一个 `title_chinese_seo` 列,以及把每一个 SEO 读者都挪过去。
改成**转换查询**则什么都不存、什么都不显示,上面这些统统不适用。在推荐之前先量过:加载现有那张表一次性花 23ms、10.7MB(而且它今天是加载在 river worker 里,不在请求路径上),而读者真会打的那些简体形式,在这批行的真实样本上 **8/8 精确**转成了库里存着的繁体串。这覆盖 1,262 / 1,319 —— 而且是这个 22:1 的不对称里更大、更面向简体读者的那一半。
剩下 57 行仍然需要 t2s。五十七行显然不值得为它 vendor 第二张表;**记录下来,不做决定。**
---
## [3.18.1] - 2026-08-28
### 修复 — 五个路由家族对不存在的内容回 200,起因是一个文件放高了一层目录
`/anime/{id}`、`/seasonal/{season}/{year}`,和三个 `/u/{username}` 页。每一个都调了 `notFound()`,每一个都渲染了 not-found 的正文,**每一个都在那之前就把 200 提交到了线上。** Google 管这叫软 404,并把它当成一个真页面收录。
Next 自己写明了规则:
```
"Next.js will return a 200 HTTP status code for streamed responses,
and 404 for non-streamed responses"
-- not-found.md(Next 随包文档)
```
`app/[lang]/loading.tsx` 在它下面的 24 个路由段上开了一道 Suspense 边界,于是每个响应都是流式的,于是在任何页面能设状态码之前,状态码已经发出去了。**那个文件注释的第一行写着「HomePage skeleton」—— 它就是首页自己的骨架屏,只是坐高了一层目录。** 现在它搬进 `(home)/`,只包住它当初为之而写的那一个页面。
另外两个 `loading.tsx` 是直接删掉而不是搬走:`anime/[id]`(167 行)和 `seasonal/[season]/[year]`(164 行)。两者都在 ISR 之下 —— 整页渲染完、一整块发出去,**这两个骨架屏从来没有被任何人看到过。**
### ★ 验证打的是生产镜像,不是 `next dev`
因为之前每一次对这件事的检查都是在 dev 里做的。生产镜像实测:**10 个 404 现在回 404,6 个 200 仍然回 200**,而变异(把 `loading.tsx` 放回去)让它们全部回到 200。
### ★ 两层守卫,因为它们抓的不是同一种东西
- `app/routeBoundaries.test.ts`:只要有人把一个 `loading.tsx` 放在任何能调 `notFound()` 的路由**之上**,它就失败。带一份有过期检查的白名单和反空转守卫,并且它自己也做过变异测试。
- `e2e/specs/sandbox/not-found-status.spec.ts`:数线上真实的那个数字,覆盖全部五个路由和三种语言形式,反方向的 200 也一起数。**这个套件在此之前断言过 404 状态码的次数正好是零。**
### 新增 — 别名 `/u/{handle}` 现在是真的 308
同一次删除顺带修好的:`permanentRedirect()` 之前同样设不了状态码,所以一个 contact 形状的 handle 回 200 并退化成一次客户端导航。现在是真 308,跳转目标是掩码后的 handle。
那条 `noindex` 暂时留着,作为佐证而不是唯一信号 —— 等 308 对着生产构建确认过再撤。**在这里乐观地提前删掉,等于重新暴露一个地址形状的 URL,那不是值得乐观的东西。**
### ★★ 一句被当成实测事实写进注释的话,在全仓传了大约十二个文件
`lib/lang-client.tsx` 的头注释写着:hydration 之后有个 effect 会用 `lang` **cookie** 把语言换掉,所以客户端叶子跟着存储的偏好走、周围服务端渲染的内容跟着地址走,两者「刻意不调和」。
**代码里既没有这个 effect,也没有这个 cookie。** 这个文件既没 import `useEffect` 也没 import `useState`,不读任何 cookie,`LanguageProvider` 没有任何调和步骤。
它之所以要紧,是因为它承了别人推理的重:同一句话在大约十二个文件里被重复,包括 `app/[lang]/seasonal/[season]/[year]/page.tsx` 里 SeasonalFilterChips 旁边那条 —— 那里拿它来论证一个设计决定;而且它还被引用来主张「404 页不能被信任按 URL 的语言渲染」。它能。三个语言现在各在浏览器里断言一次。
### 已知未闭合
五条查出来但不在这次修的,连同代价和「为什么它是独立的一次改动」写进了 TODOS.md:`proxy.ts` 的点后缀规则让邮箱形状的 handle 在裸 URL 上够不到、`/u/` 跳转丢掉语言前缀、404 带着和真页面一模一样的 `Cache-Control`、完全没匹配上的 URL 落到 Next 内置的 404,以及上面那一簇注释的清理。
---
## [3.18.0] - 2026-08-28
### 新增 — 后台「用户活跃度」:不是统计算错了,是这个事实从来没有被记录过
后台一直答得出「有多少账号」「每个账号订阅、评论、关注过什么」。**那些都不是活跃度。** `activity_events`(0018)只装 `watch_progress` / `comment` / `follow` 三种刻意的社交动作 —— 于是一个每天晚上都来、翻目录、什么都不发的读者,对这个仓库能写出来的每一条查询而言,和一个注册一次再没回来的人长得一模一样。
**DAU/WAU/MAU、最后一次出现、到访天数、D1/D7 留存,在旧 schema 下不是慢查询 —— 是无解。**
新表 `user_activity_daily`,一个用户一天一行(`first_seen_at` / `last_seen_at` / `request_count` / `login_count`),后台每一个可信数字都从它算出来。几个把话说死的地方:
- 主键是 `(activity_date, user_id)`,**顺序就是重点**:面板上每一个聚合读取都是一段有界日期范围的扫描,日期在前它才是单次索引范围扫描而不是全表过滤。想要反过来的那些逐用户读取有自己的 `(user_id, activity_date DESC)` 索引。
- `last_seen_at >= first_seen_at` 写成 CHECK 约束。upsert 用 `LEAST`/`GREATEST` 就是为了让它不可能被违反 —— 约束的作用是把这个声明变成**可检查的**,而不只是「本意如此」。
- 保留 400 天而不是 365,好让一个同比对比在被问到的那天两个端点都还在。定在 05:00 UTC,避开弹幕 TTL(04:00,0006)和 `activity_events` 的 prune(04:30,0021),三者不抢同一个 pg_cron worker 槽。
- 迁移里 `SET lock_timeout = '3s'`:那条外键会对 `users` 取 ShareRowExclusive 锁,一个无上限的 DDL 等待会把之后每一次登录和资料写入排在它后面。快速失败让部署重试,严格优于在这张表上排出一列锁队。
### ★ 日界是 +08 不是 UTC
所有 date 列按 Asia/Shanghai 分桶,因为读者在那儿。UTC 分桶会把一个晚上 00:00–08:00 那一段(正是这个目录的黄金时段)算到前一个自然日,**把一个人的一个晚上抹成两个「活跃日」**,同时把日趋势和到访天数一起抬高。这个偏移是固定且历史确定的:中国 1991 年之后没有夏令时,所以字面 `+08` 是精确值不是近似,也不依赖容器的 tzdata。写入端(`internal/activity`)用固定的 `time.FixedZone(+8h)` 算同一条边界 —— 两边必须一致。
### ★ 请求路径上一次数据库都不碰
中间件和登录钩子都以「互斥锁下写一次 map」结尾,Postgres 由后台 goroutine 每分钟写一次。实测每请求增量:
| 场景 | 增量 |
|---|---|
| 匿名 | +51ns,零分配 |
| 已认证,路由本来就要验 token | +1.3µs |
| 公开路由,别处没人验 token | +7.4µs |
+1.3µs 那一档靠的是一个 claims sink 把已验证的 claims 从 auth 中间件里带出来,而不是把 HMAC 再算一遍。见 `BenchmarkMiddleware`。
另外 `/api/avatars/` 不再算作 presence —— 它是个文件服务器,一个讨论页会记二十次「请求」。
### ★★ 方案的另一半(浏览器 beacon)在上线前被砍掉了 —— 它把 CDN 缓存命中变成了源站请求
`/anime/*` 走 Cloudflare 边缘缓存,命中时请求根本到不了源站。beacon 是 POST,而 CF 从不缓存 POST —— **于是每一次真人页面浏览都会产生一个本来不存在的源站请求。这是类别变化,不是百分比变化。**
还有第二笔:beacon 是写请求,会记进读者自己那份 per-IP 限流预算里 —— **遥测和这个人真正想做的事在抢同一份额度**,点得快的人可能被自己的遥测挡下来。成本落在读者头上。
砍掉它的代价是:页面浏览数、播放数、以及按站内区域的拆分,加上对未登录读者的**全部**可见性。除此之外不花任何东西 —— DAU/WAU/MAU、最后出现、到访天数、登录、留存全都来自服务端 recorder,而 `Navbar.tsx:276-310` 对持有 `auth_hint` 的人在每次 pathname 变化时都会重跑 `/api/auth/me`,所以已登录读者的软导航照样计得到。
`page_view_count` 和 `playback_count` 跟着一起删掉,而不是留成永远为 0 的列:**一个永远打印「播放:0」的看板,比一个压根不提播放的看板更糟。** 0025 还没部署过,所以是就地改而不是再发一个 0027 去撤。那张聚合用、没有 user 列的 `activity_surface_daily` 也一并去掉。
未登录读者的覆盖是一个真缺口,记在 TODOS.md 和 0025 的表头里:要补就从 nginx / CF 日志去拿,**不要在热路径上开一个写端点**。
### ★★ 删掉一个用户,能静默清掉所有人的计数
外部 Codex 走查发现的四个缺陷里最锐的一条。flush 是「所有用户一条语句」,出错就整批丢弃;而 access token 在账号被删之后还有效 15 分钟 —— 于是它发的每一个请求都会入队一个背后没有行的 uuid,撞外键失败,**那一分钟里所有人的计数一起消失**。修法是对 `users` 加 `WHERE EXISTS`,让它结构上不可能发生,而不是「被处理掉了」。
其余三条:
- **停机 drain 根本够不到。** `Close()` 被 defer 在一个 15s 的 `srv.Shutdown` 后面,而 Docker 的默认宽限期是 10s;并且错误分支直接退进程、defer 一个都不跑。现在两条路径都显式 Close,drain 有自己 3s 的预算,compose 给 go-api 加 `stop_grace_period: 30s` —— 那个早就存在的 15s shutdown 也是**第一次**真正够得到。
- **`Start()` 调两次会在第二次 `close(done)` 时 panic**,发生在停机时,在生产。改成 `sync.Once`;并且 `Close` 现在会在没人启动过时把循环起起来,免得它永远等一个不会有人去关的 channel。
- **溢出 flush 会给超出上限的每个请求各开一个 goroutine** —— goroutine 风暴恰好在数据库已经不舒服的时候到达。改成容量 1 的 kick channel 由现有循环消费,而且拒绝路径也要发信号(第一版漏了这半边)。
### ★ 0026 用别的表恰好见证到的东西重建历史 —— 并且必须把接缝画出来
这样面板一打开就有图而不是一片空白。但那些是**互动日**,是真实到访的一个严格且很小的子集;连着画、中间不加分隔的话,**切换点看起来就像产品起飞了,而且一定会被这么读。**
每一行重建出来的数据 `request_count` 都是 0,而实时 recorder 永远不会把它留在 0 —— 所以「埋点从哪天开始」是从数据里推出来的(`min(activity_date) FILTER (WHERE request_count > 0)`),不是一个会漂的手写常量。接口把它返回,图上把接缝画出来。
`episode_watches` 被**刻意**排除在数据源之外:0024 给每一条已存在的观看标记盖了同一个事务时间戳,拿它当源会凭空造出一个「全体用户在同一天全部活跃」的日子。
### 变更 — 留存
D1 / D7 逐日精确,每个 horizon 各有自己的资格闸门,所以今早才注册的账号不会被算成「明天没回来」。「曾经回来过」和它们并列展示 —— 因为一个 horizon 的 cohort 可能小到百分比说明不了任何事,只报比率会把噪声画成趋势。
### 测试
`lib/activityChart.ts` 有单测,但纯函数覆盖率抓不住一个**根本不调用这些函数**的组件。用 `renderToStaticMarkup`(react-dom/server,不引 jsdom,不加新依赖)钉住四件这个面板没人盯就会做错的事:四个可靠性档次仍然可区分、某个子查询失败要渲染成「不可用」而不是渲染成 0、埋点之前的柱子要带斜纹和那句说明而不是只有一个图例色块、全零序列产出 `height:0%` 而不是 `NaN%`。
新增:`login_count` 唯一写入方(含「登录失败不动它」)、ClaimsSink 的不变量(sink 写入不能伪造一个已认证上下文)、删号守卫打真 schema、留存的软失败、Start/Close 幂等、溢出合并。
### ★ effect 体里 setState 是硬 lint 错误,不是警告
D3 重写把 fetch 改成内联,`setLoading(true)` 直接写进了 effect 体,被 `react-hooks/set-state-in-effect` 拒绝。**lint 棘轮是闸门不是建议,而我本地只跑了 build + tsc。**
修法和仓库里 Navbar 那个鉴权探针同一个形状:每个 setState 都挪进 async helper,effect 期间不同步触发任何东西,也就不会引发级联渲染。同一轮里 `ActivitySection` 的 fetch effect 也不再把自己会写的 state 列进依赖 —— 那让一次失败发出了两个请求。
---
## [3.17.5] - 2026-08-28
### 修复 — 每个 404 页都带着两个自相矛盾的 robots 标签
```
<meta name="robots" content="index, follow"> <- 根 layout,全站
<meta name="robots" content="noindex"> <- notFound()
```
Google 解这个冲突的办法是取最严的那个值,所以不该被收录的东西确实没被收录。但**这个保证靠的是一条平局裁决规则,不是页面把话说清楚了** —— 而在这些路由上 `noindex` 是唯一的信号:`/anime/[id]` 和 `/seasonal/[season]/[year]` 有 `loading.tsx`,响应是流式的,按 Next 自己的文档,头一旦发出去状态码就再也改不了。一部不存在的番回的是 200。是那个 meta 标签在拦着它进索引,它不该先赢一场争论才行。
`index, follow` 本来就是爬虫的默认假设,全站声明它什么也没买到。删掉之后,正常页面没有 robots 标签(也就是可收录),404 页只剩一个毫不含糊的 `noindex`。
真正需要限制的页面本来就自己声明:`/login`、`/register`、`/profile`、`/settings`、无查询词的 `/search`,以及 `/u/[username]` 的别名形式;法律页走 `untranslatedRobots(locale)`。
### ★★ `/smoke` 是被这次删除翻出来的 —— 一刀切放行会让两种页面长得一模一样
`/smoke` 是个 debug 页,打印上游调用返回或失败的内容。它自己没声明任何 robots 指令,于是继承了根 layout 那条 `index, follow` —— **也就是说它一直在主动请求被收录。** 现在它自己声明 `index: false, follow: false`。
这就是一刀切放行造出来的风险形状:**它让一个想清楚了自己该可收录的页面,和一个从没想过这个问题的页面,变得无法区分。**
### 已知未闭合 — 这次没有交付真正的 404 状态码
两个候选修法都实测过,都被排除:
- **把 `notFound()` 挪进 `generateMetadata` 不会设状态码。** 听起来很有道理,一测就是假的。对照组成立:同一个 dev server 对完全没匹配上的路由照样正确返回 404。
- **只删掉那两个 SEO 路由的 `loading.tsx` 也不够。** 单删 `app/[lang]/anime/[id]/loading.tsx` 仍然回 200;逼出流式的那道边界是**根部的** `app/[lang]/loading.tsx`,删它能拿到真 404,代价是全站每一条路由都失去流式骨架。
所以剩下的决定是全站级的而不是逐路由的,得由权衡「冷加载时的骨架屏」和「软 404 报告」的人来做(3.18.1 做了这个决定)。Next 的文档写明收录风险已经被兜住:"In the streaming case, this does not lead to indexation because the page is explicitly marked noindex in the HTML."
`global-not-found.js` 不适用 —— 它管的是完全没匹配上任何路由的 URL,而 `/anime/999999999` 是先匹配上了自己的路由,再调 `notFound()`。
### ★ 测试 — spec 的第一版自己是错的,而且只有 CI 抓得到
`robots-posture.spec.ts` 写成 e2e 而不是单测,因为缺陷不在任何一个页面的声明里 —— 每个页面单看都是对的。是一个**继承来的**标签撞上一个**声明的**标签,只有渲染出来的 HTML 才看得见。
第一版断言的是「最多只有一个 robots 指令」(一个渲染细节),而且读的是活 DOM。两个一样的 `noindex` 是冗余,不是矛盾;而**爬虫从不 hydrate** —— 这些路由是流式的,hydration 期间那个标签会短暂出现两次,本地跑不出来,CI 上炸。现在它直接查矛盾本身,并用 `page.request.get` 读服务端渲染的 HTML,也就是爬虫真正拿到手的那份文档。`--repeat-each=3` 实测 60/60,变异仍然咬得住。
---
## [3.17.4] - 2026-08-28
### 修复 — sitemap 只向 Google 报了目录里的 20 部番
`/sitemap.xml` 的番剧行来自 `/api/anime/yearly-top` —— 一个回答「某一年评分最高的那些」并且自己封顶 20 条的端点。sitemap 要的是 `limit=100`,`parseLimit` 静默夹到了 20。
生产实测:`/sitemap.xml` 出 75 条 URL,其中 60 条是 `/anime/` —— 20 部番 × 3 个语言。目录余下的部分,只有靠爬虫猜 id 才够得到。
**没有任何东西失败过。** 那个错的端点回答得完全正确,文档校验通过,没有一条测试或一行日志对它有意见 —— 这正是它活下来的原因。而且改 limit 也修不了它:`/yearly-top` 根本枚举不了一个目录。
### 新增 — `GET /api/anime/sitemap`,故意不加任何过滤
`anime_cache` 里每一行在 `/anime/{id}` 都渲染出 200,所以在这里写一个 `WHERE` 就是在替 Google 决定该藏起哪些真实存在的页面。从生产抽 12 个随机 id 验证,全部 200。
### ★ 分片按 `anilist_id % n`,不按 LIMIT/OFFSET
用 offset 的话,新增一行会把它之后的每一部番挤进另一个文件,重读整套的爬虫看到的是整条尾巴都在变动。取模让一个 id 终身待在同一个文件里,而且 id 空间够密,不调参也是均衡的 —— 生产实测 n=4 时四片的行数偏离均值不到 ±9%,四片之和与表的行数严格相等,无丢无重。
分 4 片而不是 1 片,是因为站点给每一部番的每一个语言各出一条 URL,整个目录的 URL 数已经越过 Google 每份 sitemap 50,000 条的上限,一个文档装不下。
### ★★ 分片放在 `/sitemaps/anime/`,因为 `app/anime/` 会吃掉整个 SEO 面
两条约束决定了文件的位置:
- **不能建 `app/anime/`。** 它会是 `app/[lang]/` 的**静态兄弟**,而静态段优先 —— 于是 `/anime/{id}`,站点全部的 SEO 面,会开始解析到一个没有页面能服务它的段上。
- **`/sitemap.xml` 必须保住自己的 URL。** 给根部 `app/sitemap.ts` 加 `generateSitemaps` 会把整个文档搬到 `/sitemap/0.xml`,等于退掉 Search Console 手上那个唯一的 sitemap 地址。
没有索引文档:Next 的 sitemap 约定只发得出 `<urlset>`,发不出 `<sitemapindex>`。robots.txt 接受任意多条 `Sitemap:` 指令而且 Google 全都读,所以在 robots.txt 里逐个点名就是分片被发现的方式。
那些 URL 由 `animeSitemapPath()` 一个函数算出来,robots.txt 和 sitemap 路由都从它取,两边不可能各说各话 —— **robots.txt 指着一个不存在的文件是这层间接存在的理由,而且它是无声的:sitemap 照常工作,只是 Google 永远找不到。**
分片数从常量来而不是从 count 查询来,也是同一类考虑:这段跑在构建时,而构建时的 fetch 是构建时的失效模式 —— 镜像构建期间 Go API 本来就不在(这是常态),一个 count 查询会返回 0 个分片,烤出一个完全没有番剧 sitemap 的站点。常量不会失败。
### 变更 — lastmod 改用行自己的 `updated_at`
上一版给每次 fetch 的每一行都盖 `new Date()`,等于告诉 Google 整个目录一秒前刚全部改过 —— 这是它一爬就能证伪的说法。而文档写明的、对一个不可信 lastmod 的反应是**整站不再相信 lastmod**,所以那笔账还连累着静态页的信号。
`app/sitemap.ts` 补上显式 `revalidate`。它原本每小时重建,只是刚被搬走的那个 fetch 的副作用;不补的话 `currentSeasonPath(new Date())` 会冻结在镜像构建那一刻,把这个函数当初就是为了修掉的那个「季度过期」bug 原样带回来。
### ★ 坏的分片坐标返回 400,不夹取
这和同一个包里 `parseLimit` 的做法正好相反,理由是两者夹取的后果不同:夹一个 limit 只是少返回几行,夹一个分片返回的是**别人的行**,而同一套 sitemap 里出现重复的 `<loc>` 是 Google 记在站点头上、不会报回来的缺陷。`shards=0` 单独覆盖,因为它否则会以 `id % 0` 打到 Postgres。
### 测试
沙箱 e2e 钉住分片 URL,并检查 robots.txt 宣告的每一个 sitemap 都真的解析得出来 —— 这个失效模式没有别的症状。
### 已知未闭合
合并后要去 Search Console 提交这四个分片 URL。Google 靠 robots.txt 一样发现得了,但 Sitemaps 报告只跟踪**提交过**的文档 —— 不提交的话,`/sitemap.xml` 看起来是从 75 条掉到 15 条,而整个目录的那些 URL 不出现在任何报告里。
---
## [3.17.3] - 2026-08-28
设计债计划 E0–E9 的十一项。
### 修复 — 分数徽章的文字和背景是分开选的
**每一部评分 75 以上的番,都在线上渲染成绿字配琥珀底** —— 而且是在全站流量最高的那个页面上。两半隔着一屏:一个阈值梯子决定文字颜色,徽章自己把背景硬编码成琥珀。单看哪一半都不算错,**缺陷只存在于接缝里**,而 `scoreBadge(color)` 这个签名就是把接缝打开的那个东西。6.0 和 5.7 恰好是对的,所以盯着页面看永远看不出来。
同一个阈值梯子一字不差地存在于三个文件里,其中第二个(`player/_components/EpisodeFileList.tsx`)也是错的;`components/anime/AnimeCard.tsx` 是对的,因为它的背景本来就是中性遮罩。
修法不是「把背景也做成一个函数」:而是**没有任何东西能再只要一半**。`scoreStyle.ts` 导出的样式函数每个都返回完整的一对,并且不导出任何单独取前景色的途径。压在封面图上的那个变体单独出一对(`scoreScrimStyle`)—— 12% 的淡色底在一张可能是任何颜色的封面上没有对比度保证,所以它用不透明遮罩,只让文字带分档。
### ★ token 词汇表:DESIGN.md 定义 31 个,`globals.css` 只声明了 10 个,其中 6 个已经漂移
一个没声明的 token 会让 `var()` 解析成空、整条声明被丢掉,所以失败模式是**一个看起来差不多对的组件** —— 于是每个需要圆角或间距档位的组件都改成硬编码字面量了。
改动前实测:整个 `src/` 里 `--accent` 有 9 处引用,`--bg` 和 `--text` 各 1 处,**调色板其余部分 0 处**。这也正是修正那 6 个漂移值属于安全操作而非视觉改动的理由:它们没有任何一个存在过一次 `var()` 引用。
### 变更 — 移动端首屏往上收,桌面端逐像素不变
390×844 实测,「修前」打的是生产:
| | 修前 | 修后 |
|---|---|---|
| 横幅 | 400px | 150px |
| h1 | 769px | 187px |
| 简介 | 799px | 217px |
| 主 CTA | 1190px | 811px |
桌面端两边完全一致(437 / 484 / 702),**这一条才是「这是一次位移不是一次重设计」的判据**。
### ★★ 周更表那排 tab 不可能有过焦点环
树里有四个不同的焦点环:规范里的蓝、teal、琥珀,以及每部番的封面取样色 —— 最后这个会随轮播换颜色,在某些封面上根本看不见。每一个在自己那个文件里都说得通,所以四个都活过了 review。
而周更表那排 tab 是另一种情况:它的内联样式以 `outline: none` 结尾,**内联声明压过任何样式表规则** —— 也就是说,任何写在别处的 focus CSS 都会被写出来、被 review、被合并,然后被静默忽略。现在它们是真正的 tablist,带 roving tabindex。
### 测试 — 每一道闸门都验过真的会咬
- `globals.test.ts` 解析 DESIGN.md 再和 `globals.css` 比对;
- `focusRing.test.ts` 扫描**每一个** `*.module.css` —— 只测 Button 会漏掉四个环里的三个;
- `scoreStyle.test.ts` 对 0..100 的每一个分数断言背景和前景命名同一档;
- `Button.test.ts` 抓一个没有 class 的 variant —— 那会渲染出 `class="undefined"`。
箭头键移动抽成了纯算术(`tabListNav.ts`)才测得了:仓库没有 DOM 测试库,活在事件处理器里的行为断言不了,索引算术可以。要钉的两件事都是「写错了也不会有人发现」的那种 —— 从 index 0 往回绕不能绕成负数(JS 的 `%` 跟被除数的符号),以及**不该管的键必须返回 null**,一个对所有键都 preventDefault 的 handler 会把 Tab 困在 tablist 里,那比它本来要修的「没有方向键」更糟。
另有 `e2e/specs/sandbox/anime-detail.spec.ts`:顶层的同名 spec 跑的是**已部署**的站点,只能在缺陷上线之后报告它;这一份在 sandbox project 里,是能让 PR 挂掉的那个。不带认证 —— 断言的是搜索访客看到的那个页面。
### 已知未闭合
- DESIGN.md 规定按钮 14px/500、padding `10px 20px`,抽出来的实际是 13px/600、`10px 18px`。这是视觉改动,要单独 review,**故意没做**。
- `page.tsx` 仍有约 1300 行,对着 800 行的上限;76 处内联样式里搬走了 19 处。
---
## [3.17.2] - 2026-08-26
### 变更 — 源站地址不该出现在公开仓库里
`scripts/rotate-mail-key.sh` 当初带着源站地址和 SSH 端口作为「省事」的默认值。这个仓库是公开的,而源站地址是一层纵深防御 —— 一个从当前代码树里 grep 不出来的地址,值得多留一会儿。`VPS_HOST` 和 `VPS_PORT` 现在都必须从环境变量给,没有兜底。
### ★ 这一步没有撤销掉什么
**从被跟踪的文件里删掉一个值,不等于把它从历史里删掉。** 这次提交没有、也不可能改变那一点。
所以诚实的立场是:源站地址应当被当作公开信息,网络层的访问控制得自己扛住全部重量,不靠隐蔽性帮忙 —— 它本来就是照这个前提配的。这次提交只是不再让它更容易,而这件事本身值得做。
---
## [3.17.1] - 2026-08-26
### 新增 — 轮换邮件 API key,而新 key 不离开你的终端
现在装在生产上的那把 key 曾被粘进一份聊天记录。它是 send-only 的,但域名验证之后,这已经足以以我们的域名发信 —— 而这正是一次钓鱼想要的东西,针对的还是一个当前信誉干净的域名。
`scripts/rotate-mail-key.sh` 用 `read -s` 读替换的 key:不回显、不进 shell history、不进任何记录。传给服务器走 **stdin 而不是 argv** —— argv 里的东西,在命令运行的整段时间里对机器上每一个进程的 `ps` 都可见。
### ★ 顺序反了会无声失败
先建新 key、装上、验过,**最后**才吊销旧的。反过来先吊销,会开出一个发信坏掉的窗口 —— 而发信坏掉从外面完全看不见,因为 forgot-password 无论信有没有发出都返回 200(它必须这样,否则响应就等于确认哪些地址注册过)。没有人会发现,直到有读者来抱怨。
所以脚本在把新 key 写到任何地方**之前**,先用它真发一封信,发不出去就拒绝碰生产。另外三道:只改**恰好一行** `SMTP_PASSWORD=`,找不到恰好一行就中止;改之前先备份;重启后打印进程实际选中的发信通道 —— 那里出现 `falling back to Gmail` 就说明新 key 没落地,同时点名可以回滚的那个备份文件。
重启而不是重新部署:变的只有环境变量,重建镜像要花几分钟,白白把那个窗口撑宽。
---
## [3.17.0] - 2026-08-26
### 新增 — 密码重置信发得出去,而这恰恰是没法知道它到没到的原因
密码重置信走 Gmail SMTP,以一个 gmail.com 地址发出。这能发;这也正是我们无法判断它对真正的读者有没有用的原因:**收信方绝大多数是国内邮箱服务商**,而它们对陌生域名的判定最严 —— 偏偏 From 头里那个域名和实际发信的那台机器之间,没有任何 SPF / DKIM / DMARC 关系。
只改 From 头会更糟:无论我们写什么,Gmail 都用 gmail.com 签名,于是这封信变成一个关于自有域名的、背后什么都没有的声明。唯一能让三项检查对齐的安排,是走一个**替我们的域名签名**的中继。所以这次加的是那条路径:`NewRelaySender(host, user, password, from)` 与既有的 `NewSMTPSender(user, appPassword)` 并存。
### ★★ 信封发件人不是 From 头
这两个是不同的字段,被不同的检查读:
- **信封**发件人(SMTP `MAIL FROM`)是 SPF 评估的对象;
- **`From:` 头**是读者看到的那个,也是 DMARC 要求 SPF 或 DKIM 与之**对齐**的那个。
在 Gmail 上两者都塌到账号地址上,所以这个区别根本看不见 —— 直到换成中继才分开。而中继的 AUTH 用户名经常压根不是一个地址:**Resend 的就是字面量 `"resend"`**。照 Gmail 那条路复用它当信封发件人,`MAIL FROM` 里就是垃圾,信在上面这一切生效之前就被拒了。现在它们是 `SMTPSender` 上两个独立字段,并有测试断言信封里带的是 `MAIL_FROM` 而不是 AUTH 用户名。
这个失败本来会是无声的:`SendPasswordReset` 的错误被 forgot-password handler **故意**吞掉(否则响应就等于确认哪些地址注册过),所以无论有没有真发出去,读者都看到 200。
### 变更 — 中继优先,Gmail 兜底
`SMTP_HOST` / `SMTP_USER` / `SMTP_PASSWORD` + `MAIL_FROM` 优先;`GMAIL_USER` + `GMAIL_APP_PASSWORD` 留作兜底。一台还没拿到中继凭据的机器会继续发信,而不是悄悄变哑 —— 正因为 handler 那个 200 会盖住这个差别,这个顺序才重要。启动日志打出选了哪条路,走兜底时明说这意味着信和自有域名对不齐。
`SMTP_HOST` 校验必须是 `host:port`(裸主机名是最可能的手误,否则要到 `PlainAuth` 里才炸,且错误信息既不提变量也不提值),`MAIL_FROM` 校验必须含 `@`(它原样落进 `MAIL FROM`)。中继密码**不做**空白字符剥离 —— 那个剥离是为 Gmail App Password 的四段显示形式存在的,对一个不透明的 API key 做同样的事会静默改坏它。
DNS 和服务商都还没配;`SMTP_*` 不设时,这次构建的行为和之前完全一样。
### ★ 一项检查没能跑,就不许判通过
新增 `scripts/check-mail-dns.sh`。这里每一种会中断「已认证投递」的失败都是无声的:**两条 SPF 记录是 permerror,而接收方把 permerror 当作完全没有 SPF** —— 所以为了「加」一个发信方而多加一条记录,等于把所有发信方的认证一起删了。DKIM selector 从来没发布成功、加发信记录时把 MX 行覆盖掉,也都不报任何错,信只是对那些会验的接收方停止到达,而那正是我们绝大多数读者。
**第一版脚本在自己身上把这个道理演示了一遍**:它用了 `mapfile`,macOS 的 bash 3.2 没有这个内建,于是 SPF 那一整节根本没跑 —— 脚本打印「没有错误」并 exit 0。`set -u` 对未设变量只往 stderr 报一句,不退出,所以没有任何东西拦住它。
重写成不用数组,并且结论改成:**一项检查没能被评估时拒绝判通过**,而不是把「没有」当成「通过」。它对当前状态的报告是:恰好一条 SPF 记录、Cloudflare Email Routing 的 include 还在、而且只有那一个 include —— 也就是说,现在还没有任何发信服务被授权以这个域名发信。
---
## [3.16.2] - 2026-08-25
### 修复 — 那个为了拿掉 AniList 依赖而加的 fixture,没拿掉 AniList 依赖
`locale-routing` 断言 `/anime/21`、`/en/anime/21`、`/zh-Hant/anime/21` 能渲染出来。为了让它不再依赖一次实时的 AniList 请求,此前给它加了 fixture —— 用的是 `ensureAnimeCached`,而那一行**没有 studios、没有 characters**,`isStale`(`go-api/internal/anime/detail.go`)对这两者中任意一个都会独立触发,和 `cached_at` 无关。一行 stale 的数据照样把 handler 送去上游。
于是合并闸门的关键路径上一直站着一个限流的第三方,而且它一直在说话:同一套件当天跑了 7 次,同样的 6 条 spec 一起挂了 4 次 —— main 上挂,topic 分支上也挂,两个并发跑在不同 runner 上还互相不一致。**跑没跑绿从来没跟着代码走。**
`ensureAnimeDetail` 才是做这件事的那个 helper,它自己的 docstring 早就这么写了。
### ★ 预热循环加的是 `/anime/0`,不是那个 fixture 用的 21
预热列表里没有任何一条路由够得到 `[lang]/anime/[id]` 段,而 Turbopack 是在一个段被首次请求时才编译它 —— 所以整个套件里第一个 `/anime/<id>` 要在**某条 spec 自己的超时里**付这笔编译,还是在三个 worker 的负载下付。
id 取 0 是故意的:`generateMetadata` 和页面都在 `anilistId <= 0` 上提前返回、不调 `loadDetail`,所以 0 编译了路由却一次 API 都不发。这里拿真 id 预热反而有害:这一步跑在 globalSetup 种任何数据**之前**,而 `loadDetail` 带着 `revalidate: 60` 取数 —— Next 会在那行数据已经存在之后,继续把这个 404 回放一分钟。
### ★ 200 行日志够不到失败
失败日志的 tail 从 200 行改到 4000 行。go-api 一直在打后台富化日志,等到收集证据那一步跑起来的时候,最后 200 行**全是套件结束之后产生的** —— 真正失败的那些请求早就滚出去了。诊断这件事的过程中,有两个错误结论就是从这个截断里得出来的。
### ★★ 让 `ensureAnimeDetail` 自己验证后置条件
它现在检查自己名字所承诺的那件事:种完之后行仍然读作 stale,就抛错并**点名是哪个 fixture**。
没有这道守卫的话,症状是「expected 200, received 404」出现在一条根本没提到任何 fixture 的 spec 里,每一个假设都要花一次 CI 往返去验。这不是假想 —— 这件事已经被用错的 helper「修」过一次,并且关掉了。
---
## [3.16.1] - 2026-08-25
### 性能 — 导入本地文件不再每个文件排 800 毫秒的队
`MatchCombined`(上游 `POST /api/v2/match`)是 dandanplay 客户端里**唯一没有缓存**的对外方法。导入时每个 cluster 调一次,`matchUnmappedFiles` 还会按未映射的集数再调一次。而每次调用都要从一个**进程级**令牌桶取票 —— `rate.NewLimiter(rate.Every(800ms), 1)`,burst 1,全站所有用户共用这一个桶。
现在给它加 24 小时结果缓存(未命中 10 分钟)。
### ★ 缓存放在客户端方法层,不是 handler 层
最顺手的做法是缓存整个 `/api/dandanplay/match` 响应,而那是错的。那个信封里带着 `siteAnime` 和 Phase 2 的 `anime` 块,两者每次调用都从 `anime_cache` 现读 —— 这正是管理员改一次绑定、下一个请求就生效的原因。缓存整个响应等于给一个今天完全没有滞后的面加上 TTL;更糟的是 **Phase 2 每次调用重新给候选排名**,缓存条目可能在数据库已经改对之后还一直返回**错的那部番**。
放在 `MatchCombined` 上,所有 Postgres 派生的字段和今天一样新鲜,被缓存的只有 dandanplay 自己对「这个哈希是哪一集」的回答。
### ★ 未命中也必须缓存
浏览器在每次库挂载、每次切回标签页、每次重扫都会重发一模一样的请求,而且不会因为上次没匹配上就停。不缓存未命中,这笔账就每次重付一遍,价格是每个文件 800 毫秒,且由全站用户共担。
但未命中和命中不是同一种事实:未命中通常意味着「dandanplay 还没索引这个哈希」—— Phase 1 的宽松匹配闸门就是为这个存在的。冻结 24 小时会把「还没索引」变成「今天都别想」。所以未命中 10 分钟、命中 24 小时,10 分钟是照客户端自己的重试节奏挑的(重扫节流 60 秒、周期兜底 120 秒、延迟重试 65 秒)。
**错误从不缓存。** 传输失败或重试链耗尽说明不了这个文件的任何事,下一个调用者必须能重问。
### ★ 首次导入也快一倍 —— 但只因为那句 Wait()
库导入不带 keyword,Phase 2 根本进不去,于是 Phase 1 和 Phase 3 在同一个请求里相隔几微秒问同一个问题。ristretto 的写是异步的:**不加 `Wait()` 实测 5 次里 5 次第二问都跑赢了后台落盘、照样走网络**(加了之后 8/8 只问一次)。落盘只发生在刚花掉一张 800 毫秒的票加一次往返之后,代价可以忽略,而它把一个匹配不上的文件的首次导入成本直接砍半。
### ★★ MaxCost 不是条数上限 —— 这个缓存差点只有名义容量的 1/57
`internal/cache` 的注释写着「cost=1 时 MaxCost 就是最大条数」。**这句话是错的**,而我照着它把新缓存配成了 `MaxCost: 2e5`。
ristretto 在把条目交给准入策略之前会加上自己的开销:`i.Cost += itemSize`,其中 `itemSize = unsafe.Sizeof(storeItem[any]{})` = 56 字节。所以 cost=1、MaxCost=N 的缓存实际只装 **N/57** 条 —— 这里是约 3,500 条,不是 200,000 条。
而它**静默失败**:`Set` 返回 true,`Wait()` 返回,`Get` 照样落空。没有报错、没有日志、没有指标,唯一症状就是本该命中的文件又去了一趟网络 —— 正是这个缓存要消灭的东西。
修法是给 `cache.Config` 加 `IgnoreInternalCost`(默认 false,对现有调用方是 no-op),只在这个缓存上打开,并把那段错误的包注释改对。陷阱本身钉进了 `cache_test.go`:同样 `MaxCost=1000` 写 900 个不同的键,默认配置只活下来 **17** 条,打开开关活下来 **900** 条。
### ★ 缓存键:`fileHash` 是攻击者可控的
`/match` 直接解 JSON body,没有任何东西保证 `fileHash` 真是它该有的 32 位十六进制。朴素拼接下,这两个请求会算出同一个键 `a:1:b:2:c`:
```
{fileHash:"a", fileSize:1, fileName:"b:2:c"}
{fileHash:"a:1:b", fileSize:2, fileName:"c"}
```
于是先问的那个会替另一个回答。改成给哈希加长度前缀之后编码是单射的。
键还有一条:它由**将要发上线的请求体**算出,不是由 `MatchCombined` 的入参算出。请求体是归一化过的(hash 为空、size 非正时都会被丢掉),所以同一个文件从库导入(`rep.file?.size ?? 0`)和从播放器(`f._fileRef?.size ?? 0`)进来不会占两个格子。
### 测试
新增 8 条,全部通过变异验证 —— 8 种变异 8 杀:不缓存未命中、把错误也缓存、命中存成 `found=false`、键朴素拼接、键取入参而非请求体、去掉 `Wait()`、去掉 `IgnoreInternalCost`、把未命中当零值命中回放。
`TestMatch_RepeatedRequestAsksDandanplayNothing` 走真 handler 而不是直接调方法:要守的是「一个请求扇出到三个 phase 之后总共只问一次上游,重发则一次都不问」,不是「某个方法会记忆」。
第一轮变异里有一个存活,查下来是**等价变异** —— `cache.Get` 按值返回、`value()` 是值接收者,我那行显式拷贝是多余的。删掉冗余代码并把注释里记错功劳的那句改对,比留着一条不区分任何东西的断言强。
---
## [3.16.0] - 2026-08-25
### 修复 — 同名番剧不再各占一张卡
自动归类(`dedupeSeriesByAnimeId`)按 `Season.animeId` 分组。#105 删掉了往那个字段写值的兜底 —— 它存的是 bgm.tv subject id 塞在 dandanplay 形状的列里,`/api/dandanplay/match` 三个 phase 都不返回 dandanplay animeId,所以 `?? merged.bgmId` 不是兜底、是**唯一会触发的分支**。删是对的,但这一趟从此**没有键可分组**:自动导入的番根本没有 `Season.animeId`,两张同一部番的卡片再也碰不到一起。那个文件的头注释自己写了这件事,没人跟进。
现在按 `Series.anilistId` 分组 —— 而它可靠可用同样是 #105 给的(绑定扫描会给没人点过的番写上)。它是站点自己的主键,两个本地行共用一个就是「同一部番记了两遍」的定义。
### ★ 两个 id 空间不能共用一个 Map
`Season.animeId`(dandanplay 空间)仍有活人口(手动 rematch 走 dandanplay 搜索会写真值),所以有值时仍可用作键。但两个空间**会撞**:id `806` 在两边都是有效番剧、两个接口都返回 200。放进同一个数字 Map 会以满分信心合并两部无关的番 —— 正是 #105 的品牌类型在下面一层要防的那个碰撞。
所以键是**带命名空间的字符串**,不是数字:`anilist:806` 和 `dandan:806` 撞不了,因为它们不是同一个字符串。
### ★ 自动化的前提:手动拆分必须活下来
改成进库时自动跑,而不再只挂在工具栏按钮上 —— 看着两张一模一样的卡片的人,没理由猜到某个菜单项才是合并它们的东西。
自动化只因为两件事才安全:
- **`wasDeliberatelySplit` 守住拆分。** `splitSeries` 把血缘记在**新行**的 `splitFrom` 上,是单向的,所以要两边都查。没有这个守卫的话,自动动作会在每次访问撤销一次手动动作,而用户没有任何办法让它停 —— 那比不自动化更糟。
- **合并是软合并且留痕。** 不移动任何 Season / Episode / Progress 行,`performMerge` 追加一条 opsLog,而且弹的是和手动按钮**同一个撤销提示**。
它排在三个 sweep 的最后,好让绑定扫描当次写入的 anilistId 对它可见。这是靠声明顺序而非保证 —— 本次绑定、下次才合并只是显示上晚一拍,不是正确性问题,为此把两个 sweep 耦合起来不划算。
### 测试
`dedupeSeries.ts` 此前**零测试**。新增 13 条,四种变异全部验证:两个空间共用数字键(挂 5)、不守拆分(挂 2)、拆分只查单向(挂 1)、`Season.animeId` 优先于 anilistId(挂 1)。
`e2e/specs/sandbox/library-dedupe.spec.ts` 两例 —— 单测看不见「进库时到底跑不跑」(effect 不在 `renderToStaticMarkup` 里执行)。断言的是 `userOverride.mergedFrom` 这条软合并唯一的痕迹,而不是卡片数量,这样它不依赖网格怎么隐藏被合走的行。第二例**不用轮询**:等过合并本该发生的窗口再断言它没发生 —— 轮询「仍未合并」会瞬间通过、什么都证明不了。
配套 `dexie-seed.ts` 加 `splitFrom` 种子(**单向记录,照实现的样子**,两边都记会掩盖只查单向的守卫)和 `readSeriesRow` 的表名参数。
---
## [3.15.2] - 2026-08-25
### 修复 — 偏移量写进去了,但对账已经推完并挨了 400
3.15.1 让已绑定的番在进库时拿到偏移量。用户装上后 400 **仍然在**。
原因不在数据,在**顺序**:`reconcileLibrary` 被 `libraryReconciledRef` 按挂载锁死,而且它跑在偏移回填**完成之前**。所以最关键的那一次访问 —— 修复上线后的第一次 —— 推的仍是未翻译的集号、照样 400,然后被锁死、本次挂载再也不会带着刚写好的偏移重试。**用户在本该修好的那一次访问里,看到的正是他上报的那个错误。**
#105 给绑定扫描建过这个机制(扫完 `libraryReconciledRef.current = false; setReconcileEpoch(n => n + 1)`),偏移回填漏了同一步。现在补上,并带同样的一次性守卫 —— 回填自己的写入会改 `allSeries`,而那正是这个 effect 的依赖,不守就会变成推送风暴。
### 测试 — 这个顺序只能从外面数请求
新增 `e2e/specs/sandbox/episode-offset-sync.spec.ts`。断言的是**服务端的 `currentEpisode`**,不是界面上的任何东西:`= 10` 只可能发生在「数字在上线前被翻译过」的情况下,38 会被拒;而「界面显示 10、线上发 38」正是共享规则要防的那种无声分歧。
三件事要同时成立才算修好(前两件 3.15.0/3.15.1 已有,第三件是这次):偏移可推导、已绑定的番拿得到、**对账在回填之后跑**。第三件活在 React effect 里,而这个仓库的组件测试用 `renderToStaticMarkup` 根本不跑 effect —— 和 #105 那个 re-arm 一样,只能从浏览器外面数 `PUT` 请求。
第二例钉住反向:第一季的偏移是**确信的 0**,0 必须什么都不移。这一条也是接口返回 `{known, offset}` 而非可空数字的理由 —— 若把「没有前作」当成「未知」它照样过,若把 0 当位移应用它就挂。
配套给 `e2e/fixtures/pg.ts` 加了 `seedPrequelRelation` 和 `format` 字段(**走查时的坑**:不种 `format` 的话链上一集都不计,会以满分信心报出偏移 0)。
---
## [3.15.1] - 2026-08-25
### 修复 — 3.15.0 那个修复够不到任何已经绑定的番
3.15.0 让集号偏移在**解析绑定**时取到。而 `resolveSeriesBinding` 对**已经绑定**的 series 会提前返回 —— 我在那行的注释里写了这个天花板,但没跟进它的后果:一个上报这个 bug 的人,他的番按定义早就绑好了,所以修复对他一点作用都没有。
和 #105 那次是同一个形状的错:逻辑是对的,触发点够不到存量数据。
新增 `episodeOffsetBackfill.ts` —— 进库时的一次性批量回填,和已有的 `episodeCountBackfill` 同一个触发点、同一批行、同一个 cap。**故意是两个 sweep 不是一个**:候选集不同(一个 series 可能有总集数没偏移,或者反过来),合成一遍会让已经答过的那一半被反复重问。
配套 `GET /api/anime/episode-offsets?ids=...`(批量)。单条接口服务不了这个 sweep:200 部的库会打 200 次请求,而 per-IP 是 1 req/s —— `/api/anime/episodes` 早就为集数解决过同一个问题,所以照抄它而不是另发明一套。
### ★ 判定条件里那个容易写错的地方
「已经有偏移了」是 `typeof x === "number"`,**不是 `x > 0`**。
`0` 是真实答案(「本季之前什么都没有」),而且是目录里大多数番的答案。用正数判定会让每一部独立作永远留在候选集里,sweep 每次重发同一批 id、永远不收敛 —— 服务端的 0015 和 0023 各踩过一次这个坑,这次是它从客户端这道门进来。
### 测试
新增 12 条(判定条件、`known:false` 锁存、**服务端没返回的 id 也要锁存**、请求失败**不能**锁存、一个 id 对应多个本地行、写失败不拖垮整个 sweep)。三种变异全部验证:用 `>0` 判定挂 3 条、请求失败也锁存挂 1 条、没返回的 id 不锁存挂 1 条。
集成测试补两例批量形态:**递归里多带的 `root` 列 + `DISTINCT ON` 会不会张冠李戴**(同一个系列的三季一次查,错配会看起来特别合理),以及未缓存的 id 是「不返回行」而不是「返回一行说未知」。
---
## [3.15.0] - 2026-08-25
### 修复 — 跨季连续编号的剧集绑错格子,而且进度从来没同步上去过
上报的现象:库里《葬送的芙莉莲 第二季》只有一集(第二季最后一集),却显示在第 1 格。
集号是本地文件名解析的(`recordFactory.js` 的 `number: item.episode ?? 0`),解析器读到的是 **38** —— 第一季 28 集 + 本季第 10 集,发布组按跨季连续编号命名。落库就是 38,没错。错在下游两处,而它们缺的是**同一个事实**:
- **显示** `episodeGridModel.normalizeEpisodeNumbers` 推导位移用的是 `shift = lowest - 1`,也就是假设「你手上最小的那一集就是本季第 1 集」。只有一集时 `lowest = 38`,位移 37,38 − 37 = 1。
- **同步** 追番推送发的是原始的 38,而 `subscriptions/validate.go` 按 `anime_cache.episodes`(10)判超界,**每次都 400**。这部番的进度从上架那天起就没同步成功过,而且没有任何提示。
实测这个推断只在「手上有本季第一集」时成立:
```
本地 [38] 总集数 10 → 修前 1 修后 10
本地 [36,37,38] 总集数 10 → 修前 1,2,3 修后 8,9,10
本地 [29..38] 总集数 10 → 两边都 1..10 ← 这才是它本来对的场景
```
### 新增 — `GET /api/anime/{anilistId}/episode-offset`
沿 `PREQUEL` 边递归求和,得出「本季之前有多少集」。
**没有新建表也没有新迁移。** `anime_relations` 从 0001 起就在存关系边,由 AniList 详情查询里已有的 `relations` 块填充 —— 中途曾写了一个存 `prequel_anilist_id` 的迁移 0025,发现重复后删掉了。递归 CTE 直接读现成数据。
生产实测:
```
154587 葬送的芙莉莲 known=t offset=0
182255 葬送的芙莉莲 第二季 known=t offset=28 ← 38−28=10
209939 (第三季) known=t offset=38 两跳 28+10
```
**返回 `{known, offset}` 两个字段而不是一个可空数字。** 「前面没有任何一季」和「查不出来」是相反的答案,`offset ?? 0` 一行就能把后者变成前者 —— 而那正是「拿一个没人确认过的原点去重排用户文件」。拆成两个字段,调用方必须先读 `known` 才能拿到数字。
`known=false` 是 200,不是 404 也不是 500:约四分之一的目录没有关系行(17560 部中 13378 部有,76%),这是**常态答案**;用状态码区分「未知」和「请求失败」会让客户端对一个稳定答案无限重试。四种截断都归为未知:前作不在缓存(生产 87 条)、有两个 PREQUEL(生产 143 部,是歧义不是相加)、深度上限(防环)、TV 前作缺集数(`coalesce(...,0)` 会给出一个看起来合理的错数,比没有答案更糟)。
### 一条规则,两个消费者
显示层和同步层必须做**同一个判定**,否则会出现「界面显示第 10 集、推送发第 38 集」而两边都不报错。所以规则只写一份,放在 `lib/library/episodeOffset.ts`:
- `offsetApplies` —— 服务端知道番剧谱系,只有客户端知道盘上有什么,而 `format` 只是「是否参与连续编号」的粗略代理。所以偏移量**要先拿证据验证**:每一集减去它都必须落在 `[1, 总集数]` 内。
- `toSiteEpisodes` —— **全有或全无**。只翻译「恰好合适」的那几集,会把两个编号空间塞进同一个订阅,而且会让网格和推送对同一个文件产生分歧。
同步侧的三个承重细节:`PushPlanned` 同时带 `episodes`(本地号,写记忆)和 `siteEpisodes`(站点号,发线上)—— 让「哪个数组用在哪」由类型说了算,而不是靠调用点的纪律;`lastSyncedEpisodes` 语义不变(仍是「本设备推过的**本地**集号」),记成站点号会让 `local \ pushed` 永远减不干净、同一集反复重推;偏移量拿**整个已看集合**验证而不是拿增量 —— 平常增量只有一集,而单个 38 既符合偏移 28 也符合 37。
### 测试
新增 8 条显示层 + 6 条同步层。四种变异全部验证过:不翻译(挂 3)、拿增量验证(挂 1)、记忆记站点号(挂 1)、包含性断言比错空间(挂 1)。
★ **第一轮变异有两条没被抓到**,说明当时的注释在断言没有测试守着的性质 —— 补了一条「增量能过、整体过不了」的判别用例(`[1,2,38]`,偏移 28)和两条端到端推送用例才补齐。
另有 `test/integration/prequel_offset_test.go`(8 例:真实谱系两跳、锚点自身不计入、未缓存前作、双前作歧义、TV 前作空集数、链中电影只穿不计、成环、锚点不在缓存)。本地 Docker 未起,**没在本地跑过**,靠 CI 的 `-tags=integration` 闸门验证。
### 已知未闭合
没有关系行的那约四分之一目录仍走旧推断,因此「只有本季后几集」的场景在那部分番上仍会错位。修法需要那些番的详情被拉取过一次,属数据覆盖问题不是逻辑问题。
---
## [3.14.1] - 2026-08-25
### 修复 — 侧车 `.srt` 转出来了,却没送到播放器
3.13.2 让侧车字幕转成 WebVTT 之后,`.ass` 正常显示而 `.srt` 仍然一条都没有。线上插桩三次全同:文件读了、`convertSrtToVtt` 跑了、74 字节的 `text/vtt` blob 造出来了,而 `<track>` 的 `src` 是空的。**转换是对的,交接掉了。**
`VideoPlayer` 里接 VTT 的 effect 读 `artRef.current`,依赖却只有 `[subtitleUrl]`:
```ts
if (!art || !subtitleUrl) return;
}, [subtitleUrl]);
```
ref 从 null 变成实例**不会**重跑 effect,而 `subtitleUrl` 之后不再变 —— 所以侧车字幕只要早于 artplayer 建成一步,这次早退就是永久早退,整个 VTT 层静默消失。`.ass` 文件更大、转换更慢,落地够晚所以赢了这个竞态;`.srt` 更小更快,输了。旁边的 jassub effect 依赖里本来就带 `playerReady`,从来没这问题。现在这条也带上。
### 测试 — 补 e2e,并且写明它盖不住什么
新增 `e2e/specs/sandbox/subtitle-sidecar.spec.ts`(`.srt` / `.ass` / `.vtt` 三例):断言 cue 数 > 0、track 文档以 `WEBVTT` 开头、`{\an8}` 没有活下来。这条守的是 3.13.2 那个洞(侧车原样交出去 → 浏览器整篇拒绝 → 零字幕),**不是**上面那个竞态。
不是没想到,是量过了:本地生产构建带修复与不带修复各跑一遍,**三例全绿,两次一样**。localhost 解析侧车太快,artplayer 永远已经就位,这个竞态在本地不复现。判据只在生产延迟下成立。spec 头注释里写着这件事,免得后来人把绿灯当成有覆盖。
配套加了 `e2e/fixtures/probe4s.webm`(真 VP8 片段)。原来的 `black1s.mp4` 是 144 字节的空壳,`loadedmetadata` 永不触发,浏览器因此**推迟加载 `<track>`** —— 拿它当底,任何字幕断言都恒读 0,跟代码对不对无关。
---
## [3.14.0] - 2026-08-25
### 修复 — 库里看过的番,首页「正在追」终于跟着动
追番进度同步这条管线本身是好的:103 个单元测试、三个触发点齐全、服务端读写路径都验证过。问题是**它的入口条件在真实库上基本不成立**。
对一个 26 部番的真实库逐条比对本地 Dexie、站点订阅和站点已看集合:
```
❌ 未绑定(Series.anilistId 为空) 19 73% ← 从来没进过推送流程
🔴 集号超界(本地 13,14 vs 站点 12 集) 1 4%
— 已绑定但本地无已看集数 5 19%
✅ 已同步 1 4%
```
**73% 的卡片没有 `anilistId`。** `reconcileLibrary` 对未绑定的 series 直接 skip,所以它们从未被推送过,也从未产生过任何错误信号。
**不是匹配算法的问题。** 把那 19 个标题喂给生产环境的匹配器(直接 import `seasonMatch.ts`,不做重新实现)对真实搜索端点跑一遍:18 个命中,几乎全是 score=100 的折叠标题完全相等。唯一失败的那个,标题被解析成了字幕组名。**解析从来没被调用过而已。**
原因写在 `resolveSeriesBinding.ts` 自己的注释里:绑定只在点卡片、进单番页、进播放器这三个用户动作上发生,因为「几百个未绑定 series 会变成几百个并发搜索」。这个论证是对的,结论选错了 —— 不该是「不做」,该是**「有界地做」**。
**改动**
- **有界扫描**(`bindUnboundSeries.ts`):进库时对未绑定的 series 顺序解析,每次挂载封顶 40 个、间隔 350ms、失败锁存。形状照抄已经跑了很久的 `episodeCountBackfill.ts` —— 候选集从行本身推导,绑上的自动退出,所以它自己的写入触发的 liveQuery 不会变成死循环。
- **扫描完 re-arm 对账**:`reconcileLibrary` 由 `libraryReconciledRef` 锁死整个挂载期。不显式重开一次,这次新绑的番一部都不会同步 —— 用户开库看到的是「什么都没发生」。
- **失败第一时间可见**:上报阈值从「第 3 次确定性失败」改成第 1 次。原来的阈值挂在一个刷新即清零的模块级 Map 上,要在同一个不间断会话里连撞 3 次才弹提示;正常的观看节奏(看一集、关页、隔天再看)每次只累积 1–2 次,**永远够不到**。重试上限仍是 3,两个阈值现在是分开的:`REPORT_AT_ATTEMPT` 决定何时告诉用户,`MAX_PUSH_ATTEMPTS` 决定何时停止重试。
- **自动绑定留痕**:每条写一行 `opsLog`(`kind: 'rematch'`),详情页的「最近操作」可见。**故意不给撤销按钮** —— `opsLog` 的 kind 白名单里虽然有 `rematch`,但唯一实现的撤销是 `undoMerge`,它对其它 kind 直接抛异常。给一个按下去会抛的按钮比不给更糟。
### 修复 — `Season.animeId` 装的一直不是 dandanplay 的 id
`/api/dandanplay/match` **三个 phase 一个都不返回 dandanplay animeId**(phase1 只有 titleNative + coverImageUrl,phase2 只有 anilistId 和标题,phase3 是字面 `{}`),尽管 phase1 手里就攥着正确的值。所以客户端那条 `merged.dandanAnimeId ?? merged.animeId ?? merged.bgmId ?? 0` 的前两项永远不满足,`?? merged.bgmId` 不是罕见兜底而是唯一会触发的分支。
实测那 26 个值打 `/api/dandanplay/episodes/{id}` **全部 404** —— 它们是 bgm.tv 的 subject id。而两个 id 空间会撞,且两种问法都返回 200:`806` 当 dandanplay id 是《以柔克刚》,当 bgm id 是《铁臂阿童木》。数值范围也分不开(dandanplay 跨 806–36740,bgm 跨 1269–638497)。
**只删兜底,让新写入留空**,服务端暂不补发字段。因为空值(falsy)会重新激活 `importPipeline.js` 的结构性归位守卫,而「另一个空间的有效值」恰好会关掉它 —— 配上 `matchCache` 的 7 天 TTL,周更番第 8 天重扫就会长出第二张卡片。存量数据的翻译和服务端补发是另一件事。
**连带修好一个耦合**:`applyEnrichment` 里,Season 记录(身份)和标题/封面(元数据)本来共用同一个真值判断。id 变空之后元数据会跟着一起被跳过,新导入的番就留着解析出来的原始名。两者现在分开了:没有可用 id 就不写 Season 行(写成 0 更糟 —— `dedupeSeries` 按 `typeof animeId !== 'number'` 判,0 会当成真 id 把所有无 id 的番折叠成一张卡),但富化照常应用。
### 变更 — 两个 id 空间现在混不了,混了编译不过
同一个 bug 在这个仓库出现过两次(手动 rematch 一次、自动导入一次),两次分别修,**没有共用代码** —— 那正是会漂回去的形状。现在抽成 `animeIds.ts`:`DandanAnimeId` / `AnilistId` / `BgmSubjectId` 三个品牌类型,每个一个归一化函数。
把两个历史 bug 原样种回去,编译器直接拦:
```
dandanClient.ts: Type 'DandanAnimeId | 0 | BgmSubjectId' is not assignable to type '0 | DandanAnimeId'.
Type '"bgm"' is not assignable to type '"dandanplay"'.
rematchPayload.ts: Type 'DandanAnimeId | AnilistId' is not assignable to type 'DandanAnimeId'.
Type '"anilist"' is not assignable to type '"dandanplay"'.
```
两个细节是承重的:**`match()` 里那个 `as DandanMatchResult` 断言被删掉了** —— 品牌数字之间的**断言不报错、赋值才报错**,那个 cast 一直在拆掉这道闸门;以及 **`merged.bgmId` 故意留着**并加了类型 —— 它没有任何读取方,但它正是当初污染 `Season.animeId` 的那个数,留着当绊线。
残留的洞照实说:软品牌会被 `Number(...)` 抹掉,所以洗一道再赋值仍然编译得过。那一半是靠行为测试守的(喂真实信封进去,断言外来 id 永远不落地),不是靠类型。
### 变更 — 导入时就绑定,以及两处补上的审计痕迹
- **导入时绑定**:`siteAnime.anilistId` 在两个产出 siteAnime 的 phase 里都有,`mergeAnimeFields` 早就取了,`pickEnrichment` 把它丢了。现在它作为**第三个事实**独立传递(不折进 enrichment —— 折进去会让绑定取决于「刚好有没有封面」),经 `writeBinding` 落地。**覆盖面照实说**:只覆盖「首次导入 + 缓存冷 + 有 siteAnime + 没落到已有 series」这一种,`reuse`、缓存命中、结构性归位、ambiguous 全都绕过它。
- **`rematchSeries` 补写 opsLog**:`'rematch'` 这个 kind 从 v4 起就在白名单里,**从来没有任何东西写过**。payload 记两个 id 空间各自的 from/to,`from` 在**事务内、写入之前**捕获(晚一拍读,每行都会变成 `from === to`);未触碰的空间记 `null` 而不是 `{from: null}`,因为后者等于断言「那一季没有 id」,而真相是「我们没看」。
- **接住服务端回传的写后集合**:`PUT .../episodes` 一直返回完整的写后集合,客户端把 body 丢了 —— 字节已经过线,白扔。现在断言**包含**(服务端是 UNION 不是替换,用相等会天天误报)。不匹配只 `console.warn`:这是 200,走 `recordFailure` 要么完全没痕迹、要么烧掉重试预算并在第一次就弹「这部番没在同步」冤枉一个健康的 series。
### 明确不做的
- **集号重映射**。实测样本里 1/26。`episode_watches` 只增,唯一删除路径是逐集取消或退订级联 —— 服务端改写一旦写错就不可逆、不可检测、无运维工具清理;而它比对的总集数是富化任务写的活动目标,现有注释接受竞态的理由正是「最多多一个 400,客户端会重试过去」。加了改写之后那变成永久错写。改成让它在第一次失败时就可见,配现有的手动重新匹配。
- **服务端 → 本地的反向进度同步**。站点上标记的已看不回流到本地库。另一个特性。
## [3.13.2] - 2026-08-25
### 修复 — 字幕里露出 `{\an8}`
`{\an8}` 是 ASS 的对齐覆盖标签,本该让那一行挪到画面顶部、标签本身消失。它被当成文本直接渲染了出来。
**同一条规则,四个站点,只有两个执行了:**
| 站点 | 剥标签 | |
|---|---|---|
| `convertAssToVtt` | ✅ | 手动选 `.ass` |
| `convertSrtToVtt` | ❌ | 手动选 `.srt` |
| MKV 的 ASS 轨 → VTT 兜底 | ✅ | 自己抄了一份正则 |
| **MKV 的 SRT 轨 → VTT** | ❌ | ← 上报的这个 |
SRT 那条是用户会撞上的。**一个字段同时关掉了两条纠正路径**:worker 那条分支返回 `type: 'vtt'`,而 `VideoPlayer` 的 jassub 门是 `subtitleType === "ass"` —— libass 不挂载;artplayer 5.4 按 `t.type || V(t.url)` 分发,标成 `"vtt"` 就跳过它**自带的、会剥标签的** SRT 读取器。下游没人接得住。
规则现在只有一份,四个站点都调它。写的时候顺出两件事:
- **正则从 `\{[^}]*\}` 改成 `\{[^}\r\n]*\}`。** 现在是整篇文档应用而非逐条,而 ASS 覆盖块从不跨行。用宽松版的话,某条字幕里一个没闭合的 `{` 加几条之后一个落单的 `}` 会匹配掉中间所有东西、吃掉时间轴 —— 把「看得见的标签」变成「解析器整篇拒绝」,那更糟。
- **两个 MKV 构建器从 worker 搬进了 `subtitleConvert`。** worker 留 EBML 解析(要真 Matroska 字节才能测),搬走的是纯字符串处理 —— 那部分以前**只能靠播片验证**,这正是 SRT 轨带着标签上线好几个月而旁边 ASS 轨没事的原因。worker 净减 49 行。
### 修复 — 视频旁边的 `.srt` / `.ass` 一条字幕都不显示
查上一条时发现的,比它更严重。
`resolveSubtitle` 把侧车字幕当作**原始文件的 blob URL** 直接交出去。而 `VideoPlayer` 三处都写死 `type: "vtt"`,artplayer 收到 `"vtt"` 就原样透传给浏览器 —— 原始 SRT 没有 `WEBVTT` 头,TextTrack 解析器**整篇拒绝**。不是样式难看,是一条都没有。
而侧车匹配的优先级是 `{ ass: 0, ssa: 1, srt: 2, vtt: 3 }` —— **最优先被选中的格式,恰好是最保证渲染不出来的那个**。
现在把不变式写下来并守住:**离开 `resolveSubtitle` 的 url 永远是 WebVTT**。三处 `type: "vtt"` 因此从「碰巧对」变成「构造上就对」。
**侧车 `.ass` 拿到了它从来没有过的东西**:`content` 现在带着原文,jassub 会挂载,文件第一次真正按它自己的排版渲染,而不是退化成明文。这连带挪了本地字体授权提示 —— 它原本以 `.mkv` 为条件,那在「只有 MKV 会产出 ASS 原文」的时代等价于「jassub 会不会挂」。不动它的话,侧车 `.ass` 会渲染成豆腐块而屏幕上没有任何线索。
`.vtt` 侧车保持同步 —— 它是唯一无事可做的情况,改成异步只会平白插进一帧没有字幕。
顺带两处改名,因为名字已经不成立了:`kind: "mkv"` → `"async"`、`MkvExtractionTask` → `SubtitleTask`(一个侧车 `.srt` 挂在叫 `mkv` 的字段下,正是会把下一个人带偏的那种名字),以及 `mkvBlobUrlRef` / `cleanupMkvBlob` 和 `usePlaybackSession` 头部那两条同名的不变式。
### 测试
新增 34 条,`subtitleConvert` 和 `resolveSubtitle` 此前**零测试**。全部做过变异验证:把剥标签去掉挂 2 条、正则放宽回 `[^}]` 挂 1 条、侧车退回原样透传挂 6 条、ASS 不回传原文挂 4 条、blob 里装原文而非转换结果挂 2 条。
worker 现在 import 一个 `.ts` 模块,这在本仓库没有先例,所以跑了真 `bun run build` 而不是假设。
**还有一条值得记:** 用 `fetch(blobUrl)` 读回内容的写法**单跑绿、全套红** —— bun:test 同进程共享,另一个 spec 装了会计数的全局 `fetch` mock,报错是 `fetch called more times than mocked` 却指向一个和字幕毫无关系的文件。改成在 `URL.createObjectURL` 处直接抓 Blob,不碰任何别人拥有的全局。
---
## [3.13.1] - 2026-08-23
### 修复 — 库和播放器的封面全部消失
3.13.0 把 `FadeImage` 迁到 `next/image` 之后,`/library` 渲染出 26 张空卡片,控制台 115 个 400。播放器的手动搜索、弹幕选集、剧集列表同理。
**原因**:`next.config.ts` 的 `remotePatterns` 只放行 `s4.anilist.co`。而库和播放器的封面来自 dandanplay 的匹配结果 —— `img.dandanplay.net`、bgm 镜像、以及任何未来的富化路径写进 IndexedDB 的东西。这些主机走优化器一律 400,而 `FadeImage` 没有 `onError`,于是什么都不显示。
**修法**:这六处标 `unoptimized`,直接输出原始 `src`,回到改动前的行为。这也是更安全的默认 —— `SeriesCard` 自己的守卫注释就写着这些 IDB 值「may be attacker-influenced」,不该变成由我们服务器发出的请求。
**为什么三道闸门全绿**:`unoptimized` 是可选属性,`remotePatterns` 只在运行时求值。构建、类型检查、lint 都不可能知道某个 `src` 在运行时指向哪个主机。
**为什么上线前没测出来**:几何回归跑了 `/`、`/welcome`、`/anime/[id]`、`/seasonal/*`、`/search`,**唯独没有 `/library` 和 `/player`** —— 那两个页面的内容在浏览器本地,测试用的服务器上是空的。把「测不了」当成了「不用测」。而且那套检查比的是同一张图的几何,对照组里根本没有「图不出现」这种情况。
---
## [3.13.0] - 2026-08-23
### 变更 — 图片走优化器,首页图片体积降八成
AniList 对所有尺寸只给一张图:一张 460×650 的封面,常常是 PNG,常常 370 KB,被塞进 230px 宽的卡片里。全站 46 个图片渲染点里,此前有 45 个是裸 `<img>`,直连 `s4.anilist.co`。
现在它们走 Next 的优化器,按真实渲染宽度输出 AVIF。实测(同一台机器、同一份 Playwright 脚本、只等 load 不滚动):
```
改前 改后
/ 1440 11,295 KB 1,665 KB −85%
/ 375 2,829 KB 557 KB −80%
/anime/* 1440 1,464 KB 195 KB −87%
/anime/* 375 1,464 KB 119 KB −92%
```
详情页那两行尤其值得说:**改之前手机和桌面传输的字节完全一样**(都是 1,464 KB),手机一直在下桌面尺寸的图。
#### 三件反直觉的事
**其一,`quality` 不是 AVIF 的 quality。** Next 传给编码器的是 `quality - 20`,所以默认的 75 落到 AVIF q55 —— 在扁平赛璐璐上色的素材上过了拐点,天空和皮肤开始起色带。要 q65 就得写 85。这个失败模式只表现为「图看着差了点」,永远不报错,所以靠每个调用点记住是不可能的;默认值放进了 `FadeImage` 本身。
**其二,选 AVIF 不是因为它压得小,是因为色度。** 等体积下 AVIF 的色带是 WebP 的 1/3.8、JPEG 的 1/9.4。根因是 sharp 的 AVIF 默认 4:4:4,而 WebP 和 JPEG 默认 4:2:0,把色度分辨率砍半 —— 正好糊掉动画赖以成立的彩色线条边缘。WebP 在朴素对比里的体积优势,是拿色度换来的。
**其三,`sizes` 会把 HTML 撑爆。** 给了它,Next 就改成输出完整的候选清单(15 档、每张约 2 KB 的 srcSet)。首页有 207 张封面,那是约 400 KB 的额外 HTML,在一个目标 180 KB 的页面上。所以网格卡片一律不给 `sizes`,只给 `width`,拿 1x/2x 两档;只有首页轮播和详情页横幅给了 —— 那里总共五张图,而渲染宽度真的从 1440 摆到 217。
实际代价:首页 HTML 未压缩 326 → 354 KB,传输 70 → 84 KB。这是这次唯一变差的指标,换来的是 9.5 MB 的图片。方案里 §4 那条「HTML ≤ 180 KB」的目标属于另一个阶段(精简 DTO / 减少首屏条目),本次没做,而且离得更远了。
#### 一个只在浏览器里才看得见的回归
`width`/`height` 这两个 HTML 属性**不只是元数据,它们是 presentational hint**,也就是真实的 CSS 声明。原先这些封面是没有尺寸属性的裸 `<img>`,高度由 CSS 的 `aspect-ratio` 算出;`next/image` 一加上 `height="240"`,这个 hint 就压过了 `aspect-ratio`,卡片从 155×206 长成 155×240,把底下的时间和评分行挤了出去。
构建过、类型检查过、也没有任何警告。修法是给那七个「只声明了 `width` 和 `aspect-ratio`」的样式对象补上 `height: "auto"`。
#### 类型上的一道闸门
`next/image` 自己的 `ImageProps` 把 `width`/`height` 声明成可选(因为还有 `fill` 模式),于是漏掉两者的调用点**能通过编译、到运行时才抛** —— 而且是在客户端组件里,表现为一整块空白。这个仓库跑的是 `strict: false`,别处也指望不上。`FadeImage` 的 props 因此收成了判别联合:要么给 `width`+`height`,要么给 `fill`+`sizes`。那 16 个漏掉尺寸的调用点是编译器列出来的,不是我翻出来的。
#### 头像不走优化器,这是刻意的
`FallbackImg` 的调用点全是头像,而头像由 go-api 从自己的卷里服务在 `/api/avatars/*`。Next 解析同源路径走的是 `fetchInternalImage` —— 一个在 next-app 进程内部处理的 mock request,而生产环境的 next-app 根本没有 `/api` 路由(`rewrites()` 在 prod 返回 `[]`,那是 nginx 的活)。优化头像会 404 进 `onError`,静默显示成默认卡片。所以它留在裸 `<img>` 上。
#### 本地静态资源
优化器管不到的那批,逐个量过再动:
```
og-default.png 203.3 KB → 62.1 KB 调色板,保留 alpha
card_default/card.jpg 123.3 KB → 41.7 KB 1287² → 704²
apple-touch-icon.png 50.2 KB → 21.0 KB 调色板 + 去 alpha
favicon-192.png 44.7 KB → 18.5 KB 调色板 + 去 alpha
card_default/backdrop.jpg 188.7 KB → 142.3 KB 仅重新编码,尺寸不变
card_default/avatar.jpg 新增 → 2.8 KB 96×96
```
**`og-default.png` 不能拍平成 JPEG。** 实测它 **15.9% 的像素是半透明的**(0% 全透明),拍平等于把这些像素合成到一个我猜的底色上。所以只做调色板量化,alpha 保留;改动后与原图逐像素比较,均值差 1.91/255,肉眼不可辨。两个图标则相反:实测 **100% 不透明**,alpha 通道纯属浪费,直接去掉。
**新增的 `avatar.jpg` 是为了一个优化器解决不了的问题。** 评论楼、在看的人、通知项里那些圆头像**实测全部渲染在 24×24**(详情页上八个),而它们一直在用会员通行证卡面那张图 —— 那张图的尺寸是按卡面 252×352 在 DPR2 下的需求定的。等于用 41.7 KB 去画 576 个像素,在 Google 抓取的那个页面上。它没法走 `next/image`,理由同上(头像的真实 src 是 `/api/avatars/*`)。96×96 覆盖 24×24 到 DPR4,2.8 KB。
#### ⚠️ 上线前置
`/_next/image` 的响应带 `Vary: Accept`(Next 无条件发,改不掉),而 Cloudflare 对带 Vary 的响应默认**不缓存、报 BYPASS**。所以边缘缓存不是加一条路径规则就行,需要 Cache Rules 的 `vary` 设置把 `accept` 按 avif/webp 归一化。详见 `docs/` §12.6。没有这条,每一个尺寸变体的每一次请求都回源。
---
## [3.12.1] - 2026-08-23
### 修复 — 站点一直在给自己的 LCP 降优先级
三处改动,全部只碰加载优先级,不碰任何一个像素。
**详情页的横幅不是图片,是一条 CSS 背景。** `background: url(...) center/cover` 的地址只存在于样式声明里,而浏览器的预加载扫描器只解析原始 HTML 字节流里的标签属性 —— 它在 CSSOM 建好之前根本发现不了这个地址。于是全站最重要的那个页面(`/anime/*` 是整个 SEO 面)的 LCP 元素,198 KB,没有任何形式的预加载;而该页**确实**发出去的那条图片 preload,指向的是它下面那张 210×300 的封面。
换成真 `<img loading="eager" fetchPriority="high">` 之后两半一起修好:扫描器第一遍就看见它,而且 React 19 会自动为它抬出一条 `<link rel="preload" as="image">`。顺带查清了一件之前没人写下来的事 —— 那些 preload 全站零处显式调用,**它们本来就是 React 从 `fetchPriority` / 非 lazy 的 `<img>` 反推出来的**。
**`FadeImage` 给每一张非 priority 的图显式设了 `fetchPriority="low"`。** `low` 不是「排在优先的后面」,它是一道硬降级,图片进了视口也照样生效。而在网格页上(`/seasonal`、`/library`)LCP 元素**正是第一张卡片**,那里没有任何一张图标了 priority —— 等于站点在主动给自己的 LCP 降优先级。想要的那部分(把屏外封面让开)`loading="lazy"` 已经做到了,所以这里改成不设,让浏览器自己去提拔它在视口里发现的东西。详情页实测 33 张图带 `low`,现在 0 张。
**`FallbackImg` 没有默认的 `loading`,于是全是 eager。** 它的调用点清一色是 24–48px 的头像(评论楼、在看的人、通知项),大多在首屏以下。而 eager 的代价不止是早拉:React 19 会把非 lazy 的 `<img>` 变成一条 hoisted preload —— 所以 `/anime/[id]` 一直在预加载 `/card_default/card.jpg`,一个 1287×1288 / 126 KB、渲染尺寸 24×24 的文件,挂在 Google 抓取的那个页面的关键路径上。改成默认 lazy,那条 preload 消失了。已经在视口里的头像不受影响,lazy 对它们不是延后。
**关于「不碰像素」的验证**:横幅那处在 768 和 1440 下与改动前**逐字节相同**(两张 PNG 的 sha256 一致)。奇数视口宽下有一处均值 4/255、肉眼不可见的差异,成因已定位并证伪过其它解释:源图 1900 宽是偶数,居中裁切起点 `(1900−W)/2` 在奇数宽下落在半像素上,背景绘制路径和 `<img>` 绘制路径对这半个像素的舍入不同。实测 375 差、376 相同、377 差、378 相同,与预测完全吻合。
---
## [3.12.0] - 2026-08-22
### 新增 — 本地播放器不再需要账号
`/player` 一直挂在登录墙后面。而它恰恰是全站唯一**什么都不需要我们提供**的功能:文件在你自己的硬盘上,匹配和弹幕在你自己的浏览器里跑,服务端一个字节都不经手。用注册来换一个不消耗我们任何资源的功能,这笔交易讲不通。
裸 `/player` 现在公开。带 `?seriesId=` 的库交接仍然要登录 —— 在 `proxy.ts` 里挡一层,`PlayerShell` 里另有八处守卫把每一次库读取都锁在同一个参数后面。带语言前缀的形式(`/zh-Hant/player?seriesId=`)也一起挡了,这是这个仓库过去差点漏掉过的那一类。
顺带的结构变化:`/player` 从动态渲染变成了 SSG 预渲染(三个语言各一份)。这是取消登录墙的自然结果,不是额外做的优化。
### 变更 — 首页英雄区从「两个区块」变成「一个构图」
首页顶部新增本季精选轮播。但更值得记的是评审阶段发现的一件事:**割裂感的成因是亮度,不是接缝。**
页面近黑,横幅是制作方画的 —— BLEACH 那张是一片白色爆炸。而 `.art` 上挂着 `brightness(1.03)`,等于把一块白砖打在黑纸上。第一次修补是给图底加渐隐遮罩,结果更糟:白色淡向透明、背后是黑,中间必然经过灰,一块白砖变成一团灰雾。
真正的修法是两条:把图压进页面自身的亮度区间,再用 scrim 刷到 `#07090c` —— 图的底部和文案的顶部于是是**同一个像素值**,没有可看见的交界;以及让文案压在图的下三分之一上,而不是从图结束的地方开始。`≥1600` 那段原来的规则字面就是 `top: var(--desktop-media-height)`,用代码保证了两个区块永不重叠。
同时把 `align-content: center` 换成 `start`。居中意味着文案在剩余黑区里浮动,重叠量随视口漂移 —— 1840 下重叠得很好,768 下标题离图还有 35px。锚到 `--desktop-copy-top` 之后,重叠量在每个桌面宽度都恒等于 `--hero-copy-lift`。
**轮播里的文字现在和页面其它文字对齐。** `.copy` / `.rail` 用的是 `clamp(24px, 5vw, 76px)`,而英雄区以下的一切都活在 `.container`(max-width 1400、24px padding、居中)里 —— 1920 下标题比页面上任何一个标题都靠左 208px。两者现在通过 `--hero-gutter` 复现同一套算术。背景图按设计保持满幅。
**十二个争抢注意力的元素减到九个,且零信息损失。** `03 / 05` 计数器和竖排侧标各自重复了导轨和 eyebrow 在几个像素外、且沿阅读方向已经说过的话;评分和格式码回答的是「决定要不要在意这部番」这一刻没人问的问题,详情页一点就有。
**主按钮不再取海报色。** `--hero-accent` 每次轮播都变,等于让访客见到的第一个按钮每几秒改写一次全站唯一被学会的信号 —— 蓝色是可以按的 —— 而且没有对比度保证:海报色是画面里恰好占主导的那个颜色,其中一些根本撑不起可读的文字。海报色仍然驱动 eyebrow 的线、进度条和 hover 底色。
**680px 以下隐藏游客 CTA。** `/player` 需要 File System Access API,没有任何移动端浏览器提供。手机访客顺着那个按钮点进去,看到的是一句「请用电脑」,没有原因也没有退路 —— 比从没见过那个按钮更糟,因为他们花了一次点击才知道网站对自己是坏的。
### 变更 — 社区区块的库引导从卡片变成通栏
它是 1352×336 横跨两列,压在一张 140px 的讨论卡上面 —— 于是「正在热议」这个区块里最大的物件是一篇下载教程。现在是 1352×195 的通栏:这一页上唯一既不是卡片也不是海报的形状,所以不读字就知道「这是说明」。
内部**四层嵌套方框归零,五个色系收成一个**。三个步骤原本是三个带边框的盒子,说的是「三件事」;现在是一条带连接线的序列,说的是「然后,然后」。Chrome 那条要求原本有独立面板、绿色强调、44px logo 和一行全大写标签来传达一句话的注意事项 —— 修正时又矫枉过正成 10.5px 灰字配一个在那个尺寸下只是色斑的四色标记。现在是白字的浏览器名,放在它所约束的那个按钮旁边。
一条 `LOCAL // DANMAKU` 水印画在卡片上、又被插画面板盖住,于是从中间被切断。连同面板一起删除。
**文案主语从目录机制换成「我的库 + 播放」**,三个语言同步。评审时撤回了一处措辞:授权步骤原本写「授权一次」,但 `useFileHandles.reauthorize()` 会调 `handle.requestPermission()` —— 这条恢复路径存在,恰恰因为 Chrome 会在浏览器重启后掉授权。代码兑现不了的承诺不写。
### 修复 — 三个「状态存在但看不见」
**磁力搜索的标题切换按钮根本没有选中态。** 每一个都渲染同样的描边,看起来像选中的那抹蓝其实只是 hover。于是这一行按钮无法回答它存在的唯一目的:当前这批结果是按哪个标题搜出来的。现在按 `manualQ` 判定(不是搜索框文本 —— 手动输入应当让所有按钮取消选中;打开时为 null,正确地表示「服务端正在同时展开全部变体」),用下方剧集按钮已经在用的同一套实心蓝,并带 `aria-pressed`。
它上面那条提示原本是目录建议加一个跳去库的链接。但打开这个弹窗的人正在找资源,而最可能挡在他和资源之间的,是发布组对这部番的写法和我们不一样。那排按钮本来就解决这件事,只是从没说过。提示现在指向它们,并和按钮共用 `titleOptions.length > 1` 这个条件 —— 只有一个标题时,「点下面的标题」比沉默更糟。
**`/player` 空闲态的两个盒子宽度不一致。** 试用横幅 598px、拖放区 459px,各自缩到自己的内容宽度 —— 因为 **flex 项目在交叉轴上带 auto margin 就不会 stretch**,会退回 fit-content。两个居中但不等宽的盒子,读起来像一个什么都没决定的版面。现在共用一个 720px 列,两条边齐平;从库进入的那个回退状态也用同一个居中舞台,不再把拖放区顶在窗口最上面。
### 此前已合并、未单独发版的
- **过期标签页提示**(#93):`next.config.ts` 把构建 id 在**构建时**内联进客户端和服务端两份产物,`/version.json` 由当前运行的实例回答。两者不同即说明这个标签页比部署旧。Sentry 上 `/anime/:id` 那个 `Cannot read properties of undefined (reading 'call')` 就是这种情况的表现 —— 客户端导航去要一个当前部署已经不存在的 chunk。
- **繁体列漂移的可见化**(#96):river 富化 worker 持续填简体列,繁体列只有人工跑 `hantbackfill --apply` 才动。于是繁体树会一格一格地退回简体,且永不自愈;症状是隐形的 —— `/zh-Hant/anime/:id` 的读者拿到的是**简体**简介,因为渲染阶梯是回退而不是留空。管理面板现在把这个漂移量显示出来并设了下限。
- **集数显示**(#97):`episodes` 为 NULL 时详情页整块网格消失,而 NULL 的意思是「我们还不知道」,不是「这部番没有集数」。现在有数就显示数,没有就明说,并补了一个走 Bangumi 的回填 worker(带身份闸门)。同时删掉了死掉的 `EpisodesSection`。
---
## [3.11.0] - 2026-08-16
### 新增 — 本地库和追番进度之间,此前没有传动
库里的进度 UI 一直是全套写好的:卡片进度条、完成度筛选、继续观看行、逐集勾选、观看节奏条。五处读侧,渲染出来全是空的。原因不在任何一处 UI,而在它们共同读的那张表**没有生产者**——`progressRepo.put` 有完整定义和零个调用点,全仓库搜 `completed: true` 一个命中都没有,唯一写过 `progress` 的是一次性的历史数据迁移,而且硬编码 `completed: false`。
服务端那一侧同样是半条链:`subscriptions.current_episode` 模型完整、动态事件的写入路径带行锁和去重,但整个前端只有详情页那两个 ± 按钮在调它。播放器和库从不触碰订阅接口。
两端都建好了,中间是断的。这一版补的是中间那段。
**播放器成为进度的生产者,且按来源分流。** 从库里进入播放的剧集手上有 `episodeId`,写 Dexie,参与同步;直接拖文件进播放器的没有 `episodeId`,只写 localStorage 记住播放位置,不产生完成状态、不同步。这条线不是实现细节妥协,它就是产品定义——**没导入库就不追踪**。
位置写入节流定在 30 秒而不是沿用 localStorage 那条路径的 5 秒,`pause` / `visibilitychange` / `beforeunload` 三个点强制落盘。理由是双标签页:`progress` 表上挂着两个 `liveQuery` 全表订阅者,一个 `toArray()`、一个 `orderBy().reverse().toArray()` 外加两次 `bulkGet`。日常单标签播放时库页面是卸载的,这两个订阅者不存在;但一旦一个标签开着库、另一个在播,播放器每写一次位置,库那边就全表扫两遍。三个强制落盘点让断点精度不降反升——人离开时必定落盘,比固定 5 秒还准。
这两个 `liveQuery` 也是为什么播放器写完之后库页面会自己更新:进度条、筛选、继续观看行全部自动生效,没有一行刷新接线。
**不回退守卫放在服务端,不放客户端。** 客户端能比较的只有自己那份缓存副本,多设备下它本身就可能是旧的。PATCH 新增 `monotonic` 参数:为真时 `current_episode` 走 `GREATEST`,只能前进。这不是理论风险——Mihon 有过完全对应的事故(A 设备读到第 21 话,B 设备打开第 16 话,追踪器被压回 16)。详情页那两个 ± 按钮传假值,**人工必须能调低**。
守卫要连时间戳一起守,否则只挡住一半。陈旧标签页推一个更小的集号时 `GREATEST` 让集数不动,但如果 `last_watched_at` 和 `updated_at` 照常 `now()`,这次无效推送仍然会把这部番顶到首页「继续观看」的第一位。所以单调模式下集号没有真正前进(且没同时携带状态或评分)时,两个时间戳都保持原值——一次空推送是**彻底的**空操作。这里有个容易搞混的耦合值得写下来:列表排序读的是 `updated_at`,不是 `last_watched_at`。
**集数上界由服务端校验,超界拒绝而不截断。** 权威的总集数在 `anime_cache` 手里,客户端那个字段是可选的、经常不准。超出总集数的推送是「客户端绑错番了」的证据,截断成最大值等于把这个错误存成看起来合理的进度。连载中(总集数未知)的不校验。
**只有正篇参与同步。** 集号解析器不管文件是什么类型都会从文件名里抠出一个数字,所以 `[NCOP01]` 会变成「第 1 集」。不拦的话,看一遍无字幕 OP 就会把高水位推成 1;再配上服务端的 `GREATEST`,这个错误**再也退不回去**。特殊篇更狠,一个解析成 12 的 SP 能把只看到第 5 集的番推到 12。本地照常打勾,只是不进同步。
**对账靠差值,没有队列。** 每部番记一个「已经告诉过服务端的集数」,本地高水位超过它才推,推成功才回写。状态即真相,所以重放任意次都安全,不需要队列、不需要重试计数器、不需要担心补推顺序。断网时本地照常打勾,联网后任意一个触发点都会把差值补上。
**自动标记的阈值移植自 Animeko**:`min(时长 × 0.9, 时长 − 100 秒)`。长片按百分比,短片按「离结尾 100 秒」——短片的片尾曲占比高得多,24 分钟的番在 90% 处触发,3 分钟的短片在离结尾 100 秒处触发。四道守卫和用户开关一并移植,开关默认开。
有一个取舍是**睁着眼睛接受**的:判定只看播放位置,所以把进度条拖到片尾就会被标记成看过。本地库的使用姿势本来就是拖着确认文件对不对,所以这**会**真实发生。测试里专门用「这是有意为之」的措辞固定住了这个预期,免得半年后有人当 bug 去修。
**本地剧集和 AniList 之间,此前没有持久化的关联键。** 本地记录里唯一的外部 id 是 bangumi 和 dandanplay 的,跟 AniList 不是一个 id 空间。映射是每次会话拿标题现搜出来的,只缓存在内存里,关掉页面就没了。这一版把它落到本地库里并建索引,索引白送一个反向查询:从 AniList id 找回本地剧集。
自动匹配和人工重匹配收进同一个写入口,**人工绑定优先,自动匹配物理上不可能覆盖它**。这一条是 Taiga 十年前就验证过的形状:用户的选择要排在官方标题前面。
解析发生在两个「用户表达追踪意图」的时刻——点击卡片、进入播放器。刻意**不**在库挂载时批量解析:一个成熟的库可能有几百部未绑定,那会变成一次几百个搜索请求的突发。
**点击即追踪是幂等创建,状态只能人工改。** 原有的订阅创建走的是「冲突时更新状态」,用在点击即追踪上会把用户手动标成「已弃番」「已看完」的番静默复活成「在看」。新增一条冲突时什么都不做的路径,既有订阅原样返回。
**自动标记开关**落在设置页新的「播放」区。写入失败(无痕模式、存储写满)会明确告诉用户没保存成功,并且**把开关留在原位**——写失败意味着所有读者仍然看到旧值,显示用户请求的状态是撒谎。
**动态事件表加了 90 天保留策略。** 此前它没有任何保留策略,这在只有手动社交动作写入时活得下去;一口气刷完一季就是 24 条事件,写入速率会提高几个数量级。修剪分批执行,撞上单夜上限时写日志而不是悄悄停手。同时给指向该表的那个外键补了索引——它带「删除时置空」语义,没有索引的话每删一条父行就要全表扫一次通知表,分批根本救不了。
### 修复 — 手动重匹配一直在把 AniList id 写进 dandanplay 的 id 空间
搜索接口返回两种行:来自本站缓存的带 AniList id,来自 dandanplay 的带 dandanplay id。这两种形状**完全不相交**——各自都没有对方那个字段。而重匹配对话框写的是 `dandanAnimeId ?? anilistId`,所以**每一次从缓存那一区(更丰富、排在前面的那一区)选择**,都会落到第二项,把一个 AniList id 写进只该放 dandanplay id 的位置。
后果不是显示错误,是身份错乱:导入流程靠 dandanplay id 复用季,被污染的那一行**再也匹配不上**,下次导入会多出一张重复卡片;弹幕则指向恰好共用这个数字的另一部番。
对话框的载荷类型现在把两个 id 空间拆成两个字段,各自独立解析,两个都没有才拒绝。已经写进本地库的错误数据无法可靠地自动识别(受影响的记录当初根本没存过 AniList id,没有可比对的基准),用户重新做一次手动重匹配即可自愈。
### 修复 — 多标签页会让本地库升级卡死成「一直转圈」
本地库的连接在模块加载时就建立,所以任何一个库页面或播放器页面一打开就持有它。新版本的标签发起数据库升级时,旧版本标签持有的连接会让升级进入阻塞状态,而阻塞**不是错误**——它就是无限等待。用户看到的是库一直转圈,像是数据丢了。
两条一起上:旧标签收到版本变更通知时主动让出连接(升级完成后自己重新打开),新标签在阻塞超过宽限期后把等待变成一条明确的提示。只做前者不够(那已经是底层库的默认行为),只做后者也不够——底层库的自动打开路径会吞掉拒绝,调用方照样挂住,所以提示必须走独立的订阅通道,不能指望被拒绝的打开操作。
---
## [3.10.0] - 2026-08-15
### 新增 — 让观看长成内容,让内容把人带回来
站点此前已经有追番、评论、关注、动态和公开主页。缺的不是功能,是这些功能之间的传动:用户做了行为,行为没有长成内容;产生了内容,内容没有触发关系;形成了关系,却没有通知把人带回来。这一版补的是传动本身。
**活动事件流。** 订阅表只保存最新进度,状态覆盖了历史,撑不起观看日记和内容级 Feed。新增 append-only 的 `activity_events`,把「看到第几集」「发了评论」「关注了谁」记成事件而非状态。事件写入和业务写入在同一条语句里完成:更新进度时先 `FOR UPDATE` 锁住旧值,只有集数**确实变了**才追加事件——重试和纯状态更新因此不会造出重复的 Feed 条目。
**通知。** 回复、获赞和新关注终于有了稳定的回访入口。去重靠 `(user_id, dedupe_key)` 唯一键,重复投递返回既有那条而**不重置已读状态**:取消点赞再点回来,不会把一条已读通知变回未读。
**分集讨论前置。** 讨论此前要先进详情页、再点开某一集才看得见,有内容也难以被感知。现在卡片上有预览,分集网格里可直接展开,评论支持快捷反应。
**安全治理。** 剧透标记、拉黑、举报与后台审核队列一并落地。拉黑是一次原子操作——建立记录的同时双向切断关注边、通知与反应。策略是对称的:任一方的拉黑都关闭双向互动,即使只有发起方持有那行记录。举报保留证据快照,所以被举报内容删除后审核仍然成立;同一目标重复举报返回既有的待处理记录,不堆积。
**首页发现。** 新增「正在热议」。排序以参与人数为主、评论量次之、反应更次,再除以一个平滑的时间衰减因子,让一场新鲜的小讨论能盖过一条陈旧的长贴——不设硬性日期边界,排序不会在某个时刻跳变。**标记为剧透的内容在服务端就被置空**:不是前端打码,载荷里根本没有那段文字。
**埋点只落聚合计数**:不记 IP、不记 UA、不记匿名标识、不记评论正文,不留任何单用户事件行。
### 修复 — 社区安全控制曾经是「可选的」
上述功能的每一个新查询,最初都是通过一个可选接口加类型断言拿到的。五处断言失败会报错,另外七处没有 else 分支,于是断言失败时**静默跳过**:被拉黑的人可以关注、可以看到完整主页、可以回复、可以点赞,而三类活动事件会悄悄不写入。
生产从未出错——`*dbgen.Queries` 实现了全部十二个方法,每次断言都成功。但没有任何东西**保证**这一点,而且这个形状还藏着第二个问题:social 的测试替身没有实现那两个安全方法,所以该包自己的测试跑的正是「跳过」分支。在 social、comments、notifications 三个包的测试文件里搜 `block`,一个结果都没有。safety 包把「建立拉黑」测得很足;没有任何东西测「遵守拉黑」。
把这些方法收进 `SocialDB` / `CommentsDB` / `SubscriptionsDB` 之后,一个无法执行拉黑的依赖会变成编译错误,而不是一次静默的策略变更。
另外两处:`UpdateSubscription` 曾用 `any` 承载两个分支的不同行类型——两者今天字段与 JSON tag 完全一致,所以响应从未真的不同,但只要有人给 CTE 的 `RETURNING` 加一列,API 形状就会随分支而变,且没有任何东西会拦下来。分页曾写作 `int32((page-1)*limit)`,该表达式会截断:`?page=42949674&limit=50` 落到 `OFFSET -2147483646`,Postgres 直接拒绝。新的 `pageOffset` 用除法判界,那个会溢出的乘积从不被计算。
十三个新测试覆盖「遵守拉黑」——主页遮蔽、关注、回复、反应,以及通知收件箱,包括**未读角标必须与点开后的列表一致**:角标显示 3、点开却是空的,才是用户真正会来反馈的那个版本。每个测试都做过变异验证:把守卫关掉,它们会红。
### 性能
`community-welcome.jpg` 按渲染尺寸重新导出:1081×1600 → 252×373,386 KB → 31 KB。它落在首页的第一个内容块里,而 `FadeImage` 是原生 `<img>`、不经 Next 图片优化,源文件是原样发给浏览器的——显示尺寸只有 84×118。
---
## [3.9.1] - 2026-08-09
### 修复 — 合并后的卡片只显示了一半集数
`SeriesDetailSheet` 用 `.where("seriesId").equals(series.id)` 取集数。而 `performMerge` 是**软合并**——它从不移动 episode 行,只把 source id 追加到 target 的 `mergedFrom`。于是合并出来的卡片读的是目标自己的行,被合并进来的那一半**从未被查询**。合并一次就能触发。
`progress` 那一行是同样的形状,问题更隐蔽:就算集数出现了,观看进度也拿不回来——看起来像观看记录被删了,而不是查询写错了。
隔壁的 `useSeriesDetail` 一直正确处理了 `mergedFrom`,但没有任何东西通过它渲染。两套并行实现,屏幕上跑的恰好是忘了这件事的那套。
两条路径现在统一走 `resolveMergedSeriesIds`,且是**传递闭包**而非一跳:A 合进 B、再把 B 合进 C 之后,`C.mergedFrom = [B]` 而 `B.mergedFrom = [A]`,单层读取会丢掉 A 名下的一切——而 `useLibrary` 把出现在任意 `mergedFrom` 里的 id 都从网格过滤掉。**文件在磁盘上、已索引、界面上哪里都进不去。** 带环保护:`userOverride` 是用户可写、跨版本存活的状态,这里死循环会让详情面板挂起且控制台什么都没有。
**关于测试**:第一版 15 个测试全在 `resolveMergedSeriesIds` 这个纯函数上,而它从来没坏过——测试护着一个没病的东西,真正的病灶零覆盖。查询逻辑因此被抽到 `loadMergedSeriesRows`,其假 Dexie **记录调用方式**且 `equals` 直接抛异常:只返回正确数据的桩挡不住这个回归,因为坏版本用 `.equals` 照样能从桩里拿到东西。24 个测试覆盖两个模块,含 progress 那一半、链式合并、从 source 反向上溯、以及没有 `userOverride` 表的老库。
---
## [3.9.0] - 2026-08-09
### 新增 — 把「订阅」从终点改回起点
生产数据把问题指得很死:802 个注册用户里 **659 人(82%)再没回来过**,7 日活跃 31 人。而 255 个订阅者里 **234 人只订阅了 1 部**,且 **245 人的全部订阅发生在注册后 10 分钟内**——之后还再订过的不到 20 人。
这不是偏好分布,是被结构夹死的分布。六路调查收敛到从 Google 落地到形成习惯之间的三处物理断点:
**断点 ①:`from` 在注册那一跳被丢弃。** 全站只有两个 `/register` 入口(`LoginForm`、`Navbar`),都写死 `href="/register"`,而 `sanitizeFromParam(undefined)` 兜底 `"/"`——**100% 的注册用户落在首页**,不在他刚才想追的那部番上。新增 `authHrefWithFrom` 统一生成,顺带修好三处同类断链,其中 `settings/page.tsx` 用的是 `?next=`,而 `/login` 只读 `from`,该参数一直被完全忽略。
**断点 ②:全站只有一个地方能订阅。** `SubscriptionButton` 的唯一渲染点是详情页的 `DetailActions`;`AnimeCard`(首页热追 / 季度 / 搜索共用)零行订阅代码。加第二部番要走五步。现在卡片右下角有快速追番,页面级**一次** `GET /api/subscriptions` 供整个网格使用(不是每卡一次——go-api 有 per-IP 限流)。
**断点 ③:注册让首页变得更空。** `ContinueWatching` 在零订阅时 `return null`,整块连标题一起消失——用户交出邮箱密码,换来一个比匿名时信息更少的页面。现在有第三态,且订阅成功后会就地换掉(走 `subscriptionBus`,不跑 `router.refresh()`)。
**卡片角标的一条铁律:永远不删数据。** `+` 写入,`✓` 只导航到详情页的完整面板。审查发现原设计里 `✓` 绑 DELETE,而集合来自无 status 过滤的接口——一部 2025 年看完打了 10 分的番会在 2026 年的搜索结果里显示 ✓,手机误触就是整行删除(评分、进度一起没)。`POST` 是 `ON CONFLICT DO UPDATE SET status`(非破坏),`DELETE` 才是抹行的那个,所以网格拿安全动词,详情页拿另一个。误点的补救是成功 toast 里的「撤销」——那一刻刚创建,是唯一安全的删除时机。
其余:导航栏加「我的追番」(「我的库」降级进头像菜单,链接数不变);订阅成功 toast 从一个 `✓` 字符换成有内容的文案 + 首次带列表入口;登出时广播信号,网格角标与首页追番网格一起清空(共享设备上登出后仍列着上一位用户的追番清单)。
### 新增 — 注册要求二次确认密码,并可切换明文
这个站的密码找回路径大概率是坏的:**79.6% 的注册邮箱是 @qq.com**,而发信从 gmail.com 直发,送达率过不了一半且裸 SMTP 看不到退信。注册时打错密码 ≈ 账号永久锁死,所以二次确认在这里不是仪式,是唯一兜底。校验先长度后匹配(两个都错时先报"太短",否则用户改完确认框再提交才发现密码根本不够长),不 trim 空格(粘贴带来的尾随空格是真字符),确认框是正确前缀时保持沉默。明文切换按钮**故意可聚焦**——`tabIndex={-1}` 会让键盘用户彻底用不了它,违反 WCAG 2.1.1。
### 修复 — 隐私政策不再公开会话机制
第二节原文列出 `session` / `refreshToken` / `auth_hint` 三个确切 cookie 名并标注哪个是 httpOnly、哪个前端可读。没有任何隐私法要求这种粒度,等于公开一份会话机制说明书。改为只讲用途、承诺不做跨站追踪、告知清除方式,并补上标题承诺却从未提及的本地存储。第四节改用「接收方类别」写法(GDPR 第 13(1)(e) 条明确允许),去掉品牌名与技术描述。三个法律页的联系邮箱统一为 `animegoanime@animegoclub.com`。
### 修复 — 导航栏在匿名访客眼里有个洞
「我的追番」为消除布局抖动在匿名态也占位(`visibility: hidden`),结果是「搜索」与「关于」之间一个常驻空洞。方向错了:匿名访客是绝大多数流量,他们 100% 时间看到缺陷,换来的只是少数登录用户少一次挂载位移。现在只在 `probing` 窗口占位——而 probing 仅在 `auth_hint` 存在时进入,也就是真链接已在路上。
### 变更 — 卡片与头像的交互细节
卡片 `+` 改为指针设备上 hover 淡入(`@media (hover: none), (pointer: coarse)` 与 `:focus-within` 双重兜底,触摸和键盘都不失效);`✓` 静止缩至 `scale(0.72)`、hover 展开——`+` 是行动召唤该完整呈现,`✓` 是状态信息,二十个满屏蓝点没有意义。全部走 transform/opacity,且**不引入 `backdrop-filter`**(A/B 实测 p95 帧时间 41.6ms vs 19.9ms)。导航栏头像 36→44px,hover 放大 + 蓝环,探测期骨架同步尺寸以免头像出现时右侧整组跳动。
---
## [3.8.2] - 2026-08-08
### 修复 — 机翻在替我们思考:92.6% 的输出 token 花在了思维链上
3.8.0 上线后队列里持续出现 `deepseek: response carried no content`,累计 **2,911 次**、约 **596 万输出 token 白烧**(≈$1.73),并有几十个 job 长期挂在 retryable。三条独立调查线(统计对照 / 协议实证 / 代码推演)收敛到同一个根因,且**推翻了两个看起来很合理的假设**。
**根因**:`deepseek-v4-flash` 和 `-pro` 都是**推理模型**,且都默认开启思维链,而 `max_tokens` 是**推理 + 正文的总预算**,思维链先生成、先计费。原来的 2048 是按"最长简介约 600 token、中文比英文短"算的——这个推算对正文完全正确,**对思维链一无所知**。模型在思维链里把预算烧光,`finish_reason: "length"`、`content: ""`、HTTP 200。客户端只解析 `content`,于是把"预算耗尽"误报成"上游畸形响应"。
**被证伪的两个假设**:① **不是内容审核**——露骨内容实测被完整翻译,`content_filter` 一次未出现,`refusal` 字段根本不存在;控制原文长度后 Hentai 分类的失败率反而低于非 Hentai。② **不是"任务卡死"**——曾失败的 1,033 行里 924 行(89.4%)已靠重试自愈,永久失败为 **0**;看到的"卡住"是一张移动中的退避快照。真正的驱动因素只有**输入歧义度**(冷僻专名越难定译名,模型越纠结),表现为失败率随原文长度从 3.4% 单调爬到 95.7%。
**真正的修复是关掉思维链,不是抬预算。** 抬到 8192 只是把墙往后挪:同一条输入的思维链实测在 3,061 和 8,192 之间无界抖动,`reasoning_effort: "low"` 照样吃满 8192 返回空。官方参数 `thinking: {type: "disabled"}` 才是解法,同一条输入实测 **3,220 → 155 输出 token(降 20 倍)、25.4s → 2.8s(快 9 倍)**,此前反复失败的行第一次就翻过。全量成本从 ≈$4 降到 **≈$0.33**;`max_tokens = 8192` 保留为安全网而非修复,注释里写死了这个区别。
**同时修掉误报本身**:`client.go` 里 `len(choices)==0`(真·畸形上游,该重试)和 `content == ""`(已决结局,该盖章)原本共用一条错误分支——这正是"两眼一抹黑"的来源。现在拆开:空正文返回 `("", nil)` 落进 worker 已有的 `validateTranslation` 闸门 → 盖章 → 面板上从"永远待处理"迁到"已判定失败"。**worker 生产代码一行未改**,这是取消一个特例而不是新增特例。同时把实测存在的字段全部解析进日志:`finish_reason`、`reasoning_tokens`、完整 usage、前缀缓存命中数、`X-Ds-Trace-Id`(找 DeepSeek 支持的唯一凭据)——有这些字段,这次事故 5 分钟就能定位,而不是花掉三个调查 agent。
**来源标记**:关掉思维链后模型不再"顺手"删掉目录元数据,而输入侧的剥离正则锚定在 `$`,漏掉了 240 行 —— AniList 在署名后又追加了 `Note:` 段落,把标记挤出了锚点范围。正则改为不锚定,同时**要求 `source` 后必须有冒号**,否则 `(source of his power)` 这类正常句子会被静默删除(比原 bug 更隐蔽)。
**顺带修掉一个静默 20 天的隐患**:`401/402/403`(key 失效、余额不足、权限撤销)是确定性错误,却和 429 走同一条 25 次 `attempt⁴` 退避阶梯 ≈ 20 天——欠费会让整个 sweep **安静地停摆而不是响亮地失败**。新增 `ErrUpstream.Permanent()`,worker 侧**只抬日志级别到 ERROR,行为一字不动**:不盖章(账户的事实不是行的事实,盖章等于给上万行从没试过的行记上"试过了没结果")、不 return nil(那会拆掉退避,变成每小时 600 次对着死 key 的计费失败)。匹配用本地接口 + `errors.As`,queue 包仍然不 import deepseek。真需要止血时正确动作是暂停 `description_llm` 队列——scan job 就挂在同一队列上,暂停即同时切断投喂与消费,这个设计当初已经预留。
顺手修正了若干注释腐烂:成本估算(旧值低估约 4.7 倍且没写明不确定性)、引用了一个**根本不存在的常量** `descriptionLlmMaxWorkers`(真实并发在 `main.go`)、以及推理模型让 60s 客户端超时从"保险"变成"活约束"的新张力。`validateTranslation` 的空值检查标注为 load-bearing——将来有人看到 8192 觉得空正文不可能发生而删掉它,事故就会原样复现。
测试:deepseek 包新增思维链参数上线断言(漏发不会编译报错、不会运行时报错、别处也测不出来,只会静默恢复 20 倍花销)、空 content / null content / finish_reason+usage 解析 / `Permanent()` 六个状态码;queue 包新增空正文盖章回归(**实测把旧行为塞回去会失败,确认不是摆设闸门**)、permanent 与 non-permanent 两个方向相反的守卫、来源标记四种形态(含"正常括号必须活下来")。`go test -race` deepseek + queue 全绿。
---
## [3.8.1] - 2026-08-08
### 新增 — /admin 能看见机翻兜底层的状态
3.8.0 让 LLM 兜底跑起来了,但面板上完全看不见它:那块「中文简介」的四个数字全是 Bangumi 通道口径(建在 `description_cn_eligible` 视图上),机翻翻了多少、卡在哪、DeepSeek 是不是欠费了,一概不知——只能 SSH 上去看日志。这一版把它接进现有的富化面板。
**新增一个并列区块,不是往老区块里塞行**。理由是分母:Bangumi 层的分母是「绑定可信的行」,LLM 层的分母是**「Bangumi 已放弃 + 永远够不着」的那批行**——两组行几乎不相交,并排放会诱导人去加它们。所以是两条独立进度条(紫 vs 青,颜色也刻意拉开)、各自的四个数字、各自的两个心跳。新口径叫 `remit`(职责内)而非 `eligible`,就是为了在命名上就杜绝误读。
**四个数字**:职责内(分母)/ 已译(`source='llm'`)/ 已拒(译文没过汉字密度或长度校验,或原文剥完是空——**传输失败不在此列,那些会重试**)/ 待处理(含 30 天冷却到期的重试)。已拒与待处理**故意重叠**,与 Bangumi 块同款语义,永远不能相加。「已译」这个数**可以合法地往下走**——机翻行会回到 Bangumi 的 30 天重查,被真人简介替换掉是契约在生效,不是数据丢失,注释里写死了这一点。
**队列与心跳独立**:新 `descriptionLlm` 队列快照(排队/重试中/已放弃三态)+ 两个心跳(扫描存活 / 写入活动,各读自己的时间戳列)。**重试中这个数在这一层比 Bangumi 层更要紧**——DeepSeek 限流、key 过期、余额耗尽全部落在 retryable 上,这是它们在彻底停摆前唯一的暴露点。融进一个计数器的话,「余额没了」和「bgm.tv 慢」就分不出来了。
**软失败保持三路独立**:`GetDescriptionCnLlmStats` 是第三次独立往返(不是往 GetAdminStats 上加列),任何一块挂掉都不影响另外两块——测试从两个方向都钉死了(Bangumi 挂时 LLM 仍是真实读数,反之亦然)。前端同样有 `NO_DESCRIPTION_CN_LLM` 零值兜底,滚动部署期间老 go-api 的响应不会把 /admin 打崩。
过程中被两个既有闸门当场抓住,都是真问题:**词典漂移闸门**抓到我把键写成了 `admin.backfill.llm.*` 而实际挂在 `admin.llm.*`(不修就会在页面上渲染出键名——正是 3.4.3 建这个闸门的原因);**tsc** 抓到第二个测试夹具缺 `descriptionLlm` 字段。测试:admin 新增 remit 四值口径用例(夹具覆盖 remit 谓词每一条臂:已译 / 无绑定 / Bangumi 试过后被 LLM 拒 / 仍属 Bangumi / 已有真人简介 / 无英文原文)+ 双向软失败用例;两个队列 JSON 字节级断言纳入新字段。go 27/27 包全绿,bun 487/487,tsc 回到基线。
---
## [3.8.0] - 2026-08-08
### 新增 — 中文简介的 LLM 兜底层:Bangumi 捡不走的,DeepSeek 当轮捡
Bangumi 简介通道第一轮扫完后的现实:全库 29% 有中文简介,**当季新番 1/100**。剩下的 11,600 条有英文原文的行分两类——4,108 条试过但 bgm 条目没有可用中文,约 7,500 条因绑定过不了交叉验证门槛(或压根没有 bgm_id)永远进不了那个通道。这一版给它们建唯一的通道:`description_llm_backfill` 常驻清道夫,把英文简介翻成中文落库。全量回填的账算过:约 100 万输入 token + 250 万输出,**deepseek-v4-flash 约 1 美元**,翻一次存一辈子,线上永远是纯数据库读。
**接管规则在 SQL 里钉死,永不与主通道抢**:候选查询只选「Bangumi 已盖章试过」或「不在 eligible 视图里」的行;写入守卫是 `description_cn IS NULL`——机翻永远只填空白,覆盖不了 bangumi/manual/更早的自己。**反向覆盖是特意打通的**:`ListDescriptionCnCandidates` 加了 `source='llm'` 臂,机翻行回到 Bangumi 扫描的 30 天重查周期,哪天社区写了真人简介就换掉机翻(manual > bangumi > llm 单向语义,管理面板的 pending 镜像同步拓宽——change one, change both)。
**管线复用 0015 的全部骨架**:migration 0017 加独立的 `description_cn_llm_attempted_at` 盖章列(与 Bangumi 通道的章分开,两个扫描器互不挤兑对方的队列)+ 同款 NULLS FIRST 部分索引,收尾保证的算术原样成立。新 river 队列 `description_llm` 独立可暂停,MaxWorkers=4——这是全站唯一真能并行的队列(预算是 DeepSeek 往返秒数不是共享令牌桶),600 行/小时批量约 20 小时走完存量。job 载荷只带 anilist_id,工作时重读源文并复查 `description_cn`——**Bangumi 在 scan 与 work 之间抢先落地时,worker 让位不花 token**(有单测钉死)。
**质量与红线**:发送前 Go 侧剥 HTML/实体/尾部 "(Source: ...)"(省 token 也防模型回吐标签);回包过验证闸——汉字 ≥10 且占比 ≥25%(英文拒答与未翻译回声全灭)、长度 ≤4×源文+200(防跑飞)——不过闸就盖章不落库,30 天后带着更好的模型重试。**SERP 边界**:metadata/JSON-LD 继续只读英文原文;详情页正文渲染机翻时出「简介由 AI 翻译自英文原文」标注行并进 nosnippet(与 Bangumi 转写文本同一豁免通道),Google 摘要永远见不到机翻。系统提示词是常量——DeepSeek 前缀缓存价是 miss 的 1/50,一万一千次请求里省的就是大头。
**部署开关**:`DEEPSEEK_API_KEY` 环境变量。没有 key 时 worker 照常注册(残留 job 永不会遇到未知 kind)、扫描器以 disabled 姿态每小时打一行 info——**可以先部署后配 key**,key 落进 `.env.production` 重启即自动开扫。DeepSeek 官方已预告近期涨价,趁现价把存量走完。
测试:deepseek client 3 例(httptest:鉴权/载荷/非 2xx ErrUpstream/空 choices);worker 13 例(扫描映射/禁用不读库/Bangumi 抢先不花 token/瞬态不盖章 river 重试/拒答不落库/剥离与验证闸全分支);12 个既有 Enqueuer 测试替身补齐新接口方法。**本地全量 27 包 go test 首次全绿**(Docker 起着,testcontainer 四包一并实跑),bun 487/487,tsc/eslint 零新增。
---
## [3.7.3] - 2026-08-08
### 修复 — 库自动扫描「时灵时不灵」:五个互不相同的搁浅缝一次关掉
用户报告新片加入模块有时候会失效。逐行排查 rescanController / rescanService / useAutoRescan / folderWatcher 后证实五个缝,每个都产生"有时候没反应",且触发条件互不相同——叠在一起就是"时灵时不灵"的体感。经 /plan-eng-review 完整对峙(D1-D7 七个决策,含一次对 0804-D7 的正式修订),修法如下:
**周期兜底替代补丁**(D2):最大的洞是"人坐在页面上等下载完,永远等不到"——能在页面静止时发现新文件的只有 Chrome/Edge 133+ 才有的 FileSystemObserver,其余浏览器静默降级成"只靠切标签页"。原计划给被节流吃掉的 visibility 触发做补偿定时器,评审时推翻:**observer 缺席且页面可见时每 120 秒轻量 diff 扫一次**(`PERIODIC_FALLBACK_MS`),走 maybeScan 现成守卫链,反而砍掉了专用补偿器——无聊轮询换聪明补丁,覆盖更全代码更少。
**重试链弹回续期**(D5):deferred-retry 闹钟开火撞上守卫(正在导入/正在播放/忙/刚扫过)时被消耗且无人续期,链条静默死亡;observer 环境里文件写完那刻的 watch 触发被弹回同理——写完了不会再有事件,搁浅到下次切标签页。现在 watch/deferred-retry 被四种守卫弹回都重新武装同一个 65 秒闹钟,直到真正扫成一次。
**黑名单限次**(D3,修订 0804-D7):一次瞬态导入失败(IDB 抖动、哈希线程崩)曾把该文件整个会话永久拉黑,UI 零痕迹。改为每键最多 3 次尝试:瞬态错误有两次自愈窗口,永久坏文件的浪费钉死在每会话 2 次额外的 16MB 哈希+dandanplay 匹配——防重试风暴属性与原设计等价。`failedCount` 语义统一为"到限被放弃的键数"。
**静默期容差带**(D4):`now - mtime < 60s` 推迟的算式对未来时间戳永远为真——NAS 时区飘移或坏时间戳的文件每次扫描都被推迟,永久进不了库。改为三段带:超前 2 分钟以内当"正在写"继续保护(SMB 服务器时钟快一两分钟的活写场景),超前更多视为坏时间戳成品立即导入;病理场景有 size-变化 superseded 重导自愈兜底。
**观察面补全 + 降级可见**(D1/⑤):「+ 添加文件夹」后新根目录从未被实时监听(watch effect 只在挂载时武装)——新增 `rearmWatch()`,加文件夹成功后重新观察全部根。observer 缺席时文件夹区域出现一行提示"此浏览器不支持文件夹实时监听,页面打开期间每 2 分钟自动检查一次新文件"——本次排查的起因就是静默降级没有任何提示,用户把设计行为误判为故障。
实现为三车道并行(两个 subagent 扛控制器与扫描服务,主线接 hook/UI/词典)。测试:控制器 +7 例新增 +1 例 IRON 回归改写(28/28),扫描服务 +3 例(12/12),全量 bun test 487/487(main 基线 477),词典四份齐上 spaDictCoverage 闸门绿,tsc/eslint 零新增。**OPFS 沙箱 e2e 本地实跑 3/3 绿**(全栈:dev postgres 迁移到 0016 + go-api 容器 + next dev + Playwright,autorescan 正向链路含真 FileSystemDirectoryHandle 枚举→diff→哈希→卡片浮现)。控制器头部陈旧的 ASCII 触发流程图(缺 watch/deferred-retry 两个既有触发源)随本次更新为六源完整矩阵。
设计文档与评审记录:`~/.gstack/projects/lawrenceli0228-animego/lawrence_li-main-design-20260808-autorescan-fixes.md`(含 HTML 版)。剩余人工项:真 Chrome 133+ 环境验 rearmWatch 与弹回续期(e2e 的 Chromium 无 FileSystemObserver,自动化到不了)。
---
## [3.7.2] - 2026-08-07
### 修复 — AniList 熔断器从未生效:限流风暴里每个请求白烧满 5 秒
从 Sentry 两条报错查下去,挖出的真问题比报错本身大。生产 24 小时里 go-api 记了 865 次 `AniList upstream error`,cause 全是 `anilist: retry sleep: context deadline exceeded`,每条 `duration_ms=5000`;同一窗口 nginx 上 `/anime/{id}` 的 5xx 有 1043 次。打过来的几乎全是爬虫:meta-externalagent(Meta 的 AI 训练爬虫)267 次,剩下 751 次来自一个住宅代理池——**每个 IP 恰好只出现一次**、桌面 Chrome UA 轮着换。
根因在 `anilist/client.go` 的 429 分支:只有「重试次数耗尽」那条路会 `tripBreaker()`,而生产实际永远走的是另一条——AniList 叫我们退避 60 秒,请求预算只剩不到 5 秒,`sleep(ctx)` 把剩余预算睡光后报错返回,**不触发熔断**。于是精心设计的 30 秒熔断从上线起一次都没开过:风暴期间每个冷详情页请求都排队烧满 5 秒再 502,而不是微秒级快速失败。
修三处:退避时长塞不进请求剩余预算时**不睡了**,立即熔断并返回 `ErrRateLimited`;睡眠中途被打断同样熔断(429 是真发生了的);限流器排队醒来后复查熔断器,风暴中排队的请求不再逐个去捅一个正叫我们减速的上游。
顺手把三个 handler(detail 冷路径 / search / schedule)的限流错误从 502 统一改成 503——它是熔断窗口内的瞬态条件,和真上游故障在 nginx/CF 日志里应当一眼可分。
同一次调查的另两笔:`/seasonal` 是全站唯一没有 error.tsx 的核心路由,RSC 一炸就整页白屏(Sentry NEXTJS-2 的 15 次崩溃全在 `/seasonal/summer/2026`,用户连刷两三次后放弃;08-06 部署后零复发但旧日志已随容器重建丢失,无法验尸定罪)——补上边界,错误只换页面段、导航留着、`reset()` 可重试,边界内显式 `Sentry.captureException`(error.tsx 会把错误在全局 handler 之前吞掉)。robots.txt 全站禁掉 meta-externalagent:它是链接预览爬虫 facebookexternalhit 的邻居但不是它,只做 AI 训练采集,带不来任何搜索或引流价值,而且它文档承诺遵守 robots.txt——对住宅代理池那种无名氏这招没用,对它有用。
测试:anilist client 新增 2 例(退避超预算→1 次 HTTP、零睡眠、熔断开;睡眠被打断→哨兵与 ctx 错误同链、熔断开),detail 新增冷未命中限流→503 一例(顺带钉死 `errors.Is` 穿透包装链),search 的限流用例随映射改为 503。`go test -race` anilist/anime 两包通过。
---
## [3.7.1] - 2026-08-06
### 新增 — /admin 能看见中文简介回填的进度与状态
P3 让 sweep 跑起来了,但它在后台跑得无声无息:要知道回填到哪了、还活着没有,只能 SSH 上去数 SQL。这一版把它接进现有的富化面板。
**先说一句地基**:这个需求的八成已经建好了——`GetAdminStats` 是单行多子查询聚合(加统计=加子查询)、`EnrichmentBar` 已经是完整的进度+状态+控制面板且轮询都调好了、`QueueStatusFn` 已经在读 river 队列深度。所以没有新页面、新端点、新轮询器、新组件体系。
- **进度用覆盖率而不是进度条**。sweep 是永续 job,候选数会随回填推进而变小、也会因新绑定和冷却到期而变大。套 `processed/total` 的批次模型会显示一个假的完成度——最糟的情况是 sweep 已经卡住,但因为候选数也在变小,进度条反而显示"快好了"。
- **两个心跳,不是一个**。「上次扫描」查 river_job 里 scan 任务的最新完成时间(**它还在跑吗**),「上次写入」查 `max(description_cn_attempted_at)`(**它最近干活了吗**)。单一指标分不出「没活干」和「死了」——回填跑完后 sweep 每小时空转,写入时间会冻在几天前,看起来和 job 挂掉一模一样。
- **队列按 state 拆开**:排队 / 重试中 / 已放弃。`retryable` 的意思是"这条失败了等着重试",混在"有活"里会让 bgm.tv 挂掉时看起来完全健康。
- **信任门槛抽成 `description_cn_eligible` view(migration 0016)**。这个判断此前散在候选查询和写入语句两处,加上仪表盘就是三处。三份手抄的条件会漂移,而漂移的后果是仪表盘报的数和 sweep 的实际行为分家——**一个会撒谎的仪表盘比没有仪表盘更坏**。
- **轮询给 backfill 单独的 30 秒档**。首次回填要跑 31 小时且队列全程非空,套现有的 5 秒档等于开着 admin 页就连续打 22,320 次请求。而 scan 每小时才动一次,5 秒刷新只是在看同一个数字重复 720 遍。
### 修复 — 两处「故障时显示健康」(审查发现)
这次审查抓到的两个问题都在同一条线上:仪表盘在坏掉的时候不能看起来是好的。
- **`rows.Err()` 没检查**。pgx 的 `Scan` 出错会让后续 `Next()` 返回 false,**和干净读完完全无法区分**。一次中途断流会留下被截断的聚合,而截断只会让数字变小——`retrying` 少报、`queued` 少报,正好读成健康。现在读完显式检查,有错就整体走零值 + warn,不 publish 半截数据。
- **四个新计数挂在 `GetAdminStats` 里会拖垮整页**。它们依赖 0016 建的 view,而 `GetAdminStats` 此前只读基表。view 缺失时整条语句挂 → /admin 的用户数、番剧数、富化进度、队列**全部一起消失**。现已拆成独立查询并 soft-fail,`GetAdminStats` 的 SQL 与改动前逐字节相同。
另外纠正了 `rejected` 的语义注释:它涵盖四种「已决」结局,其中 bgm 404(绑定失效)和写入端报错**都不产生 retryable job**,所以队列区块会一片绿,`rejected` 攀升是唯一信号。原注释写的"大 rejected 是正常的"会教运维忽略掉真正的故障。
`go build` / `go vet` / `go test ./...` 全过,`bun test` 477/477(新增 81 条)。migration 0016 在真实 postgres 上跑通,接口实测返回 `eligible=13 done=9 rejected=4 pending=0`,与手工 SQL 逐字一致。
---
## [3.7.0] - 2026-08-06
### 新增 — 中文简介存量回填
P2 把管子接上了,但**存量 17k 行没有任何入口能重新入队**——admin 的 re-enrich 只覆盖 `bangumi_version` 0/1/2,而存量绝大多数已经是 version=3。这一版补上入口。
- **做成 river 周期 job 而不是一次性 CLI**。这让"首次回填"和"日常补漏"变成同一件事:job 每小时扫一次候选,首跑时有约 9,100 行待处理、一天多消化完;之后每次扫只剩每周 `bgm_id_map` 刷新带来的零星几条。跑完它就安静地变成每小时一次索引 SELECT 的 no-op,而哪天有一批行被重新绑定,它不需要谁记得它存在就会捡起来。
- **必须跑在 server 进程内**——bangumi 的限速器是 `*bangumi.Client` 实例上的进程内令牌桶,独立 CLI 会开出第二个桶,对 bgm.tv 的实际速率直接翻倍,而 `AnimGo/1.0` 那个 UA 的白名单是最不该赌的东西。跑在进程内还白捡 river 的断点续跑和重试。
- **专用队列 + `MaxWorkers: 1`**。独立队列买到的是隔离和可单独暂停,买不到并行——三个队列共用同一个令牌桶,多开 worker 只会挤占在线富化的派发槽。
- **不复用 V3**:V3 写 `title_chinese` 是无条件覆盖语义,有些行的中文标题来自 dandanplay heal(比 bgm 的 `name_cn` 更准)。回填 worker 的依赖接口只声明了它需要的两个方法,**类型层面就写不到 `title_chinese` 和 `bangumi_version`**。
- **扫描 SQL 里就带信任门槛**,不只在写入时挡:约 3,200 行是我们持有但没有独立来源确认的绑定,注定写不进去,先滤掉能省下同样数量的无用上游请求——否则每一轮都会重抓一次,而它们永远不会变。
### 修复 — 回填会在 5% 处永久停摆(migration 0015)
初版候选查询用 `description_cn IS NULL` 找活、按 `anilist_id` 排序,注释里写着"处理完的行会自动退出结果集"。**这只对成功的行成立。** 实测约 37% 的 bgm 简介是未翻译的日文原文,被语言闸门拒绝后 `description_cn` 仍是 NULL,于是永远留在候选集里;而按 id 排序让它们的位置固定,会一直堵在每一批的最前面。
这不是"变慢",是硬天花板,且有闭式解——设批大小 B、单行成功率 p,第 k 轮产出 `B·p^k` 行,全生命周期写入总量收敛到:
```
p·B/(1−p) 行,永远
```
B=300、p≈0.6 时约 **450 行**,而待处理约 9,100 行——覆盖率封顶 5%。这个式子同时否掉了"调大批量"这个直觉解法:要覆盖全部需要 `B ≥ N(1−p)/p ≈ 6,100`,即单轮拉走三分之二个番库、吃掉 80 分钟以上的令牌桶,批量化本身就没意义了。
- **加 `description_cn_attempted_at`**,每次**已决**结果都打戳(文本存下、闸门拒绝、bgm 上查无此条),候选查询改按它排序并排除冷却期内的行。被拒的行去队尾而不是堵队头,sweep 就能推进到底。
- **网络失败不打戳**——超时或 5xx 说明不了这一行的任何事情,打戳等于为一个网络问题把它冷藏 30 天。那种交给 river 重试。
- 冷却期 30 天顺带白拿了一个**重试**能力:bgm 的中文简介是社区逐步补的,今天只有日文原文的条目,下个季度可能就有中文了。
- 索引换成 `(description_cn_attempted_at NULLS FIRST, anilist_id)` 的部分索引以支持新排序,`EXPLAIN` 已确认命中。
`go build` / `go vet` / `go test ./...` 全过(含 testcontainer 包)。migration 0014+0015 在真实 postgres 上跑通,并实测验证打戳的行确实退出候选集。
---
## [3.6.0] - 2026-08-06
### 新增 — 中文简介通道(上线时零可见变化)
站点一直对中文用户渲染 AniList 的英文简介,因为从来没有中文简介可渲染。而 Bangumi 的 `Subject.Summary` 是社区写的中文 synopsis,**enrichment worker 每次请求都收到它、然后原样丢弃**——`bangumi/client.go` 早就解析了这个字段,`internal/queue/` 全目录零引用。本次把这条通道打通。
**本次上线看不到任何变化**:迁移跑完两列全是 NULL,页面逐字节不变。存量回填是下一阶段的事(见文末)。
- **schema(migration 0014)** — `anime_cache` 加 `description_cn` 与 `description_cn_source`(CHECK 限定 `bangumi`/`llm`/`manual`)。两列而非一列,是为了让日后的机翻能垫在真人文案下面而不丢失区分能力,也让任一来源可以整批撤回。优先级 `manual > bangumi > llm`。刻意**不加** hash 列——那是机翻判断原文是否变化用的,等写机翻的那个阶段再落。
- **语言闸门 `bangumi.CleanSummary`** — 这是本次最需要实测支撑的一块。拿 150 个通过信任门槛的 prod 条目实拉 bgm.tv 统计:**可用中文只有 57.3%,日文原文占 36.7%**,空 3.3%、过短 2.7%、带 `[简介原文]` 分隔符 1.3%。没有这道闸,超过三分之一的富化行会把用户读不懂的英文换成同样读不懂的日文。闸门做三件事:剥掉 `[简介原文]` 及其后的日文原文、规范换行与剥尾注、然后过「空 / 短于 40 字 / 假名占比 > 0.35」三关。
- 阈值 0.35 有依据:实测分布是干净的双峰,86 条落在 0–0.1(中文散文,假名只来自引用的日文专名),50 条落在 0.5–0.8(日文散文,假名必然多于汉字),中间几乎是空的。**早期试的 0.12 是错的**——它会误杀引用日文原名的中文简介,实测样本 bgm 37459 的中文 synopsis 因为引用「バトルスキッパー」达到 14.7% 假名占比却是纯中文。测试里放了这条真实回归用例专门防止有人把阈值调低。
- **信任门槛写在 SQL 的 WHERE 里** — `UpdateDescriptionCn` 的门槛不是 Go 里先查后写,而是与 UPDATE 同一条语句,因此不存在"判断完到写入"的时间窗。门槛问的也不是 `bgm_match_source = 'id_map'`(那列只记录绑定是怎么建的,0011 之前的老行即使正确也是 NULL),而是**独立权威源同不同意这个绑定**——去 `bgm_id_map` 查有没有同样的 pair。2026-06 审计里有权威答案的 7,183 行一致率 100%,已知错绑全在无映射的尾巴上,正好被挡掉。赌注不对等:标题绑错是一个词错,**简介绑错是整页在讲另一部番**。
- 语句同时按 `bgm_id` 定位,把 job 执行期间发生改绑的窗口也关掉了——只按 `anilist_id` 写的话,改绑会让旧 subject 的简介落到新绑定上。
- `'manual'` 永不被覆盖:那是人工修正,自动 sweep 不该撤销它。
- **零额外上游请求** — worker 只是把已经拿在手里的那个 `/v0/subjects/{id}` 响应体里的 Summary 顺手落库。测试里用 `subjectCalls == 1` 把这条钉成回归断言。写入失败只记 warn 不让 job 失败(简介是增益,V2/V3 的主职责是评分和中文标题);闸门拒绝走 debug 级——实测约四成条目走这条路径,用 warn 会刷屏。
- **前端** — `pickDescription(detail, lang)` 沿用 `pickTitle` 的模式,zh 优先中文、en 恒取英文。命中中文时正文下方渲染「简介来自 Bangumi」署名并回链条目,整段包 `data-nosnippet` 让这段转载文本不参与搜索摘要竞争。
- **SEO 面一行未动** — `generateMetadata`、JSON-LD、og/twitter/canonical/hreflang、缓存配置全部保持原样,仍用英文 description。让中文简介进 SERP 需要分桶灰度加 GSC 观察,是独立阶段的事,代码里加了注释标明这条边界。
- **「展开更多」的截断阈值改按视觉宽度算** — 原来的 `300` 是字符数,而这个单位对两种语言含义完全不同:300 个英文字符是两三句话,300 个汉字是好几段,且汉字全角、视觉宽度是拉丁字符的两倍。实测这批简介,中文全部落在 55–289 字之间而对应英文是 235–1100 字符,于是**整个中文语料都在阈值以下、按钮只对英文读者出现过**。现在按「拉丁计 1、CJK 计 2」的视觉宽度判断,阈值仍是 300:英文行为逐字节不变,中文在 150 字处触发。
刻意没有用「按 `lang` 分两个阈值」这个更省事的写法——中文读者遇到没有中文简介的番会回落英文原文,那段文本必须仍按英文规则判断,所以判据只能看文本本身,不能看界面语言。
`go build` / `go vet` / `go test` 通过,`bun test` 396/396(新增 24 条),`tsc` 非测试文件零错误。migration 已在 prod 真实 schema 上用事务包裹 dry-run 验证语法后回滚,零残留。
**已知边界(下一阶段)**:admin 的 re-enrich 入口只覆盖 `bangumi_version` 0/1/2,而存量绝大多数行已经是 version=3,**目前没有任何代码路径能把它们重新入队**。所以上线后 `description_cn` 只随新番和新绑定缓慢增长,17k 存量不动。0014 已经建好配套的部分索引 `idx_anime_cache_description_cn_missing`,回填 sweep 与周期 top-up job 留给下一阶段。
---
## [3.5.1] - 2026-08-06
### 修复 — /seasonal 的分类筛选点了等于清空列表(静默失效两个月)
在 `/seasonal` 点任何一个分类 chip 都是「0 部 / 暂无番剧」,而不筛选时有 98 部。prod 与本地行为一致,`?genre=Action` 稳定返回 0 张卡片。
根因不在前端:**Go 的 `/seasonal` 端点从来没返回过 `genres` 字段**。前端 `applyFilters` 里 `a.genres?.includes(genre)` 拿到的永远是 `undefined`,可选链一路 falsy,于是筛选把整个列表滤空。
这正是 `lib/types.ts:79-85` 两个月前写下的预言:
> Go API 的 `/seasonal` 只返回 16 列主表,不 join genres 子表。老的 Express 端点会从 Mongo 富化缓存里带出 genres,seasonal 页的筛选依赖它。**Go 切换完成后,要么 seasonal handler 加个 genre join,要么这个字段保持可选、筛选静默失效。**
老栈已于 2026-06-01 退役(`8ba923b`),落到的正是后一种。可选链让它没有报错、没有 500、没有任何日志——只是安静地永远返回空列表。
- `GetSeasonalAnime` 加一列 `genres` 聚合子查询。**没有额外查询代价**:同一条 SQL 的 Hentai 排除条件本来就在查 `anime_genres`。
- wire 由 sqlc 自动生成 `Genres []string \`json:"genres"\``,前端 `SeasonalAnime.genres` 已声明为可选、filter 逻辑一行没改,字段一到位就自愈。
- 顺带更正三处已经过时的「16 列」注释,并补上一条冷启动语义:从未富化过的季度(AniList 现拉)子表还是空的,`genres` 会是空数组、筛选匹配不到;站点实际会列出的都是已富化季度,首次请求就带 genres。
已在 prod 数据上直接验证该 SQL 返回正确分类数组。`go build` 与 `go test` 通过(两个 testcontainer 用例需 Docker daemon,本地未启动,由 CI 覆盖)。
---
## [3.5.0] - 2026-08-06
### 新增 — 内容枚举汉化(词典层)
中文用户此前在站内看到的 genre、format、staff 职位全是 AniList 的英文枚举;而关系条目与推荐位明明拿到了中文标题,渲染时却被 `x.title || x.titleChinese` 一句写死成罗马字。本次把这些"标签类"内容一次性汉化。
**为什么不进 `locales/`** — 这些是数据枚举映射,不是 UI 文案。四套词典(`zh.ts`/`en.ts`/`zh-spa.js`/`en-spa.js`)有漂移前科(3.4.3 刚为此加了 `spaDictCoverage.test.ts` 闸门)。新建的 `lib/contentLabels.ts` 是**服务端与客户端共用的单一普通模块**,结构上不可能漂移,也不进那道闸门的管辖范围。所有 helper 对 `en` 恒等返回原文,让"英文不退化"由类型和实现机械保证,而不是靠人工评审。
- **genre 19 词全覆盖** — 接入详情页标签行、卡片 hover、首页轮播、完结佳作、年度榜、`/search` 与 `/seasonal` 两处筛选器。附完备性测试:AniList 日后新增 genre 会直接让 CI 失败,而不是静默渲染英文。顺带修掉 `/search` 页 "Action 类型的动画" 这种中英夹杂标题。
- **两份硬编码 GENRES 合一** — `SearchFilters.tsx` 与 `SeasonalFilterChips.tsx` 各存了一份 18 项数组(前者注释自陈是从 legacy SPA 的 `constants.js` 逐字节抄来)。现在共用 `FILTER_GENRES`。它刻意是 `GENRE_LABEL`(19 项) 的子集——`Hentai` 是目录里的真实值、详情页必须能渲染,但不作为浏览筛选项。
- **format 补上缺失的 `MUSIC`** — 原映射表只有 6 个值,prod 有 7 个,导致 1,207 部(占 7%)的徽章 fallback 成裸枚举。另外 `MOVIE`/`SPECIAL` 等在 en 下从 `SCREAMING_SNAKE` 变成 `Movie`/`Special`,与 `/seasonal` 筛选器一贯的标签对齐——改动前同屏会出现筛选 chip 写 "Movie"、正下方卡片徽章写 "MOVIE" 的不一致。
- **staff 职位可译了** — 这项原本判断是"自由文本、没救":prod 有 4,794 个不同 role 值。但剥掉 `(eps 3, 7)` `(ED2)` `(12 episodes)` 这类限定后缀后只剩 1,405 个,**55 词表即覆盖全部 104,412 条 staff 记录的 91.8%**。译文保留原限定后缀(`Episode Director (eps 3, 7)` → `演出 (eps 3, 7)`),未命中的原样返回。有意义的括号如 `ADR Director (English)` 不剥离,否则会把不同职位并成一个。
- **解除关系/推荐的中文压制** — 三处 `x.title || x.titleChinese` 换成 `pickRelatedTitle`。实测 prod:推荐位 50,990 条里 **40,354 条(79.1%)** 的目标番有中文标题,关系条目 30,849 条里 15,020 条(48.7%)有——一直白白压着不用。顺带修掉 `title` 为纯空白时是 truthy、会渲染出空 chip 的老毛病。
- **`WatchingSection` 反向修** — 它此前不分语言恒取 `titleChinese` 优先,对英文用户是强喂中文。改为按语言走 `pickTitle`。这条是"英文体验不退化"的双向兑现。
**客户端叶子的取舍** — `getLang()` 服务端恒返 `zh`(ISR islanding 决策),所以 RSC 里就地汉化会让英文用户永久看到中文。详情页最显眼、文字最密集的两处(genre 行、format 徽章)下沉到 `LocalizedChips.tsx` 客户端叶子走 `useLang()`;`SeasonRankings` 是纯 RSC,同理抽了 `RankingMeta.tsx`。**但刻意没有把这个待遇逐条扩展到 staff 职位和关系标题**——那是每页几十个节点,而紧挨着它们的 `pickTitle`/`RELATION_LABEL` 无论如何都是中文,重绘只会买到一个半吊子翻译,却付出真实的 hydration 代价。要真正解决得解开 `getLang` 的钉,那是拿边缘缓存换,属另一个决策。此取舍已写进 `page.tsx` 的文件级注释,防止后来人逐项扩散。
**顺带修掉一个没预料到的布局跳变** — 两个 `loading.tsx` 的筛选器骨架屏硬编码了按英文标签量出来的像素宽度(`"Mahou Shoujo"` → 96px)。中文标签整行窄约 33%(18 个 genre:1462px → 966px),足以在约 1200px 容器上少掉一行——骨架预留两行、真实内容一行,切换时跳一下。新增 `lib/chipWidth.ts` 从标签本身推导宽度,日后改文案不会再悄悄把这个位移带回来。
`bun test` 372/372(新增 46 条)。`tsc --noEmit` 与 `eslint` 在改动文件上均干净。
---
## [3.4.3] - 2026-08-05
### 修复 — 库溢出菜单等 5 处直接显示 i18n 键名
用户截图:溢出菜单里那一项渲染成了 `library.overflow.rescan` 而不是中文。
根因是**两套词典无声漂移**:客户端组件走 `useLang()`,它背后是 `locales/*-spa.js`;而 `zh.ts`/`en.ts` 只有服务端组件读。两者之间没有任何关联,只往 `zh.ts` 加键在 SSR 侧看着好好的,到浏览器就露键名。3.4.0 的 watch-folder 那批工作往 `zh.ts`/`en.ts` 加了 5 个键、`-spa` 一个都没加。`t()` 命中失败时返回键名本身(见 `lib/i18n.ts`),既不报错也不打 warning,所以一路混过了评审和 CI。
- **补齐 5 个键**(zh + en 两份 `-spa`):`library.overflow.rescan` / `rescanBusy`、`library.scanToast.kicker` / `title`、`library.persistHint`。文案沿用 `zh.ts` 里已经定好的(「重新扫描文件夹」「扫描中…」「自动扫描」「发现 {{count}} 个新剧集」)。
- **加防漂移闸门** `locales/spaDictCoverage.test.ts` — 扫描所有用了 `useLang()` 的文件里的 `t("…")` 调用,断言每个键在两份 `-spa` 词典里都能解析,失败时直接打印 `文件:行号 键名`。带 `defaultValue` 的调用豁免(那是显式的"可以缺"契约,如 `player.dropRelease`);另有一条「扫到的调用点 > 50」的自检,防止正则或路径改动让断言变成空跑。实测把 `rescan` 删掉能精确报出 `LibraryShell.tsx:1124`。
- 顺带查清 `zh.ts` 相对 `-spa` 共缺 87 个键,但其余 82 个都只被服务端组件使用(meta/seasonPage/landing/admin 等),不受影响 —— 闸门按**实际调用点**而非键集合求交,不会误报这些。
`bun test` 326/326。
---
## [3.4.2] - 2026-08-04
### 修复 — 播放页/库卡片挂错季度的评分与「查看详情」链接
用户实测:拖入《无职转生Ⅲ》的文件,标题显示正确(Ⅲ),但下面整排徽章全是**第二季**的——★8.1 / BGM 7.3(15,368)/ 13 集 / 夏季 2023 / 已完结,「查看详情」也链到 `/anime/146065`(第二季)。真正的第三季是 178789(2026 夏 / 14 集 / BGM 7.9)。
根因是**匹配到了正确的番剧,却把身份丢掉重猜了一遍**:Phase 1 靠文件哈希命中 dandanplay,拿到的是精确条目(所以标题对);但 `siteAnime`(评分/年份/集数/anilistId)不沿用这个结果,而是拿标题回头去 `anime_cache` 做 ILIKE 模糊搜索,再 `rows[0]` 盲取第一行。而 ① SQL `LIMIT 10` 没有 `ORDER BY`,返回顺序是堆序;② 取行时零打分、零阈值。实测 prod 搜「无职转生」返回顺序恰好是 `[146065(S2), 166873, 178789(S3), …]`,于是每次都稳定取到第二季。前端 `useSiteAnimeForSeries` 有一份同源的孪生 bug(`hits[0]` + 双向 `includes` 一律 50 分,季号不参与打分)。
- **精确身份优先(治本)** — dandanplay `/api/v2/bangumi/{animeId}` 的响应里本来就带 `bangumiUrl`(bgm.tv subject)和 `onlineDatabases` 的 AniList 链接,只是客户端从没解析。现在解析进 `EpisodeData.AniListID/BgmID`,`/match` 按 **anilistId(anime_cache 主键)→ bgmId → 模糊搜索** 的顺序解析 siteAnime。前两条是精确命中且**不产生任何额外上游请求**——这个 payload 本来就为了取剧集列表拉过了。注意 `bangumiId` 字段是个陷阱:它重复的是 dandanplay 自己的 animeId,不是 bgm.tv subject。
- **模糊兜底不再可能挑错季** — 新增 `seasonmatch.go`:从标题抽「季号 / 部分号」二元组(第二季 / 第2期 / Ⅱ / II / Season 2 / 2nd Season,以及独立的 第2部分 / 第2クール / Part 2 / Cour 2),季号不一致直接**淘汰**而非降权。相似度沿用已有的 `bangumi.TitleSimilarity`(它会剥季号标记,所以只回答「同一系列吗」),季号守卫回答「同一部吗」——两个信号各司其职,缺一不可(S2 和 S3 的标题相似度约 0.85,光靠相似度分不开)。挑不出就返回 null:**卡片不显示徽章,远好过显示错的**。
- **Phase 2 一并修** — 它按顺序走候选、取第一个能解析出剧集的,而一个系列的**每一季**都能解析出第 1-n 集,所以先返回的那行就赢了。现在先按季号过滤 + 相似度排序再走。
- **SQL 补 `ORDER BY anilist_id`** — 纯为确定性:没有它,`LIMIT` 截断的是任意堆序切片,同一个查询能随 vacuum 漂移。相关性排序放在 Go 里做(SQL 表达不了季号守卫)。
- **前端同修** — 新增 `lib/seasonMatch.ts`(go-api 那份的浏览器孪生),`useSiteAnimeForSeries` 去掉 `hits[0]` 盲兜底。这里的文本比较**故意剥掉季号标记**:dandanplay 写「无职转生Ⅲ」而站内缓存写「无职转生 第三季」,带着序号比字符串会让正确的一对看起来毫不相干;季号身份交给抽出来的序号单独判定。
- **测试** — go 侧 21 个新单测(季号抽取全notation 表驱动 / 五季五部精确区分 / 宁可返回 nil 也不返错季 / 三条腿优先级 / 关键词放宽搜索但标题定裁决 / 跨链接解析与缺失容错),前端 19 个。顺带修正 4 个既有 Phase 2 fixture——它们的行没有任何标题,等于把「随便哪行都行」写进了断言,而那正是这个 bug 本身。go 全量 + `bun test` 323/323 通过。
- **已知残留**:修复前导入本地库的卡片,`titleZh/posterUrl` 已按错的季度落库,需要用溢出菜单「重新匹配」刷新;新导入不受影响。
---
## [3.4.1] - 2026-08-04
### 修复 — watch-folder 新集并入已有番剧卡(跨批次归属)
3.4.0 上线当晚用户实测发现:自动扫描进来的新集**不并入已有的番剧卡**,而是另开一张新卡,溢出菜单的「合并重复」也无效。根因:跨批次合并此前只有一条路——dandanplay 给新文件匹配出 animeId 且旧系列也带同 animeId 的 season;**刚放出的新集 dandan 哈希库往往还没收录**(匹配失败→本地标题路径→铸新卡),clusterizer 预留的跨批次标题提示(`_titleHint`)从未接线(DB season 行没有该字段,索引恒空),本地标题系列更是连 season/animeId 都没有;「合并重复」按 animeId 去重,对无 animeId 的新卡自然无感。
- **三层"归属守卫"补齐跨批次合并**(强度降序,任何歧义都放弃自动合并、留给手动):①内容 hash(3.4.0 已有)②**同文件夹**——新文件所在目录的既有文件全部属于同一系列即并入(watch-folder 主场景:qB 按番剧分文件夹)③**同标题**——规范化标题词元唯一命中一个既有系列(平铺下载目录场景),带季号守卫(S2 文件不并入仅 S1 的卡)与碰撞墓碑(两系列同名→不猜)。
- **dandan 身份优先**:dandanplay 明确识别为其他番剧的文件绝不被文件夹/标题启发式拐走。
- 索引构建 best-effort(fake db 缺 toArray 自动降级,不破测试);派生归属无 animeId 不写 matchCache(避免裸缓存条目)。
- **测试**:6 个新单测(文件夹并入/混合目录跳过/标题唯一并入/同名碰撞跳过/季号冲突跳过/dandan 优先);OPFS e2e 扩展成完整回归——第二集写入同文件夹后卡片数必须仍为 1、episodes 到 2;顺带修掉 e2e fixture 文件名带 "[E2E]" 被解析器读成"第 2 集"的自坑(全部文件解析成同一集号,第二个文件被当成同集备用源)。全套 304/304 + sandbox e2e 3/3。
- 存量重复卡清理路径:升级后把重复的新卡「移出库」,再点菜单「重新扫描文件夹」,文件会重新导入并并入正确的卡。
---
## [3.4.0] - 2026-08-04
### 功能 — 播放库 watch-folder 自动更新(一期):下载丢进文件夹,回到网站就能看
此前 `/library` 没有任何重扫路径:新下载的剧集必须重新点「+ 添加文件夹」、重挑同一个目录、全库每个文件重读 16MB 做哈希才进得了库(还每次多铸一条 fileHandles 记录)。本次给库补上「对账式增量扫描」—— 打开 /library 或切回标签页时,用已持久化的目录 handle 静默重枚举、与已导入文件 diff、只把新文件喂进现有导入管线,成本 O(新文件)。方案经 6-agent 调研 + 反驳验证 + /plan-eng-review 全流程评审(8 项决策,详见 workflow 设计文档)。
- **增量 diff 扫描(`rescanService`)** — 全库 `(relPath,size)` 基线(历史多 libraryId 脏数据天然免疫,重复 root 第二次枚举 0 新增);**60s 静默期守卫在哈希阶段之前**挡还在写盘的种子文件(BT 乱序写,前 16MB 不完整会产生脏 hash);同 relPath 但 size 变化 → 按新文件导入 + 旧 fileRef 行打 `supersededAt` 标记(不删行,Episode.primaryFileId 引用保持有效,播放按 relPath 解析天然拿到新内容)。
- **纯控制器 + 薄 hook(`rescanController` / `useAutoRescan`)** — 全部决策逻辑(守卫链、60s 节流、roots 按 lastSeenAt 降序 + isSameEntry 内联去重、per-root 故障隔离、会话级失败跳过集防「持续失败簇每轮重哈希 + 重打 dandanplay」的重试风暴、手动导入 cancel + 5s 有界等待抢占)在纯函数模块里,bun test 全覆盖;hook 只做 mount / visibilitychange 两个触发点的接线。权限契约:自动路径只读、**绝不** `requestPermission`;granted 静默跑,prompt 落回现有 reauthorize 手势;扫描失败的 root 走 `refreshHandles()` 进现有可用性 UI,不进 console(e2e 零报错断言不破)。
- **关键修复:reuse 导入现在会刷新 `series.updatedAt`** — `persistFileRefsOnly` 只写 episodes/fileRefs,而 useLibrary 的 liveQuery 只监听 series 表 → 老番出新集从来不会浮进 NewAdditionsRow(注释写了设计意图但代码路径缺失)。补 `seriesRepo.touchSeries()` 在 reuse 分支回写,新集现在会自动浮到「新加入」行。
- **UI** — 静默扫描以 mini pill 呈现(不弹全屏导入抽屉);完成后 toast「发现 N 个新剧集」;溢出菜单新增「重新扫描文件夹」(手势路径,denied root 先走授权,并清空失败跳过集强制重试);UnavailableSeriesSection 下新增持久授权引导(授权弹窗选「每次访问时都允许」或安装 PWA,重启浏览器后免点击)。
- **测试** — 新增 29 个单测:rescanService 分支全覆盖(9)、控制器守卫链/排序/隔离/抢占(16)、importPipeline reuse 分支 IRON 回归 + touchSeries(4,经真实 runImport + fake Dexie 表面);e2e seeded 用例加「自动扫描不得弹全屏 scrim」断言,现有两条 spec 一行未改。`bun test` 280/280;生产构建编译 + TS 检查通过。
- **(PR-2)管线加固** — 修掉一个存量崩溃:matchCache 里缓存的裸 `{kind:'new'}`(无 animeId,即"本地标题导入 + dandan 未命中"的产物)被复用时没有 seriesRecord 可持久化,`upsertCluster` 校验直接抛错、整簇计失败——每次重导入本地标题系列都触发;现按缓存未命中处理、重新匹配。新增**重导入守卫**:簇内每个文件都已有归属(同内容哈希 → 同 fileRef id → 各自挂在同一系列的剧集上)时不再铸新系列、翻转为 reuse——堵住"缓存过期 + dandan 不可达 → 重复卡片"和移动/改名场景;文件跨两个系列、或命中 ambiguous 行时守卫保守跳过。派生 reuse 无 animeId 不写缓存(避免再造裸条目)。5 个新单测。
- **(PR-2)player 页 tab-return 扫描** — `useAutoRescan` 增加 `enabled` / `triggers` / `shouldDefer` 选项(shouldDefer 进控制器守卫链,可单测);PlayerShell 在 library 模式挂 hook:仅 visibilitychange 触发(挂载扫描是 /library 的职责)、`playbackPhase === "playing"` 时让位(哈希 worker 不与解码抢 CPU)、扫到新集后调 `seriesDetail.refresh()`(它是一次性加载非 liveQuery)让 EpisodeNav 立即出现新集——"看完第 6 集,下完第 7 集切回播放页点下一集"的续看场景就此闭环。
- **(PR-3)FileSystemObserver 实时监听** — `folderWatcher.js` 薄适配(规格未入 whatwg/fs,所有触点防御式包裹、调用方只依赖模块自有接口):特性检测(Chrome/Edge 133+ 桌面默认启用,Firefox/Safari/Android 与 Playwright 自带 Chromium 均无 → 自动降级 no-op)、`observe({recursive:true})`、事件一律当"提示"——落地动作永远是同一个 diff 扫描;appeared/modified/moved/disappeared → 10s debounce;`unknown`(官方语义"丢过事件,请轮询")→ 立即扫;`errored` → 转入可用性重探。控制器新增 `watch` 触发档位(10s 细节流)与**静默期推迟重试**:扫描发现"还在写盘被推迟"的文件时定一次 65s 后的单发重试,页面开着时刚下完的文件也能在守卫窗口过后自动进库(此前会一直等到下次切换标签页)。hook 按 handlesStatus 对每个 ready root 起观察、卸载时 disconnect + dispose。12 个新单测(watcher 8 + 控制器 4)。
- 已知边界:FSA 为 Chromium-only(与现状一致);重启后能否静默扫描取决于用户授权选项(引导文案即为此);实时监听仅在页面存活期生效,挂载对账扫描始终是兜底真相。
---
## [3.3.0] - 2026-06-19
### 开源 — 项目改用 AGPL-3.0 许可 + 社区贡献治理骨架
AnimeGo 从「公开可见但专有(All rights reserved)」正式转为 **GNU AGPL-3.0 开源**并开放社区贡献。本次只动许可证与治理文档,**无任何运行时 / 代码行为改动**。
- **许可证:专有 → AGPL-3.0** — `LICENSE` 由原「仅供透明 / 参考、未授予任何使用权」的专有声明替换为官方 AGPL-3.0 全文。选 AGPL 而非 MIT/Apache 的理由:强 copyleft + **网络服务条款**(别人若把修改版当线上服务跑,必须向其用户公开修改后的源码),既防被闭源 fork 走、又与同类动漫弹幕项目(Animeko 等)的许可一致。`README.md` / `README.zh.md` 的 License 与 Contributing 两段同步翻新(EN+ZH),旧的「proprietary / 暂不接 PR」措辞全部替换。
- **可单方面 relicense 的前提已核实** — 全部提交的人类作者均为本人的不同 git 身份;历史里仅有的 `Co-authored-by` 是 Claude 各型号(Anthropic),AI 产出不构成独立版权人 → 本人为唯一人类版权人,有权单方面把许可从专有改为 AGPL。
- **DCO 而非 CLA** — 贡献走 [Developer Certificate of Origin 1.1](DCO):贡献者保留各自版权、仅签核(`git commit -s`)声明有权提交,符合 AGPL 社区惯例、对贡献者更友好。代价(已知并接受):DCO 下无法日后单方面把整库闭源或卖闭源 fork(需全体贡献者同意),要留这条路才需改用 CLA。
- **磁力子系统不接外部贡献(三层围栏)** — 种子 / 磁力元数据搜索子系统(`go-api/internal/torrents/`、`TorrentModal*`)因法务与维护原因由 owner 独维,**不接受外部代码 PR**(欢迎提 issue):`.github/CODEOWNERS` 锁目录 + `CONTRIBUTING.md` 列明 out-of-scope + `PULL_REQUEST_TEMPLATE.md` 加「本 PR 不碰磁力子系统」勾选项。注:真正 block 合并要在 GitHub 后台给 `main` 开「Require review from Code Owners」,该开关属仓库设置、不在本仓文件内。
- **新增治理文件** — `CONTRIBUTING.md`(DCO + 贡献范围 + Conventional Commits + 本地测试命令)、`CODE_OF_CONDUCT.md`(Contributor Covenant 2.1)、`SECURITY.md`(私密漏洞披露,不走公开 issue)、`DCO`、`.github/` 下 PR 模板 + bug/feature issue 表单 + config。
---
## [3.2.2] - 2026-06-05
### 性能 — 首页走 CF 边缘缓存(冷启动 1.6s → 0.12s)
详情页 `/anime/*` 早先已上 CF 边缘缓存(实测 HIT ~0.2s),首页 `/` 却一直 `cf-cache-status: DYNAMIC`、冷启动 TTFB 1.6s —— 每次访问(含爬虫 + 匿名 SSR 读)都回香港源站全量渲染,正是当初引发详情页 500 爬虫事故的那批流量。本次把首页也搬到边缘。
- **根因:3 个服务端 `no-store` fetch** — `safeSchedule` + `ContinueWatching` + `ActivityFeed`,任何一个都让整页 dynamic;其中两个还**读 cookie 渲个性化数据**(你的在追 / 关注动态),HTML 因人而异,**不能直接缓存**(否则一个人的追番列表会被发给所有匿名访客 = 缓存投毒)。`page.tsx` 那句"prod 有 CF HTML cache 兜底"的旧注释实测是错的(一直 DYNAMIC,从没缓存到)。
- **修法:把两个按用户区块 island 化** — 新增 `useAuthGatedList` hook(`auth_hint` cookie gate → `authFetch` → loading 骨架 / 匿名 stub / 数据;SSR 初始恒渲 loading 骨架,人人一样、可安全缓存),`ContinueWatching` / `ActivityFeed` 从服务端组件改成客户端岛(挂载后自取,匿名零 auth 请求)。沿用 Navbar / 详情页同款 `auth_hint` 探测模式。
- **`force-dynamic` 故意保留** — 首页是无参静态路由,**不能**像 `/anime/[id]` 那样靠 `generateStaticParams→[]` 躲成 ISR-on-demand;而构建时 go-api 不可达(`GO_API_INTERNAL_URL` 是 runtime env 非 build arg),首页若转 ISR 会把空数据烤进构建、每次部署后首页空 60s。故保留 `force-dynamic`(每次源站渲染拿真数据),缓存放边缘。
- **CF Cache Rule on `/`** — Edge TTL 选「Ignore cache-control header and use this TTL = 1 分钟」(覆盖源站发的 no-store)+ serve-stale-while-revalidating;带 `session`/`refreshToken` cookie(登录用户)或 `rsc` header(客户端导航预取)的请求 bypass。
- **实测**:匿名首次 MISS → 之后 HIT、TTFB **1.6s → 0.12s**;登录用户 / RSC 请求全 `DYNAMIC`(拿个性化、不吃缓存);匿名 HTML 只有骨架、无登录 CTA、无用户数据。tsc / eslint / 生产构建(`/` 仍 `ƒ Dynamic`)/ `useAuthGatedList` 单测(7)全过。部署 prod + CF 规则上线 + 四项 live 验证通过。(PR #45,`5484478`)
---
## [3.2.1] - 2026-06-03
### 修复 — 公开页用户名 %20 · 主页背景显示错番
3.2.0 上线后用户报的两个 profile 页 bug,均已修复部署。
- **公开页用户名显示成 `the%20creator`(含空格用户名)+ 粉丝/关注页 404** — 带空格的用户名(如 "the creator")在路由里是 `the%20creator`,**Next 16 不解码动态路由参数**,所以公开页 `/u/[username]` 直接把编码后的串当名字渲染(大标题 + `<title>` 都中招)。更隐蔽的是 followers/following 页对**本就已编码**的 slug 又 `encodeURIComponent` 一次 → `the%2520creator` → 后端解一层得到 `the%20creator`、查无此人 → **404**,整个粉丝/关注页打不开。**修法**:加 `lib/username.decodeUsername`,在每个页面边界把参数解码一次用于**显示**,所有拼 URL/接口的地方统一 `encodeURIComponent`(主页 + 粉丝页 + 关注页 + hero 链接 + 关注按钮 + 关注列表行 + 分享)。原则:代码里用解码后的真实用户名,只在 URL 边界编码。(`2f0aeea`)
- **`/profile` 主页大背景显示的不是所选番剧(退回了第一部番)** — 用户在设置里选了某番当背景,导航右上角 mini-card 显示正确(服务端按真实 anilist id 解析),但主页大背景却是另一部。**根因**:`ProfileClient` 把候选背景列表 `slice(0, 60)` 砍到 60 部,订阅 ≥ 61 部的用户若所选背景排在 60 名之后就 `find` 不到,`ProfileHero` 又错误地退回 `backdropOptions[0]`(用户的**第一部**番)→ 显示错番。导航走的是服务端解析(不砍),于是两边不一致。**修法**:把服务端已解析好的 `backdropBannerUrl`/`backdropCoverUrl`(与导航同源)传进 `ProfileHero` 优先用,彻底绕开 60 上限;去掉"匹配不到就用第一部"这个错误兜底(只在用户**没选**背景时才用第一部当自动默认)。同样的"选了但不在列表里 → 回退默认而非乱显示"守卫也加到了公开页 hero。纯前端改动,live 域名验证(标题/h1 解码、粉丝关注页 200)。
---
## [3.2.0] - 2026-06-03
### 会员通行证 · 个人设置页 · 头像文件存储
会员主页升级成「会员通行证」,新增标准设置页,头像改文件存储 + 全站统一。一次大特性(PR #29),含上线前一轮 `/qa` 多代理审查的加固。
- **会员通行证(`/profile` + 公开 `/u/[username]`)** — 个人页做成电影感的「会员通行证」卡(把 Pokémon TCG 全息倾斜光泽引擎移植成 React hook),带专属编号 `#AGC-xxxxxx`、入会年份、状态徽章。卡面 + 背景都从 DB 读(`avatar_url` / `backdrop_anilist_id`),所以**别人(含匿名访客)也能看到你选的照片和背景番剧** —— 公开页本就是分享 / SEO 入口。背景用所选番剧的宽幅 banner(非拉伸封面)。
- **`/settings` 设置页(中英双语)** — 改用户名、上传 + 裁剪照片(canvas 前端先缩到 1280px 再 `toDataURL` JPEG q0.9,约 50–120KB)、选背景番剧。**自助改密码移除**(会话被劫时不安全)→ 引导到登录页「忘记密码」走邮箱验证流。右上角实时 mini-card 预览。
- **头像文件存储(go-api `/api/avatars/*`,migration 0012)** — 原方案把头像当 data URL 存进 DB,每条 `/me`/在追/关注/评论行都驮着约 100KB base64、撑爆响应又不可缓存。改为:解码 → 校验 → 重编码 JPEG(剥 EXIF)→ 写命名卷 `{uuid}.jpg` → 存短 URL `/api/avatars/{uuid}.jpg?v={unix}`(`?v=` 变更即刷 CF);go-api 直接服文件,不动 nginx + CF 源站防火墙。`users` 加 `avatar_url` + `backdrop_anilist_id` 两列。
- **导航方块头像 + 横幅 mini-card** — 导航换 Steam 风方块头像,下拉头部是所选番剧的横幅 mini-card;管理后台入口按 role 门控。
- **全站头像一致** — 在追「X 人在追」、关注列表、评论的头像统一回退链:照片 → 所选番剧封面 → 默认卡图(列表查询 `LEFT JOIN anime_cache` 取背景封面)。
- **订阅状态探测 404 → 200** — 详情页每次都探「是否已订阅」,未订阅返 404 在控制台刷红、像 bug;改为 `200 {"data":null}`(前端读 null 即「可订阅」)。
- **`FallbackImg`** — AniList 封面会轮换 hash 导致 404,加一次性 `onError` 兜底切默认卡图(在追 / 关注 / 评论 / 导航 / 设置都接)。
**上线前 `/qa` 加固**(3 个子代理审 + 浏览器全面 QA,无 CRITICAL):
- **头像上传抗解压炸弹** — `image.Decode` 在尺寸检查前就分配整张 W×H×4 RGBA,一张小压缩图能撑到几十 MB OOM 容器;改用 `image.DecodeConfig` 先读头部尺寸再拒,不分配像素缓冲。
- **`avatarUrl` 锁死自家 URL** — 默认分支原样存任意 URL → 可被塞外链当全站追踪像素;限定只收 `/api/avatars/` 前缀。
- 容忍换行 base64 + JPEG/PNG 白名单 + 精确错误提示;清头像先清库再删文件(防写失败留悬空指向)。补 `avatars` 包首份测试(含解压炸弹 / 路径穿越)。
- 评论发表 / 删除失败原来静默吞 → 加双语 toast;背景图 `url()` 过 `cssUrl()` 守卫(纵深防御);头像 `alt` 补用户名(无障碍)。
部署 prod:迁移 0012 已应用、`avatars` 命名卷创建、live 域名验证(`/u`·`/settings`·`/api/avatars`·首页全 2xx)。(#29)
---
## [3.1.5] - 2026-06-03
### 修复 — 详情页 AniList 上游抖动时 500(降级失效)
- **`/anime/{id}` 间歇 Internal Server Error** — go-api 详情接口在缓存过期时会向 AniList 重拉刷新。根因双重:① `refetchFromAniList` 注释声称用 15s 独立超时"不占用 5s 请求预算",但 `context.WithTimeout(parentCtx, 15s)` 会被父 ctx 的 5s `queryTimeout` 截断,重拉实际把 5s 预算吃光;② AniList 上游变慢/失败时重拉耗尽 5s 后失败,本应"返回旧缓存"降级,但紧接着的 `enrichRelations(ctx)` 用的还是那个**已到期的 ctx** → `context deadline exceeded` → 500 `query failed`。于是 AniList 一抖,所有缓存过期的详情页(SEO 主战场)就 500。**修法**:重拉失败时直接用**已读到的旧行**组装返回(关系富化走无 DB 的 `convertRelationsToDetailRelations` 兜底,不再在到期 ctx 上查库),并把旧结果写入缓存——避免上游抖动期一堆过期页各自重试 5s 拖垮 worker 池。go build / vet / `go test ./internal/anime/...` 全过。
---
## [3.1.4] - 2026-06-03
### 修复 — 登录态"假掉线" + 登录后导航不刷新
- **反复"掉登录"被迫重登 / 登录成功后导航栏不变(需手动刷新)** — 两个症状同一处根因:`Navbar.tsx` 的登录态探测。排查实据:go-api 与 next-app 的 `JWT_SECRET` 一致、容器稳定运行 23h 且 0 重启、`proxy.ts`(Next 16 新版 middleware)的服务端 session 刷新在跑 —— **排除密钥失效 / 部署冲 token / middleware 没跑**。真因:① Navbar 用**裸 `fetch("/api/auth/me")`**,15 分钟的 `session`(access token)一过即 401 → 显示未登录,但 7 天的 `refreshToken` 仍有效、裸 fetch 不会刷新 → **假掉线**,用户误以为 JWT 失效而重登;② 探测 effect 依赖为 `[]`,只在挂载跑一次,客户端登录后 `router.refresh()` 不重挂该客户端组件 → 导航卡在未登录、须整页刷新。**修法**(只动 `Navbar.tsx`):`/api/auth/me` 改走 **`authFetch(..., { skipRedirectOnFailure: true })`**(401 自动用 refresh token 刷新重试,session 过期自愈;真过期则渲染匿名、不强跳 /login);effect 依赖改 `[pathname]`,登录跳转后即刻重探、导航实时更新。本地 typecheck + eslint 通过。
---
## [3.1.3] - 2026-06-02
### 详情页 AggregateRating 富媒体修复 — 补 ratingCount
- **GSC 报「应指定 ratingCount 或 reviewCount」(38 个 `/anime/*` 页无效)** — 详情页注入的 `TVSeries` JSON-LD 里的 `AggregateRating` 用的是 AniList `averageScore`,而 Schema.org 要求 `AggregateRating` 必须带评分人数(`ratingCount`/`reviewCount`)。查后端,AniList 这条数据**只存分数、无投票计数**(`go-api` schema 仅 `average_score`),凑不出 count → Google 全部判无效 → 这些页拿不到搜索结果里的⭐星级富媒体(从 5/27 起随新页收录涨到 38)。**修法**:改用 Bangumi 的 `score + votes`(`bangumi_votes` ← `Subject.Rating.Count`,有真实计数),且与详情页本就可见的「★ x.x (n)」徽章一致(满足 Google「结构化数据须与可见内容一致」)。门槛 `bangumiVotes > 0` 才输出 rating;无 Bangumi 票数的冷门页不输出评分(AniList-only 本就无效,输出也是白报错)。被收录的多为高分热门番、Bangumi 票数齐全,覆盖面几乎不受影响。部署后在 GSC 点「验证修正情况」清掉 38。
---
## [3.1.2] - 2026-06-02
### 全屏字幕闪修复 · 品牌名统一 AnimeGoClub · 详情页按钮解 gate · CI 清理
3.1.1 之后陆续合并的几项,本批一起记录。
- **播放器全屏字幕闪(控制条 / 弹幕热图)** — 全屏下每当字幕换行,底部控件 + 弹幕热图跟着闪一下(每句对白一次、暂停即停、关字幕也闪因为轨 still `hidden`、窗口模式太小看不出)。**真根因(live 调试定位)**:Chrome 原生全屏把 `<video>` 提到**硬件 overlay 平面**(位于 DOM 之下、独立扫描输出),而字幕/控件/热图是 DOM 层合在上面;每次 `cuechange` 重写 `.art-subtitle` innerHTML → overlay 平面 ↔ DOM 层重新合成不同步 → 那块闪。**先试的「提 DOM 合成层」(给 `.art-subtitle`/`.art-control-heatmap` `translateZ(0)`)只减轻没根除** —— 不同步在「overlay vs DOM」之间、不在 DOM 内部。**根治(#24)**:全屏时给 `<video>` 加 `filter: opacity(0.999)`,让 Chrome 无法把它放上 overlay 平面 → 视频 + 字幕 + 控件走同一条 GPU 合成路径、保持同步;作用域限定全屏(`.art-fullscreen` / `:fullscreen`),窗口播放保留高效 overlay、不付额外功耗。排错:DevTools `cuechange` 日志定位单条 hidden 字幕轨 + 逐档 live 注入 CSS 验证(提层全失败 → 视频脱离 overlay 成功)。cf. Stremio #644。(#22, #24)
- **品牌名全站统一为 AnimeGoClub** — 之前分裂:首页 title/h1/JSON-LD = AnimeGoClub,其余页 title 后缀 / `og:site_name` / 导航 logo / 页脚 = AnimeGo。AnimeGo 是红海通用词(撞俄罗斯盗版站 AnimeGO.org),animegoclub 与域名一致、独特、能稳排第一;分裂信号拖慢 Google 实体消歧。统一搜索信号 + 视觉字标为 AnimeGoClub(title 模板 / 各页 og:site_name / titleDefault / 详情页·`/u`·`/search`·`/welcome`·`/calendar`·`/faq` 标题 / navbar / footer / 登录注册页 / spa locales);仅保留 JSON-LD `alternateName` + keywords 里的 "AnimeGo" 当别名。顺带修了 `/calendar`+`/faq` 预存的双品牌标题 bug(plain title 内嵌品牌又被模板追加)。(#21)
- **详情页磁力 / 播放按钮不再 gate 在 AniList 集数** — 之前 `episodes` 为 null(连载中番剧 AniList 不给总集数)就把磁力 + 播放按钮藏了;但种子搜索靠标题、播放器自己选集,与集数无关。改为始终显示。(#19)
- **删废 CI workflow** — `e2e-sandbox.yml` 启的是已退役的 Mongo/Express 老栈(`docker-compose.yml` 早无 mongo),每个 PR ~10 秒就挂、刷失败邮件、给 PR 挂红叉、零信号。删除;保留其余 workflow 的真失败邮件。残留 sandbox 套件(specs / config / globalSetup 的 mongo seed)记 TODO 单独清。(#20)
**已知 / 延后**:`HeatmapTuner` + `VideoPlayer` 设的热图自定义样式 CSS 变量(`--heatmap-h` / `--heatmap-scale-y` / `--heatmap-fill` / `--heatmap-opacity` / `[data-heatmap-always]`)在迁移时整块丢失,仓库里无消费这些变量的规则 —— 热图当前走 artplayer 默认样式(`applyHeatmapPath` 仍直接改 SVG path,形状正常)。与本次闪修复无关,单独一轮重建。
---
## [3.1.1] - 2026-06-01
### 修复 — 播放器字幕缓存 · 详情页 SEO 信号 · 品牌图标 · 标题重复
ISR 上线当天修掉的几个用户可见问题(均已部署上线)。
- **播放器字幕偶尔不出(jassub WASM 缓存)** — 2MB 的 jassub worker WASM(`/jassub/wasm/jassub-worker.wasm`)用 Next `public/` 默认 `max-age=0` 服务,且 Cloudflare 默认不按扩展名缓存 `.wasm`(`Cf-Cache-Status: DYNAMIC`),每次开播放器都从香港源站重下 2MB;慢加载时超过 jassub 10s init 预算 → 字幕暂时不出(控制台 `[jassub] renderer.ready still pending after 10s`)。加 `next.config` `headers()`:`/jassub/:path*` → `max-age=2592000`(30 天,不加 `immutable` 因路径非 content-hash、升级可自愈)。CJK 回退字体走本地系统字体(Font Access API),非网络瓶颈。(#15)
- **假 hreflang** — `/anime/[id]` 的 `generateMetadata` 对外宣称 `?lang=en` 是 en-US 版,但 `getLang()` 恒 `zh`、服务端忽略该 query,实测吐相同中文 → 无效 hreflang + 重复缓存键。删 `alternates.languages`,留 zh canonical,等真有本地化 URL 再加。(#15)
- **详情页匿名守卫** — `/anime/*` 被 CF 边缘缓存,`page.tsx` 顶部加注释守卫:服务端渲染须保持匿名(勿读 `cookies()`/`headers()`/per-user fetch),防未来回归把某用户的个性化页缓存给所有人。(#15)
- **浏览器标签图标** — `app/favicon.ico` 一直是 `create-next-app` 自带的黑底白三角占位图(P3.0 skeleton 起从没换过),品牌角色图只放进了 `public/`(`apple-touch-icon.png` / `favicon-192.png`)。从角色图重新生成 `favicon.ico`(48×48,9.8KB)。(#16)
- **标签标题 AnimeGo 重复** — player/library/admin layout 设 `title: dict.X.pageTitle`,过根模板 `"%s . AnimeGo"`;而 pageTitle 串已含 "— AnimeGo" → 渲成 `我的库 — AnimeGo . AnimeGo`。去掉 pageTitle 里的 "— AnimeGo"(zh + en 共 6 处)。(#17)
**延后(未做)**:① 详情页 soft-404 — `/anime/{坏id}` 因 streaming `loading.tsx` 返 HTTP 200 非 404;Next 自动注入 `noindex` 已护住索引 → LOW,记 TODO(`global-not-found.js` 实验特性对匹配路由内 streaming notFound 大概率无效)。② CF 边缘缓存规则(`/anime/*` HTML + `/jassub/*` WASM)—— 浏览器 / 源站层已修,全球首次加载的边缘层待建,**不急**,完整方案见 `docs/migration/CF-EDGE-CACHE-PLAN.md`。
---
## [3.1.0] - 2026-06-01
### SEO 详情页 ISR — `/anime/[id]` 从动态渲染变静态缓存(前端 islanding,不动 URL)
`/qa` 巡检 + `/seo` 调查发现:标榜 ISR 的 SEO 页其实全是 per-request 动态 SSR(`Cf-Cache-Status: DYNAMIC`,详情页 TTFB ~844ms),根因是 root layout 渲染时读 cookie/header(语言)+ 一个 no-store 的 `/api/auth/me` 取用户,且 `api.ts` 每个服务端 fetch 都转发 cookie —— 全把页面打成动态。经 eng review + 独立 outside-voice 评审,放弃原计划的 `/[lang]/` URL 分段大迁移(省掉几周 + 全站重索引),改用前端 islanding 的便宜路。
`next build` 现在把 `/anime/[id]` 标为 `●`(SSG/ISR),不再是 `ƒ`(Dynamic)。
- **layout / Navbar**:root layout 不再服务端拉 `/api/auth/me`(no-store forcer)—— Navbar(`"use client"`)挂载时自己取用户,gate 在新的 `auth_hint` cookie 上,**登出访客零 auth 请求**;去掉 `x-pathname` header 读取。
- **i18n**:`getLang()` 不再读 cookie/header —— 服务端渲规范默认 `zh`,`lang-client` 客户端读 `lang` cookie 并在 hydration + 切换时 swap(中文优先,zh 为 SEO 索引目标)。
- **api.ts**:`buildHeaders` 加 `auth` 开关(默认 `true` 不变);公开 ISR fetch(`loadDetail`、watchers)走 `auth:false` 跳过 cookie/header。
- **page.tsx**:去掉服务端 `cookies()` 的 loggedIn 读取(ISSUE-001)—— 客户端组件改读 `auth_hint`;加 `generateStaticParams`(Next 16 里 `[param]` 路由需要它才能 ISR)。
- **go-api**:登录/注册/刷新设非 httpOnly `auth_hint=1` 伴随 cookie、登出清除 —— 客户端可读的"会话大概存在"信号,不读服务端 cookie 也能保住 ISSUE-001 的"登出不探测"。
**验证**:`next build` → `/anime/[id]` = `●`;`bun test` 全绿;go-api auth 测试绿;活体实测 —— 登出详情页 auth 请求 0(旧 3 个)+ 控制台 0 错误,语言切换客户端 swap + 跨页持久。EN 详情内容现为 zh(服务端规范默认),chrome + 客户端岛切 EN。`welcome`/`home`/`sitemap` 仍动态(与 main 一致,可同样 `auth:false` follow-up)。
---
## [3.0.0] - 2026-06-01
### 全栈 Go 架构 + 老栈退役 — Express / Mongo / Vite SPA 全部退场
历时 2026-05-10 → 06-01,把 AnimeGo 从 Vite SPA + Express + MongoDB 整体重写为 **Next.js 16 (RSC) + Go + PostgreSQL** 多语言栈,并在 06-01 物理退役全部 legacy 代码。线上 [animegoclub.com](https://animegoclub.com) 已跑新栈(05-31 切换)。
**架构(现 5 个服务)**
- **go-api**(Go 1.26 · chi · pgx v5 · sqlc · river queue · ristretto):48 个 HTTP endpoint 全量重写替代 Express;类型安全 SQL(sqlc);富化走 river 后台队列。
- **next-app**(Next.js 16 App Router / RSC · Bun 构建 · standalone Node):SSR 接管所有用户路由替代 Vite SPA;SEO 关键页(`/anime/:id`、`/seasonal`、`/search`)服务端渲染。
- **ws-server**(独立 Node + socket.io):弹幕实时层从 Express 抽出成独立微服务。
- **postgres**(PostgreSQL 16 · pg_cron · pg_trgm):单一主存储替代 MongoDB;normalize schema + 生成列全文检索 + 弹幕 TTL pg_cron。
- **nginx**:反代 + SSL,源站锁 Cloudflare 段。
**富化匹配重构**
- 老 `selectHit` 抓搜索 `list[0]`、无相似度校验 → ~10% bgm 错绑(错中文名 + 错评分)。重写为**置信度分级匹配**:权威 AniList↔Bangumi id-map(10,972 条)命中直接绑;否则评分模糊匹配(标题相似度 + 年份 + 集数),高置信绑、**低置信宁可不绑、转人工复核**(「宁可显示罗马音也不显示错的中文名」)。
- 富化版本 v0→v1(绑定)→v2(评分 / 角色中文名 / 集数)→v3(中文补救)分阶段链式。dandanplay 作为**离线独立交叉验证 oracle**(不在请求热路径)。
- 一次性 backfill 修正存量 **340 个 id-map 错绑**(One Piece 等被绑到剧场版的全部修回主条目),零 dandanplay 配额。
**老栈退役(2026-06-01)**
- 删除 `app`(Express)+ `mongodb` 服务、`client/`(Vite SPA)+ `server/`(Express)目录、根 Dockerfile + mongo 迁移工具(共 46,002 行删除)。
- mongo 数据 `mongodump` 归档后删容器 + 卷;prod 内存占用降约 900MB。
**其它**
- dandanplay client 加 429 / 5xx 退避(认 Retry-After + full-jitter 指数退避、ctx 可取消)。
- admin 富化表:表头 / 数据列错位修复 + 匹配来源列(id_map / fuzzy_high / fuzzy_low 徽章)+ BGM ID 跳 bgm.tv 链接。
- Auth:JWT access 15m / refresh 14d,httpOnly cookie,SSR 感知刷新。
- 部署:单 `docker compose` 5 服务 + nginx SSL,VPS 在 Cloudflare 后。
- **分支模型**:`main` = 稳定 / 生产(只进测试过的代码,从此部署);`feat/go-backend` = 活跃开发。
---
## [2.0.1] - 2026-05-11
### libass-wasm 字幕渲染 — MKV 内嵌 ASS 不再乱码
v2.0 上线后看 SweetSub 等组的 MKV,底部字幕区出现 `x□34626□ l□(Q□Up□□1...` 二进制乱流。诊断结论:Chrome 把 MKV 容器内嵌的 ASS 字幕轨自动暴露成 `video.textTracks`,自身没有 ASS 渲染能力,把 SimpleBlock 的原始字节当 plain text 喂到 cue 里。本版本接入 [jassub](https://github.com/ThaUnknown/jassub)(libass 的 WebAssembly 端口)做完整 ASS 渲染。
**新增**
- **jassub libass-wasm 集成**: 当 `subtitleType === 'ass'` 时,挂载 jassub canvas overlay 在 `.art-video-player` 容器内,z-index 在 video 之上 / 控制条之下。完整保留 ASS 的样式、定位、Sign 排版、动画。
- **MKV ASS 完整解码**: `mkvSubtitle.worker.js` 现在解 Matroska ContentEncoding zlib 压缩(SweetSub 等组常用),改用 pako 同步 inflate(替代 DecompressionStream 的 per-block async 调用)。2000+ 事件解压从 ~4s 降到 ~500ms。
- **CJK 字体回退**: 通过 `queryLocalFonts()` 加载系统 PingFang SC / Hiragino Sans / Microsoft YaHei / Noto CJK 作为 libass defaultFont。ASS 引用的混淆字体名(`7GYB4TKY`、`GI6JUCQT`、`PJSM6X93` 等 fansub 用的 subsetted Dream Han SC 别名)全部 fallback 到 CJK 系统字体。Chrome 首次需要授权 "本地字体" 权限。
- **VTT plaintext 兜底**: jassub 加载失败(权限拒、WASM init 失败、SAB 不可用)时自动落回 artplayer 原生 VTT plaintext 字幕,用户不至于"完全没字幕"。
- **Native textTrack disable**: 拦截 Chrome 自动暴露的内嵌字幕轨,disable 时排除 artplayer 自己 inject 的 `<track>` 元素(否则它的 `.cues` 会变 null,`Array.from(null)` 崩)。`loadedmetadata` 和 `addtrack` 双事件覆盖异步出现的轨道。
**Performance**
- 第一集冷启动:点播放 → 字幕首帧约 1.5s(其中 MKV 读 + EBML 扫 + pako 解压 ~1s,jassub WASM init ~500ms)。
- 后续切集:~500ms(jassub module + CJK font 已缓存)。
- jassub WASM 资产 ~2MB,player route 才懒加载。
**Cross-origin isolation**
jassub 内部用 emscripten pthread workers,要求 SharedArrayBuffer,只在 cross-origin isolated context 下可用。`client/vite.config.js` server.headers 加:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Resource-Policy: same-origin
```
`credentialless` 而非 `require-corp` 因为 AniList / Bangumi 海报 CDN 不发 CORP — `credentialless` 用 no-cors 兜底放行跨域图片。CORP 仍要发(spec:credentialless 不放宽 worker/iframe 子资源)。生产部署 nginx 需要同样的 3 个 header,已加进 `docs/migration/MIGRATION_PLAN.md` § Phase 6。
**资产路径**
- `client/public/jassub/` 由 postinstall 脚本 (`copy:jassub`) 从 `node_modules/jassub/dist/` 拷贝 wasm/worker/default.woff2。原因:Vite 直接 serve `/node_modules/jassub/...` 时给 Content-Type: text/html(SPA fallback),worker 加载失败。
- `mkvSubtitle.worker.js` 通过 `?raw` import + `URL.createObjectURL(blob)` 加载,bypass Vite worker pipeline,避免 COEP 下的 transform URL 拦截。pako 内联到同一个 Blob(~24KB)。
- jassub 主 worker 走 Vite `?worker&url` (`dist/worker/worker.js`),Vite bundle 解析 abslink / lfa-ponyfill 等 bare imports。
**Tests**
1342/1342 client tests / 0 failures。`VideoPlayer.test.jsx` 加 9 个新测试(native textTrack 排除 artplayer track / jassub mount lifecycle / VTT fallback / hide-on-success / destroy-on-unmount)。`resolveSubtitle.test.jsx` 和 `usePlaybackSession.test.jsx` 的 createObjectURL 计数 baseline 更新(blob worker URL + VTT URL 两次创建)。
**Tradeoffs**
- CJK 字体回退要 Chrome `local-fonts` 权限。拒绝则降级到 LiberationSans(纯拉丁),CJK 字符渲染为 □ 但 Sign typesetting 和 Latin 字符正常。
- Firefox 不支持 FSA 持久化 + `queryLocalFonts`,本地媒体库这条线 Firefox 仍是 v2.0 之前的状态(本版本不增强)。
- libass 在 console.debug 级别输出 `JASSUB: fontselect: Using default font family...`,每个 ASS 事件一行。用户侧解法:DevTools Console filter 关掉 Verbose 级别。代码侧尝试过 wrapper worker + 预注册 Style fonts,前者破坏 jassub 初始化时序,后者让 libass 在 wasm 里崩 `null function`,放弃。
**Refs**
- [jassub](https://github.com/ThaUnknown/jassub)
- [WICG Credentiallessness](https://wicg.github.io/credentiallessness/)
- [Chrome dev blog: COEP credentialless](https://developer.chrome.com/blog/coep-credentialless-origin-trial)
- [Matroska ContentEncoding spec](https://www.matroska.org/technical/notes.html)
---
## [2.0.0] - 2026-05-10
### 本地影音库 (P1-P5) — 浏览器里的本地番剧管理 + 播放
把 AnimeGo 从"在线追番清单 + 详情页"扩成"本地番剧库 + 弹幕播放器"。所有解析在浏览器内完成,文件不上传,数据存在 IndexedDB。
**新增**
- **Library 模块**: 本地视频库浏览页 (`/library`),支持多文件夹导入、按系列归类、续看、最近添加。
- **File System Access API 持久化**: 支持跨会话保留文件夹访问授权 (Chrome / Edge / Brave)。Safari 走 drag-and-drop fallback (内存模式)。
- **Import pipeline**: 文件夹枚举 → 文件名解析 → 哈希指纹 (md5) → dandanplay 双通道匹配 (文件名 + 哈希) → 自动归类成 Series / Season / Episode。
- **手动覆盖**: 合并、拆分、重新匹配、改集号四种操作。一次选定,后续导入默认这条规则 (`userOverride` 持久层)。
- **24h 操作日志**: 详情页抽屉式 ops log,展示该系列最近的写库操作,支持撤销。
- **Watch progress**: 续看进度 (`progressRepo`) 跨设备本地存储,从 localStorage 自动迁移。
- **Cinematic library UI**: HUD header (// 02 / LOCAL · MEDIA · LIBRARY //) + 横向滚动行 (// 继续看 // / // 最近添加 //) + 主网格 + 详情 sheet + by-folder 文件树 + 控制室 (filter chips · search · watch rhythm strip)。
- **DropZone 极简空状态**: 单 SVG 六边形 + 类型韵律,替换原"灰底无个性"空态。
- **跨字幕组合并**: 自动检测同 animeId 的重复系列并合并,支持撤销。
- **可用性检测**: 硬盘断连时 OFFLINE / PARTIAL 角标,不影响其他系列。
- **未归类区**: 解析失败的文件单独陈列,支持忽略 / 创建本地系列 / 搜索归番。
- **批量操作**: 长按进入选择模式,支持批量合并 / 删除。
- **重置库**: 一键清空 IDB 全部表 (磁盘文件不动)。
**集成**
- **animes.garden 替换 dmhy RSS**: 磁力资源后端从直连 share.dmhy.org RSS 切到 [api.animes.garden](https://api.animes.garden) 的 JSON API。Garden 已聚合 dmhy + bangumi.moe + 其他源,coverage 严格优于直连。结构化 fansub 名称、KB→MB/GB 自动格式化、provider 透传 (在 source pill 显示为 `via dmhy` / `via moe` 工具提示)。
- **Zero-result tripwire**: garden 返回 200 OK 但 resources 为空时打 `console.warn`,oncall greppable,防止上游 schema 变更静默退化。
- **Dandanplay 富化**: 命中后写 AnimeCache,后台拉 AniList 补齐 titleZh / titleEn / posterUrl。
**Player 增强**
- **手动字幕拾取 + ASS/SRT → VTT 转换**: 浏览器内字幕格式转换。
- **解说轨道检测**: 自动识别 commentary 音轨,路由到 supplementary 通道。
- **弹幕模糊修复**: 关掉 `artplayer-plugin-danmuku` 注入的 `perspective: 500px`,跨 Chrome / Safari 字体清晰。
- **§5.10 视觉 token 集中化**: 整套 oklch 色板 + JetBrains Mono / Sora 字体 + LOCAL 六边形标识从散落到统一 `shared/hud-tokens.js`。
**i18n**
- **库页面中英文切换补全**: 60+ 新 key 覆盖 DropZone / FilterChips / SearchBar / BulkActionToolbar / WatchRhythmStrip / 行标题 / SeriesCard 菜单 / SeriesActionsMenu / HudOverflowMenu。
- `useLang()` 加 `FALLBACK_LANG`,组件级单测无需 wrap LanguageProvider。
**Schema 升级**
- IDB schema v5: 新增 `progress` / `userOverride` / `migrationFailures` / `seasons` / `opsLog` 表。
- 一次性迁移: localStorage 旧进度 → IDB,幂等。
**Tests**
- 1327 client tests / 317 server tests / 0 failures (排除过期 worktree 噪声)。
- 测试覆盖含: episode parser、series matcher、import pipeline、merge/split/rematch ops、buildLibraryMatchResult、SeriesCard 渲染、SeriesGrid 长按选择、TorrentModal 源分布、anime.garden 解析 (含 KB→MB 格式化、provider 透传、malformed JSON graceful fallback)。
**修复**
- `callDandan` null-guard + `useLibrary` 在 React 19 strict-mode 下双调用安全。
- `SeriesCard` 标题裁剪到 2 行避免网格卡片高度抖动。
- 弹弹分隔符容错。
**已知 deferred** (写进 TODO.md)
- 持久化磁力 cache (Redis / 跨进程 cache,缓解 cold-start 风暴)
- enqueueEnrichment 支持 bgmId 键入队
- Library 详情页 / 模态对话框 / Drawer 类的 i18n Phase 2
---
## [1.0.18] - 2026-05-03
### 弹幕滚动模糊修复 — 关掉 plugin 的 perspective:500px
**问题:**
- 弹幕在横向滚动时字体严重模糊,看不清,跨 Chrome / Safari 都有
**根因:**
- `artplayer-plugin-danmuku@5.3.0` 给每条弹幕 `<div>` 设置了 inline `perspective: 500px`,触发 3D 渲染上下文
- 配合 `transform: translateX(...)` 的横向位移,浏览器按 3D 透视矩阵 rasterize 字形,在非整数像素位置产生亚像素模糊 + 抗锯齿失效
- `will-change: transform` 已经把元素提到独立 GPU layer,perspective 是好心办坏事
**修复(`client/src/styles/artplayer-overrides.css`):**
- 覆盖 `.art-video-player .art-danmuku > div`:`perspective: none !important` + `transform-style: flat`
- 加 `-webkit-font-smoothing: antialiased` + `text-rendering: geometricPrecision` 做防御
- 弹幕回到纯 2D 平移(仍是 GPU 加速),滚动中字体保持锐利
---
## [1.0.17] - 2026-04-28
### 弹幕热力图视觉重调 — 缝隙消除 + Tuner/Production 同源
**问题:**
- v1.0.16 上线后,进度条上方的弹幕热力图与进度条之间有明显缝隙,缩放越大缝隙越显眼
- HeatmapTuner 调参面板显示效果与刷新后 production 渲染不一致(峰值高度差异),无法所见即所得
**根因:**
- 插件 `artplayer-plugin-danmuku@5.3.0` 把 `yMax` 硬编码为 128,而单集真实 max bucket count 通常 ~10,导致 peaks 只占 viewBox 顶部 ~8%,scaleY 放大后那 92% 的空白就是看到的"缝隙"
- Tuner 走 `buildHeatmapPath` 默认 yMax=128,Production 之前用动态 yMax + 1.18 headroom,两边算法不同,所见非所得
**修复(`client/src/lib/heatmapPath.js`):**
- 新增 `applyHeatmapPath(art, opts)` 共享 helper:动态 `yMax = peak`(无 headroom),peaks 直达带顶,缝隙归零
- VideoPlayer.jsx 与 HeatmapTuner.jsx 同时调用此 helper — 单一真相来源,Tuner 实时预览即 Production 最终效果
**默认值(design review 收敛):**
- 几何: `sampling=7 / smoothing=0.35 / flattening=0.05 / scale=0.011 / minHeight=4`
- 视觉: `bandHeight=2px / scaleY=17.35 / fillColor=#ffffff / fillOpacity=0.4`
- 行为: `alwaysVisible=true`(`data-heatmap-always="1"`),不再随 chrome 淡入淡出
**清理:**
- HeatmapTuner 调参面板从 PlayerPage 卸下挂载,组件本体保留以便后续微调
- 删除生产代码中过期的 control-fade handler 与 1.18 headroom 常量
**测试:**
- VideoPlayer 单测断言更新到新默认值,删除过期的 fade-with-chrome 测试
- 663/663 通过
---
## [1.0.16] - 2026-04-27
### 切换弹幕时旧弹幕残留 — artplayer-plugin-danmuku 5.3.0 API 误用修复
**问题:**
- 用户进入播放页 → 点 "切换弹幕" 选另一集匹配 → 上方弹幕轨道仍是旧集的内容,新弹幕不显示
- 复现 100%:点击播放后任何 `loadComments` 触发的列表更新都不生效
**根因:**
- `artplayer-plugin-danmuku@5.3.0` 的 `load()` 内部只在**不带参**时才走 reset 分支(清 queue / states / `$danmuku.textContent`),源码 `dist/artplayer-plugin-danmuku.mjs:361`:
```js
if (danmuku === void 0) { this.reset(); this.queue = []; ... }
```
- 旧代码 `danmuku.load(danmakuList)` 显式传了参数,等于"追加",老队列从来没清掉
- 另一个隐性 bug:`if (danmuku && danmakuList?.length)` 短路掉了空列表 — 切到 0 弹幕的集数时旧弹幕也保留
**修复(`client/src/components/player/VideoPlayer.jsx:209`):**
```jsx
useEffect(() => {
const danmuku = artRef.current?.plugins?.artplayerPluginDanmuku;
if (!danmuku) return;
danmuku.config({ danmuku: danmakuList || [] });
danmuku.load(); // 不带参才触发内部 reset
}, [danmakuList]);
```
**测试:**
- VideoPlayer 单测 +3:list→list 切换 / list→空 切换 / null 归一化
- 完整 632/632 通过(顺手把 SEO 改造遗留的 5 个 AnimeCard 测试更新到 `getByRole('link')`)
---
## [1.0.15] - 2026-04-25
### 详情页 halo 客户端兜底采样 — AniList 无 k-means 色的番剧也能拿到真实色身份
**问题:**
- v1.0.13 commit 49b06f8 把 `posterAccent === '#8B5CF6'` 的 halo 渲染显式压住了(品牌紫不当真色用),解决了"假色冒名"问题
- 但 AniList 对一部分番剧不返回 `coverImage.color`(例如 anilistId 179828 カッコウの許嫁 S2),server 回落到品牌紫,detail 页就完全没 halo,海报旁边一片黑
- v1.0.15 给这条路加客户端 canvas 兜底:server 没拿到色时,前端从封面采
**实现:**
- `client/src/utils/oklchAccent.js`:`server/utils/normalizeAccent.js` 的 ESM 镜像,Björn Ottosson OKLab 矩阵 + L 0.56–0.70 / C ≥ 0.11 同款 clamp 带,parity test 走 `createRequire` 直接加载服务端模块,任一侧漂移 CI 就红
- `client/src/utils/sampleCoverAccent.js`:off-DOM `new Image()` + 32×48 canvas downscale + 24-bucket hue 直方图,a/b 笛卡尔累加避开 0/2π 拼接,最大桶 < 12% 视为没主色返回 null。失败路径(CORS/404/decode/灰度/abort)统一返 null,Hero 等价 "no halo"
- `AnimeDetailHero` 状态机重排:`serverAccentHex`(server 真色) → 若无则 `sampledAccent`(客户端采),`effectiveAccent = serverAccent ?? sampledAccent`,`fastHalo` 仅 server 真色路径享有
- `accentCache` 加 `source: 'server' | 'client'` 字段,采样结果写入有 7 天 TTL,重访短路秒回
**调试踩坑(写出来给未来自己):**
- 第一版上线后浏览器 console 报 `CORS policy: No Access-Control-Allow-Origin header`。AniList CDN(s4.anilist.co)实际是 origin-reflective 的:带 `Origin` 头才回 CORS header,不带就不回
- 页面 `<img src>` 先于 sampler 触发,浏览器以非 CORS 模式拿了图并缓存了无 CORS header 的响应。sampler 的 `crossOrigin='anonymous'` 请求复用了同一缓存条目,直接报 CORS 错(根本没到 CDN)
- 修复:sampler 拼 `?accent=1` 走独立缓存条目,触发新请求时浏览器才会送 `Origin`,AniList 才会回 `Access-Control-Allow-Origin`,canvas 不污染。AniList 忽略未知 query 参数,字节相同
**测试:**
- `oklchAccent.test.js` 16 case 客户端/服务端 parity(含真实 AniList 色 `#c19902`)
- `sampleCoverAccent.test.js` 10 case 覆盖 solid / split / 灰度 / 黑 / 白 / SecurityError / load fail / abort 全错误路径
- 完整套件 630/630 通过
**Subagent 参与:**
- planner 给整体规划(三方案选 client canvas)
- code-reviewer 一轮深审给出 3 HIGH + 5 MEDIUM + 4 LOW 清单,HIGH 全修(SecurityError 局部 try/catch / 状态机简化 / `#8B5CF6` 防御性 guard),MEDIUM 选修(parity test loader / cache-bust 注释)
---
## [1.0.14] - 2026-04-25
### `/about` 九章节 HUD 化 · OKLCH 章节色身份 2.0 · 从单色到多色数据流
**背景:**
- v1.0.13 把 /about 立起来,但九个 section 的视觉语汇还不统一:§04 bento 已经 HUD 风,其余八个是杂志排版。"HUD 感"只存在于一张 bento,散到整页像补丁
- 另外一个问题:每个 section 都是"一色到底"。§02 Stats 四个数字全蓝,§03 DataSources 48 个 chip 全琥珀,数据密度再高也一眼过,抓不住注意力
- v1.0.14 做两件事:① HUD 家族扩到 §01–§09 全章节共享基建;② 给颜色单调的密度区"换血",每个单元拿到自己的 hue
**Sprint 1 — 共享基建 + 其余八章节 HUD 升级:**
- `shared/hud.jsx` + `shared/hud-tokens.js` + `shared/motion.css`:SectionNum(右上 §0X 标号)、SectionHeader、ChapterBar(3×52px OKLCH scaleY 入场)、CornerBrackets、`mono`/`label` 令牌、`useCountUp` 滚动计数、共享 keyframes(`hudBlink` 等)
- 八章节逐个重写(每章都走 engineer + design review gate):
- `§01 Hero` 去 iOS 系统色、HUD CTA 取代蓝色胶囊、海报 bezel + CornerBrackets
- `§02 Stats` 2-1-1-1 editorial 网格 + 滚动计数
- `§03 DataSources` SystemNode (live-dot + idx + 名字 + 延迟) + 遥测 footer (计数 48 + 进度条)
- `§05 PosterIdentity` HUD 化 + OKLCH 5 STOPS 调色板
- `§06 Differentiator` EP lock console (LOCKED 读出 + 进度条,取代裸 12 格选集)
- `§07 Danmaku` 3 车道滚动 + 3 条 pinned + LIVE 角标 + 底部密度条
- `§08 FAQ` [OPEN]/[CLOSE] mono marker 取代旋转 "+"
- `§09 FinalCta` mask-reveal 标题 + HUD CTA 取代白色大胶囊
**Sprint 2 — OKLCH 调色基建 + gamut 修 + 和声次色:**
三个 subagent 深度分析(editorial 理论 / OKLCH 技术 / 电影造型)给出三条路径,选稳妥路径:
- `hud-tokens.js` 新增 `HUE` / `L` / `C` 令牌矩阵 + `oklchToken(layer, hue, alpha)` 组装器,六级 L 分层 (`trench` / `rail` / `primary` / `readout` / `hot` / `flash`)
- **sRGB gamut 修**:§02 蓝 `C 0.19→0.17`、§07 青 `L 62→68 C 0.19→0.13`、§08 黄绿 `L 62→82 C 0.19→0.16`。之前在 sRGB 空间夹角色,显示器实际渲染不准
- **六章节和声次色**:每个 section 除了主色 (P1) 还有 1-2 个和声伙伴色 (P2/P3),比例 70/25/5:
- §01 Cold Verdigris 175 (ghost CTA) + Paper Cream 80 (meta 分隔)
- §02 Deep Indigo 255 (hero 分隔) + Brass Signal 75 (关键数字 dot)
- §03 Terracotta 25 (chip hover) + Straw 75 (footer 计数)
- §06 Reticle Lime 100 ([LOCKED] dot) + Gunmetal Whisper 245 (分割线)
- §07 Chatter Rose 330 (一条 pinned 弹幕) + Mint Spark 95 (密度条右端)
- §08 Lilac Fog 300 (分隔线 listener)
**Sprint 3 — §02 四色数据流 + §03 48 chips 5 色循环:**
用户测试反馈:"§02/§03 颜色太单调不能吸引注意"。单一主色 + 小面积次色还不够,需要让每个数据单元拿到自己的色身份:
- `§02 Stats` 每个 stat 独立取色,顺便回应下游章节:
- `LIBRARY` Blue 210 · 数据根基
- `DANMAKU` Cyan 195 · 回 §07
- `SOURCES` Amber 40 · 回 §03
- `DAILY` Chartreuse 85 · 回 §08
- 一行 Stats 现在变成整页的"目录预告"
- `§03 DataSources` 48 chips 的 dot + latency 循环 5 色 (Amber / Cyan / Magenta / Lime / Violet),每条信源自带色身份,marquee 横滚形成多色节奏,琥珀仍占首位
**验收:**
- 共享基建 + 八章节升级 + 调色 + 多色数据流 = 21 个 commit,每个都过 lint + build green
- 所有 OKLCH 色值 gamut-safe,WCAG AA 对比度保持
- reduced-motion 全尊重(所有 hudBlink / 滚动 marquee / spring 入场都有降级分支)
---
## [1.0.13] - 2026-04-24
### 介绍首页 `/about` — dark editorial × OKLCH 章节色身份,7 张有密度的 bento
**背景:**
- v1.0.12 把"海报色身份"做到了详情页,但站点对外没有一张页面讲"我们到底在做什么"。从 Navbar 点 logo 直接进 HomePage 列表页,新用户没有任何着陆体验
- 要做一张接得住第一眼印象的页面:告诉访客**为什么我们值得收藏**,而不是"又一个 AniList 列表皮"
- 参考锚点锁死在 **Apple TV+ / A24 / Linear / Spotify Year Wrapped** —— dark editorial + 杂志目录感 + 章节化色身份。不做 Discord-泡沫风,也不做 SaaS-card-grid 模板
**Sprint 1 — `/about` 落地(commit `aaca423`):**
- 9 个 section 单文件,纯 inline style 跟现有 pages 约定一致:`HeroSection` / `StatsRow` / `DataSourcesTribute` / `FeaturesBento` / `PosterIdentityShowcase` / `DifferentiatorSection` / `DanmakuInsert` / `FaqSection` / `FinalCta`
- 排版锚:Sora 大字 manifesto + JetBrains Mono 做数据 / 代号 / `§01`-`§08` 杂志目录编号。字体对比度足以让"主张"和"证据"分属两套视觉系统
- **章节色身份系统**:四个 hue(330 粉 / 210 蓝 / 155 绿 / 40 橙)在 StatsRow 竖条、Bento 章节卡、FinalCta 渐变条、FAQ 打开态 bar 四处串联,形成色身份索引。不是装饰渐变,是目录式的编号色
- Hero showcase hue 锁 295,跟 Bento poster 卡 hue 330 解耦,避免同色语义重复
- `Navbar` 加 `/about` 入口,删 `lastY` 死代码;`Footer` 站点列加 "关于" 外链(commit `b7c9ac4`)
- 两轮 engineer review + design review,HIGH 问题全部收掉
**Sprint 2 — §04 bento 重做:从 4 张空旷卡做到 7 张有密度的功能卡(commit `9c5bb7f`):**
v1 的 bento 只有 4 张大卡,用户反馈"太空旷了,看不到到底做了什么"。这个 section 是"我们到底在做什么"的落点,空旷等于沉默。
- 扩到 7 张卡,12 列 asymmetric 网格:hero 带(7+5 + row-span 2)+ 中排(4+4+4)+ 下排(6+6)。每张卡一个 hue,按章节编号递进
- **7 个 feature 一一对应真实产品能力**,不是榜单式罗列:
1. `§01 海报色身份` —— OKLCH 归一化演示(k-means + Lab → OKLCH),ΔE 对比度条 + AA 安全区标注
2. `§02 弹幕热力` —— 60 格密度时间线 + 3 个峰值(04:10 / mid / 18:42)+ playhead at 52% + top-3 高频弹幕
3. `§03 字幕组聚合` —— 3 个种子源(动漫花园 / Nyaa / ACG.RIP)并行抓取 + 4 行字幕组 release(SubsPlease / LoliHouse / 桜都 / VCB-Studio)+ 过滤 tail。**这一栏 v1 曾写成"多源视频流失效切换(AGE动漫/樱花动漫/Bilibili/Bangumi)"—— 本站根本没有这个能力,是 copy 捏造。v2 改成真正写在 `server/controllers/anime.controller.js` 里的 `Promise.allSettled([fetchDmhy, fetchAcgRip, fetchNyaa])`**
4. `§04 手动选集` —— 3 张 FlowCard(bad → pick → locked)+ handoff 箭头动画 + 周统计条 + kbd 提示
5. `§05 续播队列` —— next-drop ticker + 3 行带海报色小块 + SVG ProgressRing(r=12, stroke-dasharray, rotate -90)
6. `§06 周表` —— 7 列每天 + 当日高亮环 + 每日点阵 stack + next-airing 行
7. `§07 拖拽解析` —— drop zone marching dashes + MKV SVG icon + 解析 input/output 预览 + 接受格式 pill(.mkv .mp4 .webm .avi .srt .ass)
- **动效原则**:compositor-only(`transform` / `opacity`),所有 `@keyframes`(`posterBreathe` / `arrowDotTravel` / `dropMarch` / `featPulse` / `featMarch`)在 `prefers-reduced-motion: reduce` 下全部降级到 none
- 7 张卡 staggered entrance,`viewport={{ once: true }}` 只触发一次,`ease: [0.16, 1, 0.3, 1]` 出场曲线
- Hover:转成 ::before radial gradient spotlight(跟鼠标),章节 bar 拉高 scale
**f1 海报色身份 —— 三张海报真封面 + 吉祥物(也在 `9c5bb7f`):**
v1 的 f1 卡里三张海报是纯渐变 tile + 烫金标题,没有"这是一张真封面"的说服力。第一稿修成从 trending(10)match 标题取封面,但 `药屋` / `败犬` 不稳定在 top-10 里,刷新一次少一张。
- trending 列从 10 拉到 30 增加 hit rate,但仍非保证
- 最终方案:**直抓 AniList ID**,三张海报锁死:`154587`(芙莉莲)/ `161645`(药屋少女的呢喃)/ `171457`(败犬女主太多了)。三个 ID 全部走 `curl` 打 graphql.anilist.co 校验过 romaji 匹配
- `useAnimeDetail(id)` 优先,trending match 保留兜底,两层 fallback 不闪
- `img` 叠在渐变 tile 上 + 底部黑色 gradient 压字,hue 徽章保留(20°/330°/340°)
- **右侧补吉祥物**:`public/mascot-wink.png`(蓝发,比耶,眨眼),`position: absolute; right: 0; bottom: 0`,`height: 480px` 延伸到海报 tile 底线,与下方 INPUT/OUTPUT 规格块顶边紧贴。`< 1180px` 隐藏避免挤占
- 三轮迭代才定位置:top-right 第一版悬空有 gap → 挪进 PosterVisual 的 tile 容器 + `bottom: 0` 锚定才算紧贴
**验证:**
- 客户端 `vite build` — 全绿(989 kB / 293 kB gzip,相比 v1.0.12 略增是 bento 变密 + 吉祥物 PNG)
- 真机肉眼在 1920 / 1440 / 1180 / 720 四档断点验证 bento 布局切换:12 col → 6 col → 1 col
- `prefers-reduced-motion: reduce` 下所有 loop / hover / entrance 降级确认
- 三张海报 AniList ID 用 graphql 单测校对过 romaji,不会拉错番
**教训:**
- **空旷 ≠ 极简**。当 section 的职责是"证明我们做了什么",空旷就是沉默。极简留白要放在那些"观众已经同意你的主张"的地方,不是"你正在招募观众"的地方
- **捏造的 feature 再漂亮也会暴露**。v1 写的"多源视频流失效切换"视觉上很酷,但用户第一眼就指出"我们好像没有这个功能吧你看一下"。写 landing copy 的唯一来源是 `server/controllers/` 里真跑的代码,不是脑补
- trending 数据做视觉锚点要**假设 miss**。一旦依赖"top-N 里一定有 X",就会有刷新闪烁。要么不依赖,要么给 fallback。锁 ID 是最稳方案
- 装饰图的放置没有"一次就对"——第一版 bottom-right、第二版 top-right、第三版嵌进 tile 容器 + bottom 锚定才算贴合。图片不是丢进 `<div>` 就结束,是"相对谁贴紧"的布局题
**配套:**
- `CHANGELOG.md` 本条
- `public/mascot-wink.png`(2.7 MB PNG,透明底)
- landing 全站组件同步迭代:HeroSection / StatsRow / DataSourcesTribute / PosterIdentityShowcase / DifferentiatorSection / DanmakuInsert / FaqSection / FinalCta 均有小改(见 `git diff` stat)
---
## [1.0.12] - 2026-04-22
### 前端设计系统现代化 — Tailwind v4 底座 + shadcn 接入 + OKLCH 海报色身份
**背景:**
- v1.0.11 收尾后一度标了维护模式,但"每个详情页长得一样"这件事越看越不对劲 —— 20 个番放在一起没任何视觉身份,iOS 系统蓝铺满所有 CTA,品牌色 `#8B5CF6` 纸上谈兵从没真的出现
- Arc / Linear / Apple TV+ 的共同做法是**把调色权交给内容**:每个 tab、workspace、影片都有自己的一抹色,交互表面跟着变,装饰底再跟着呼应。AniList GraphQL 本身就返回 `coverImage.color`(每张封面的 k-means 主色),之前在 schema 里却没透传
- 同时前端栈也到了需要刷新的节点:自制 style object 散落在各组件里,没有 design token 中心,也没有 primitive 可复用。Sprint 1/2 一并重铺
**Sprint 1 — Tailwind v4 + token 双向同源(commits `d4c54fa` `cd34e92` `bfe6986`):**
- 接入 `@tailwindcss/vite` v4,配 `@import "tailwindcss"` 一行起步
- 建立 **token 双向同源**:`client/src/index.css` 里一份 `:root` CSS 变量(color / radius / font / spacing),Tailwind v4 通过 `@theme` 块同步消费,手写 style object 走 `var(--...)`。改一处、两边都跟上
- 清掉 Vite 脚手架残留的 `App.css`
- 写 `CONTRIBUTING.md` 锁死 **JSX-only 政策** —— 本项目不引 TypeScript,新组件一律 `.jsx`;给未来的 agent / 贡献者一个明文约束,免得又回到混合栈的老路
- `tailwindSmoke.test.jsx` 做 CI 级烟雾测试,保证 Tailwind 工具类真的产出样式
**Sprint 2 Step 1 — shadcn JSX 底座(commits `45dd50f` `8220633`):**
- 由于我们坚持 JSX-only,shadcn CLI 直接不可用。手工搬 `cn` util(`clsx` + `tailwind-merge`)+ `class-variance-authority`,做一版纯 JSX 的 `Button` primitive,variants 抽到独立的 `button-variants.js`
- token 扩充:新增 `--radius-card` / `--radius-chip` / `--space-section`,圆角全局 -2px(偏硬朗一点,离 Discord-泡沫风格远一步)
- `button.test.jsx` 做 primitive 行为锁定
**Sprint 2 Step 2 — 海报色身份链(commits `fe84e21` `93acae4` `6ecc386` `1d3e01d` `20e52b2`):**
这条链是五个递进的 commit,最后一步才让它真的"可信"。按顺序:
*Step 2a — 服务端透传原始 coverImage.color(`fe84e21`):*
- `animeDetail.graphql.js` / `seasonalAnime.graphql.js` / `searchAnime.graphql.js` / `weeklySchedule.graphql.js` 四个 query 全部加 `coverImage { color }` 字段
- `anilist.service.js` normalize 层透传到 JSON API,schema 里加 `coverImageColor: String`
- 这一步只给"原料",不做任何归一化
*Step 2b — 客户端 DetailHero 接入(`93acae4`):*
- `AnimeDetailHero.jsx` 外层 div 挂 `--poster-accent` / `--poster-accent-rgb` CSS 变量
- banner 渐变顶部染 14%、cover 图 80px `-20px` 扩散光晕
- 第一版铺完发现:**肉眼看不见变化**。AniList 返回的色里~30% 是接近中性的淡奶油色(鬼灭的 `#f1c9ae` 之类),在 14% 不透明度下无限趋近于零
*Step 2c — 强度上调,先让肉眼先看见(`6ecc386`):*
- banner 14% → 35%、halo 加 border + 60px @ 45%
- 只是 debug 用途:确认管道通了,用户才看见
*Step 2d — Skeleton 预挂色 + 导航 state 传递(`1d3e01d`):*
- 点卡片进详情页时会"闪一下" —— skeleton 是纯灰,mount 完才变色
- `AnimeCard` / `HeroCarousel` / `RelationSection` / `RecommendationSection` 四个入口全改走 `navigate(path, { state: { posterAccent, posterAccentRgb } })`
- `DetailSkeleton` 从 `useLocation().state` 读同一批值,让 skeleton 就跟详情页同色 —— 点进去视觉上是连续渐变,不是二段跳
- 同步把 Step 2c 的强度回退到保守档
*Step 2e — 服务端 OKLCH 归一化,根治"washed pastel"(`