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 a51deef..b11c62b 100644 --- a/app/api/douban/image/route.ts +++ b/app/api/douban/image/route.ts @@ -1,7 +1,15 @@ import { NextResponse } from 'next/server'; +import { buildDoubanImageCandidates } from '@/lib/server/douban-image'; export const runtime = 'edge'; +const REQUEST_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8', + Referer: 'https://movie.douban.com/', +}; + export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); @@ -10,51 +18,43 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Missing image URL' }, { status: 400 }); } - try { - const imageResponse = await fetch(imageUrl, { - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8', - Referer: 'https://movie.douban.com/', - }, - }); + let lastStatus = 502; + let lastError = 'Error fetching image'; + + for (const candidate of buildDoubanImageCandidates(imageUrl)) { + let imageResponse: Response; + + try { + imageResponse = await fetch(candidate, { headers: REQUEST_HEADERS }); + } catch { + // 线路不可达,换下一个镜像 + continue; + } if (!imageResponse.ok) { - return NextResponse.json( - { error: imageResponse.statusText }, - { status: imageResponse.status } - ); + lastStatus = imageResponse.status; + lastError = imageResponse.statusText || 'Error fetching image'; + continue; } - const contentType = imageResponse.headers.get('content-type'); - if (!imageResponse.body) { - return NextResponse.json( - { error: 'Image response has no body' }, - { status: 500 } - ); + lastStatus = 500; + lastError = 'Image response has no body'; + continue; } - // 创建响应头 const headers = new Headers(); + const contentType = imageResponse.headers.get('content-type'); if (contentType) { headers.set('Content-Type', contentType); } - - // 设置缓存头 headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); - // 直接返回图片流 - // @ts-ignore return new Response(imageResponse.body, { status: 200, headers, }); - } catch (error) { - return NextResponse.json( - { error: 'Error fetching image' }, - { status: 500 } - ); } + + return NextResponse.json({ error: lastError }, { status: lastStatus }); } diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts index e980c6a..eb0a22b 100644 --- a/app/api/user/config/route.ts +++ b/app/api/user/config/route.ts @@ -5,20 +5,25 @@ * so they persist across browsers, devices, and PWA installs. */ -import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; +import { getRedisClient } from '@/lib/server/redis'; export const runtime = 'edge'; -const redis = Redis.fromEnv(); - function redisKey(profileId: string): string { const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, ''); return `user:config:${safe}`; } +function syncUnavailableResponse() { + return NextResponse.json( + { error: 'Server-side sync is not configured on this deployment' }, + { status: 503 } + ); +} + export async function GET(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; @@ -27,6 +32,11 @@ export async function GET(request: NextRequest) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const data = await redis.get(redisKey(profileId)); return NextResponse.json({ success: true, data: data || null }); @@ -47,6 +57,11 @@ export async function POST(request: NextRequest) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const body = await request.json(); const key = redisKey(profileId); diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts index e52aee3..f83032a 100644 --- a/app/api/user/sync/route.ts +++ b/app/api/user/sync/route.ts @@ -1,21 +1,31 @@ -import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; +import { getRedisClient } from '@/lib/server/redis'; // 确保这行代码在整个文件中只出现一次 export const runtime = 'edge'; -const redis = Redis.fromEnv(); +function syncUnavailableResponse() { + return NextResponse.json( + { error: 'Server-side sync is not configured on this deployment' }, + { status: 503 } + ); +} export async function GET(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; - + if (!profileId) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const data = await redis.get(`user:sync:${profileId}`); return NextResponse.json({ @@ -31,11 +41,16 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; - + if (!profileId) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const body = await request.json(); const { history, favorites } = body; diff --git a/components/favorites/FavoritesList.tsx b/components/favorites/FavoritesList.tsx index 52e8c43..9be1b23 100644 --- a/components/favorites/FavoritesList.tsx +++ b/components/favorites/FavoritesList.tsx @@ -5,6 +5,7 @@ import type { FavoriteItem } from '@/lib/types'; import { FavoritesItem } from './FavoritesItem'; import { FavoritesEmptyState } from './FavoritesEmptyState'; +import { keepRenderableFavorites } from '@/lib/utils/sync-records'; interface FavoritesListProps { favorites: FavoriteItem[]; @@ -13,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 (
- {favorites.map((item) => ( + {renderableFavorites.map((item) => ( - {history.length === 0 ? ( + {renderableHistory.length === 0 ? ( ) : (
- {history.map((item) => ( + {renderableHistory.map((item) => ( 0) { - historyStore.getState().importHistory(result.data.history); + const history = keepRenderableHistory(result.data.history); + const favorites = keepRenderableFavorites(result.data.favorites); + + if (history.length > 0) { + historyStore.getState().importHistory(history); } - if (result.data.favorites?.length > 0) { - favoritesStore.getState().importFavorites(result.data.favorites); + if (favorites.length > 0) { + favoritesStore.getState().importFavorites(favorites); } } } catch (error) { diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 375af6a..d2611da 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,6 +1,6 @@ -import { Redis } from '@upstash/redis/cloudflare'; -import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; import { NextRequest, NextResponse } from 'next/server'; +import { getRedisClient } from '@/lib/server/redis'; +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; import { getRuntimeFeatures } from '@/lib/server/runtime-features'; import { createStoredAccount, @@ -87,25 +87,11 @@ const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_D const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const MANAGED_AUTH_FORCED = process.env.MANAGED_AUTH_ENABLED === 'true'; -function getRuntimeEnvValue(name: string, fallback = ''): string { - try { - const runtimeEnv = getOptionalRequestContext()?.env as unknown as Record | undefined; - const value = runtimeEnv?.[name]; - if (typeof value === 'string') return value; - } catch { - // Outside Cloudflare's request runtime, fall back to process.env. - } - - return process.env[name] || fallback; -} - function getEffectiveAdminPassword(): string { return getRuntimeEnvValue('ADMIN_PASSWORD', ADMIN_PASSWORD) || getRuntimeEnvValue('ACCESS_PASSWORD', ACCESS_PASSWORD); } -let cachedRedis: Redis | null | undefined; - export class ManagedAuthStorageError extends Error { constructor(operation: 'read' | 'write', cause?: unknown) { super(`Managed auth storage ${operation} failed`, { cause }); @@ -113,25 +99,6 @@ export class ManagedAuthStorageError extends Error { } } -function getRedisClient(): Redis | null { - if (cachedRedis !== undefined) { - return cachedRedis; - } - - const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL'); - const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN'); - if (!url || !token) { - cachedRedis = null; - return cachedRedis; - } - - cachedRedis = new Redis({ - url, - token, - }); - return cachedRedis; -} - function isManagedAuthEnabled(): boolean { return !!getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET) && !!getRedisClient(); } diff --git a/lib/server/douban-image.ts b/lib/server/douban-image.ts new file mode 100644 index 0000000..8367ac5 --- /dev/null +++ b/lib/server/douban-image.ts @@ -0,0 +1,30 @@ +// Douban's imgN.doubanio.com hosts mirror the same paths but use different +// network routes. Some routes are unreachable from overseas deployments, so a +// failed request can retry the same path through known reachable mirrors. +const DOUBAN_IMAGE_HOSTS = [ + 'img9.doubanio.com', + 'img3.doubanio.com', + 'img2.doubanio.com', +]; + +export function buildDoubanImageCandidates(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 { + // Leave malformed URLs to fetch so the route returns its normal proxy error. + } + + return candidates; +} diff --git a/lib/server/redis.ts b/lib/server/redis.ts new file mode 100644 index 0000000..3379610 --- /dev/null +++ b/lib/server/redis.ts @@ -0,0 +1,35 @@ +import { Redis } from '@upstash/redis/cloudflare'; + +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; + +let cachedRedis: Redis | undefined; + +/** + * Build the shared Upstash client from whichever environment source is available. + * + * `Redis.fromEnv()` is deliberately not used here: the `@upstash/redis/cloudflare` + * implementation only reads Cloudflare's global bindings and never falls back to + * `process.env`, so it always resolves to `undefined` on Docker / Node + * self-hosted deployments. The client is also created lazily because Cloudflare's + * per-request bindings are not reachable while a module is being evaluated. + * + * Returns `null` when Upstash is not configured, which callers should treat as + * "server-side sync unavailable" rather than as a request failure. + */ +export function getRedisClient(): Redis | null { + if (cachedRedis) { + return cachedRedis; + } + + const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL'); + const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN'); + if (!url || !token) { + return null; + } + + cachedRedis = new Redis({ + url, + token, + }); + return cachedRedis; +} diff --git a/lib/server/runtime-env.ts b/lib/server/runtime-env.ts new file mode 100644 index 0000000..84da27c --- /dev/null +++ b/lib/server/runtime-env.ts @@ -0,0 +1,20 @@ +import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; + +/** + * Read an environment value that may live in either runtime environment. + * + * On Cloudflare, bindings and secrets are only reachable through the + * per-request context. On Docker / Node self-hosting there is no such context, + * so the value comes from `process.env`. + */ +export function getRuntimeEnvValue(name: string, fallback = ''): string { + try { + const runtimeEnv = getOptionalRequestContext()?.env as unknown as Record | undefined; + const value = runtimeEnv?.[name]; + if (typeof value === 'string') return value; + } catch { + // Outside Cloudflare's request runtime, fall back to process.env. + } + + return typeof process !== 'undefined' ? process.env[name] || fallback : fallback; +} diff --git a/lib/utils/sync-records.ts b/lib/utils/sync-records.ts new file mode 100644 index 0000000..c260a9f --- /dev/null +++ b/lib/utils/sync-records.ts @@ -0,0 +1,38 @@ +import type { FavoriteItem, VideoHistoryItem } from '@/lib/types'; + +function hasIdentity(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const record = value as { videoId?: unknown; source?: unknown; title?: unknown }; + const videoId = record.videoId; + const hasVideoId = typeof videoId === 'string' + ? videoId.trim().length > 0 + : typeof videoId === 'number' && Number.isFinite(videoId); + + 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` 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 { + 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 { + return hasIdentity(value); +} + +export function keepRenderableHistory(items: unknown): VideoHistoryItem[] { + return Array.isArray(items) ? items.filter(isRenderableHistoryItem) : []; +} + +export function keepRenderableFavorites(items: unknown): FavoriteItem[] { + return Array.isArray(items) ? items.filter(isRenderableFavoriteItem) : []; +} 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), []); +});