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