From 81c46d754e1dbdd055df73fe0afcce37cbf5f57f Mon Sep 17 00:00:00 2001 From: can4hou6joeng4 Date: Fri, 31 Jul 2026 10:12:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E4=B8=8B=E6=97=A0=E6=B3=95=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=20Upstash=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/api/user/{sync,config} 用 @upstash/redis/cloudflare 的 Redis.fromEnv() 创建客户端。该实现只读传入的 env 参数或 Cloudflare 全局绑定,从不回退到 process.env,因此在 Docker / Node 自托管下 url 与 token 恒为 undefined,两个接口必定返回 500,跨设备同步 (收藏、历史、设置)完全不可用。认证走的是 auth.ts 里的 new Redis({ url, token }),所以登录正常,只有用户数据读写失败。 把 auth.ts 中已有的双源回退抽成 lib/server/runtime-env.ts,Redis 客户端抽成 lib/server/redis.ts,两个路由改为惰性获取共享客户端。 惰性创建同时修正了另一个隐患:Cloudflare 的 per-request 绑定在 模块求值阶段不可达,而原先客户端是在模块作用域创建的。 未配置 Upstash 时返回 503 并给出明确说明,而不是当成请求失败。 Fixes #226 --- app/api/user/config/route.ts | 21 +++++++++++++++++--- app/api/user/sync/route.ts | 23 ++++++++++++++++++---- lib/server/auth.ts | 37 ++---------------------------------- lib/server/redis.ts | 36 +++++++++++++++++++++++++++++++++++ lib/server/runtime-env.ts | 20 +++++++++++++++++++ 5 files changed, 95 insertions(+), 42 deletions(-) create mode 100644 lib/server/redis.ts create mode 100644 lib/server/runtime-env.ts 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/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/redis.ts b/lib/server/redis.ts new file mode 100644 index 0000000..3fabeb6 --- /dev/null +++ b/lib/server/redis.ts @@ -0,0 +1,36 @@ +import { Redis } from '@upstash/redis/cloudflare'; + +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; + +let cachedRedis: Redis | null | 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 !== 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; +} diff --git a/lib/server/runtime-env.ts b/lib/server/runtime-env.ts new file mode 100644 index 0000000..7f08bff --- /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 process.env[name] || fallback; +}