From f1a00867013c9e50b9c9de598aa9893d4856e37d Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 31 Jul 2026 23:17:50 +0800 Subject: [PATCH] fix: harden sync and image fallback; release 4.9.19 --- CHANGELOG.md | 7 ++++ app-release.json | 13 ++++++- app/api/douban/image/route.ts | 30 +------------- components/favorites/FavoritesList.tsx | 6 ++- components/history/HistoryList.tsx | 6 ++- lib/server/douban-image.ts | 30 ++++++++++++++ lib/server/redis.ts | 7 ++-- lib/server/runtime-env.ts | 2 +- lib/utils/sync-records.ts | 16 +++++--- package-lock.json | 4 +- package.json | 2 +- tests/douban-image-fallback.test.ts | 54 ++++++++++++++++++++++++++ tests/sync-records.test.ts | 41 +++++++++++++++++++ 13 files changed, 172 insertions(+), 46 deletions(-) create mode 100644 lib/server/douban-image.ts create mode 100644 tests/douban-image-fallback.test.ts create mode 100644 tests/sync-records.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ed53c..cc004b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 4.9.19 - 2026-07-31 + +- 修复 Docker / Node 自托管下 `/api/user/sync` 与 `/api/user/config` 无法从 `process.env` 读取 Upstash 凭据、持续返回 500 的问题;Redis 客户端改为按请求环境惰性创建,未配置同步时明确返回 503(#226 / PR #227)。 +- 云端恢复和历史/收藏列表渲染会过滤缺少 `videoId`、`source`、`title` 或有效 `episodeIndex` 的损坏记录;全部记录无效时正常显示空状态,不再让账户首页因渲染异常永久崩溃(#228 / PR #229)。 +- 豆瓣图片代理在 `imgN.doubanio.com` 线路不可达时按 `img9`、`img3`、`img2` 镜像回退,并保留最后一次上游响应状态,修复境外部署海报批量 500(PR #230)。 +- 新增同步记录和豆瓣镜像回退回归测试;真实 Next.js standalone + Upstash REST 兼容环境下的托管登录、同步读写与配置读写均验证通过。 + ## 4.9.18 - 2026-07-29 - 修复移动端播放器控制栏在窄屏下被裁切、最右侧“系统全屏”按钮不可见的问题(#224)。 diff --git a/app-release.json b/app-release.json index 5a6b90d..cb28c16 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,19 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.18", + "currentVersion": "4.9.19", "releases": [ + { + "version": "4.9.19", + "publishedAt": "2026-07-31", + "title": "修复自托管同步与豆瓣海报回退", + "notes": [ + "修复 Docker / Node 自托管下用户同步与配置接口无法从 process.env 读取 Upstash 凭据、持续返回 500 的问题(#226 / PR #227)。", + "云端恢复与列表渲染会丢弃缺少 videoId、source、title 或有效 episodeIndex 的损坏记录,避免账户首页被永久崩溃数据锁死(#228 / PR #229)。", + "豆瓣图片代理会在 imgN.doubanio.com 线路不可达时按镜像回退,并保留上游最终状态码,修复境外部署海报批量 500(PR #230)。", + "新增同步记录与豆瓣镜像回退回归测试,并通过真实 Next.js standalone + Upstash REST 兼容环境的登录、同步和配置读写验证。" + ] + }, { "version": "4.9.18", "publishedAt": "2026-07-29", diff --git a/app/api/douban/image/route.ts b/app/api/douban/image/route.ts index 2a8bc70..b11c62b 100644 --- a/app/api/douban/image/route.ts +++ b/app/api/douban/image/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { buildDoubanImageCandidates } from '@/lib/server/douban-image'; export const runtime = 'edge'; @@ -9,33 +10,6 @@ const REQUEST_HEADERS = { Referer: 'https://movie.douban.com/', }; -// 豆瓣的 imgN.doubanio.com 互为镜像,但各自解析到不同线路。部分线路(例如 img1 -// 的国内 IP)在境外主机上不可达,fetch 会直接抛错而不是返回状态码,海报因此变成 -// 500。同一路径换个镜像通常就能取到,所以失败时按顺序回退。 -const DOUBAN_IMAGE_HOSTS = ['img9.doubanio.com', 'img3.doubanio.com', 'img2.doubanio.com']; - -function buildCandidates(rawUrl: string): string[] { - const candidates = [rawUrl]; - - try { - const parsed = new URL(rawUrl); - if (!/^img\d+\.doubanio\.com$/.test(parsed.hostname)) { - return candidates; - } - - for (const host of DOUBAN_IMAGE_HOSTS) { - if (host === parsed.hostname) continue; - const alternate = new URL(parsed.toString()); - alternate.hostname = host; - candidates.push(alternate.toString()); - } - } catch { - // 非法 URL 留给 fetch 去报错 - } - - return candidates; -} - export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); @@ -47,7 +21,7 @@ export async function GET(request: Request) { let lastStatus = 502; let lastError = 'Error fetching image'; - for (const candidate of buildCandidates(imageUrl)) { + for (const candidate of buildDoubanImageCandidates(imageUrl)) { let imageResponse: Response; try { diff --git a/components/favorites/FavoritesList.tsx b/components/favorites/FavoritesList.tsx index ff8a058..9be1b23 100644 --- a/components/favorites/FavoritesList.tsx +++ b/components/favorites/FavoritesList.tsx @@ -14,13 +14,15 @@ interface FavoritesListProps { } export function FavoritesList({ favorites, onRemove, isPremium = false }: FavoritesListProps) { - if (favorites.length === 0) { + const renderableFavorites = keepRenderableFavorites(favorites); + + if (renderableFavorites.length === 0) { return ; } return (
- {keepRenderableFavorites(favorites).map((item) => ( + {renderableFavorites.map((item) => ( - {history.length === 0 ? ( + {renderableHistory.length === 0 ? ( ) : (
- {keepRenderableHistory(history).map((item) => ( + {renderableHistory.map((item) => ( 0 : typeof videoId === 'number'; + const hasVideoId = typeof videoId === 'string' + ? videoId.trim().length > 0 + : typeof videoId === 'number' && Number.isFinite(videoId); - return hasVideoId && typeof record.source === 'string' && typeof record.title === 'string'; + return hasVideoId && + typeof record.source === 'string' && record.source.trim().length > 0 && + typeof record.title === 'string' && record.title.trim().length > 0; } /** * Records restored from server-side sync are not guaranteed to be well formed — * they may come from an older schema or a partial write. Rendering one without - * `videoId` throws while building the player URL, which takes down the whole - * page, so anything lacking a usable identity is dropped on the way in. + * `videoId` or `episodeIndex` throws while building the player URL, which takes + * down the whole page, so anything lacking the required URL fields is dropped. */ export function isRenderableHistoryItem(value: unknown): value is VideoHistoryItem { - return hasIdentity(value); + if (!hasIdentity(value)) return false; + const episodeIndex = (value as { episodeIndex?: unknown }).episodeIndex; + return typeof episodeIndex === 'number' && Number.isInteger(episodeIndex) && episodeIndex >= 0; } export function isRenderableFavoriteItem(value: unknown): value is FavoriteItem { diff --git a/package-lock.json b/package-lock.json index 174dc86..0d3f2fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.18", + "version": "4.9.19", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.18", + "version": "4.9.19", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 6f7b193..be2e709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.18", + "version": "4.9.19", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", diff --git a/tests/douban-image-fallback.test.ts b/tests/douban-image-fallback.test.ts new file mode 100644 index 0000000..c15f121 --- /dev/null +++ b/tests/douban-image-fallback.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GET } from '@/app/api/douban/image/route'; +import { buildDoubanImageCandidates } from '@/lib/server/douban-image'; + +test('Douban image candidates preserve the path and try reachable mirrors', () => { + const candidates = buildDoubanImageCandidates( + 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + ); + + assert.deepEqual(candidates, [ + 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + 'https://img9.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + 'https://img3.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + ]); +}); + +test('non-Douban and malformed URLs are attempted only once', () => { + assert.deepEqual(buildDoubanImageCandidates('https://example.com/poster.jpg'), [ + 'https://example.com/poster.jpg', + ]); + assert.deepEqual(buildDoubanImageCandidates('not a url'), ['not a url']); +}); + +test('image proxy falls back after a mirror network failure', async (context) => { + const requested: string[] = []; + + context.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => { + const url = input.toString(); + requested.push(url); + + if (url.includes('img1.doubanio.com')) { + throw new TypeError('fetch failed'); + } + + return new Response('image-bytes', { + status: 200, + headers: { 'content-type': 'image/webp' }, + }); + }); + + const target = encodeURIComponent('https://img1.doubanio.com/poster.webp'); + const response = await GET(new Request(`https://kvideo.test/api/douban/image?url=${target}`)); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'image/webp'); + assert.equal(await response.text(), 'image-bytes'); + assert.deepEqual(requested, [ + 'https://img1.doubanio.com/poster.webp', + 'https://img9.doubanio.com/poster.webp', + ]); +}); diff --git a/tests/sync-records.test.ts b/tests/sync-records.test.ts new file mode 100644 index 0000000..963f7ec --- /dev/null +++ b/tests/sync-records.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + keepRenderableFavorites, + keepRenderableHistory, +} from '@/lib/utils/sync-records'; + +const validIdentity = { + videoId: 'video-1', + source: 'source-a', + title: 'Example', +}; + +test('sync record filters reject malformed identities', () => { + assert.deepEqual(keepRenderableFavorites([ + validIdentity, + { ...validIdentity, videoId: undefined }, + { ...validIdentity, videoId: Number.NaN }, + { ...validIdentity, source: '' }, + { ...validIdentity, title: ' ' }, + null, + ]), [validIdentity]); +}); + +test('history records require a usable episode index', () => { + const validHistory = { ...validIdentity, episodeIndex: 0 }; + + assert.deepEqual(keepRenderableHistory([ + validHistory, + { ...validIdentity }, + { ...validIdentity, episodeIndex: -1 }, + { ...validIdentity, episodeIndex: 1.5 }, + { ...validIdentity, episodeIndex: '0' }, + ]), [validHistory]); +}); + +test('sync record filters tolerate non-array payloads', () => { + assert.deepEqual(keepRenderableHistory({ history: [] }), []); + assert.deepEqual(keepRenderableFavorites(undefined), []); +});