# 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 秒),系统开了「减弱动态效果」时全部关掉。鼠标停在卡片上时封面图在框里慢慢放大一点(封面网格和图表卡一样);追番的「+」只淡入,不缩放也不旋转——这么小的图标一缩放,结束那一下会跳。
- `
`、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 图片,给出经压缩服务、以镜像为图源的地址。
- 普通 `
` 用 `src` 加 `srcSet`。
- CSS 背景,以及只能放一个地址的地方,用 2 倍屏那一档。
- 成员自己上传的照片、本地预览、Bangumi 和弹弹play 的图、站内默认图,一律原样不动。
- **`FallbackImg` 统一处理**:头像回退成番剧封面时,按头像尺寸取压缩图,不再加载整张原图。
- **各处按显示尺寸取图**:个人主页背景 1920,下拉菜单头图 244,设置页预览 290,会员卡 320;头像和缩略图按各自的尺寸。
- **新增守门测试**:凡是渲染普通 `
`、或用 `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()。
- **在每部番里匹配,不按全站同名**:只读已经绑定 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 的下载地址在 (`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 上有几条集名被转义了两次(`&`),意思都是原字符。已经存下的数据另行清理。
- **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 正文长度上限前截在整行上、补上没闭合的 `` 并注明截断,所以截断也截不掉钉子。上游番名里的 `#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 条(含真的走到截断:多字节长番名超限时截在整字上、不留没闭合的 ``、钉子还在;`#`/`@` 不会变成链接或提及;2 万行删除时标题照写全数);`internal/bgmidmap` 新增三条(AniDB 嵌入解析、「AniDB 表不是对照表的子集」、对照表每一条都完整且只出现一次),真 Postgres 上新增一条:seed 两遍不重复、对照表里没有的番磁力输入照样带 AniDB 编号——把查询改回读 `bgm_id_map`,这条会变红。
### 今日更新:去掉左右按钮,换成行下一条可拖的细滑动条;甩动有惯性、停在卡片上;行尾说明今天结束
上一版在「今日更新」两端加了 ← → 圆按钮,它们压在封面上,像通用轮播。这一版去掉按钮,换成卡片行下面一条细滑动条(设计在画布上比过时间轴指针、表盘、安静边缘三案后定下):
- **滑动条,所有宽度都有。** 滑块宽度是这一行在屏内的比例(最窄 6%),位置是滚了多远。平时是一根 2px 的细线;鼠标移上去、按住或键盘聚焦时变粗并换成页面的颜色。按住滑块拖,按住的那一点一直在指针下;点轨道空白处,行平滑滑过去并停在一张卡上,按住接着拖就改成手跟着走。手机上可点区域 44px 高;手指在空轨道上松开且没移动才算点——从滑动条开始的竖向滑动是在滚页面,什么也不做。这一行放得下时滑动条保留位置但不画,量出来之后下面的内容不会跳。高对比度模式下轨道有边框、滑块用系统色,焦点照样看得见。
- **键盘和读屏**走滑动条前面一个看不见的 ``(取代了两个按钮,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`。
- 每张卡的播出时间是 `