From 2f0b49606604f9805f905c756b987fb4feacf446 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 20 May 2026 22:23:28 +0800 Subject: [PATCH] =?UTF-8?q?ops=20=E8=AE=A2=E9=98=85=E5=BC=80=E9=80=9A?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20Next.js=20=E7=9B=B4=E8=BF=9E=20Supabase?= =?UTF-8?q?=EF=BC=8C=E5=85=8D=E5=8E=BB=20VPS=20=E9=89=B4=E6=9D=83=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/ops/subscriptions/grant/route.ts | 163 ++++++++++++++++-- .../account/__tests__/paymentShell.test.ts | 18 ++ 2 files changed, 171 insertions(+), 10 deletions(-) diff --git a/frontend/app/api/ops/subscriptions/grant/route.ts b/frontend/app/api/ops/subscriptions/grant/route.ts index 10b1f919..d1b6e130 100644 --- a/frontend/app/api/ops/subscriptions/grant/route.ts +++ b/frontend/app/api/ops/subscriptions/grant/route.ts @@ -1,25 +1,168 @@ import { NextRequest, NextResponse } from "next/server"; -import { applyAuthResponseCookies, buildBackendRequestHeaders, BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; -const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || ""; + +function parseAdminEmails() { + return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +async function getBearerEmail(req: NextRequest) { + const auth = String(req.headers.get("authorization") || "").trim(); + const token = auth.replace(/^bearer\s+/i, "").trim(); + const supabaseUrl = String(process.env.NEXT_PUBLIC_SUPABASE_URL || "").trim(); + const anonKey = String(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "").trim(); + if (!token || !supabaseUrl || !anonKey) return ""; + const res = await fetch(`${supabaseUrl.replace(/\/$/, "")}/auth/v1/user`, { + headers: { + apikey: anonKey, + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + cache: "no-store", + }); + if (!res.ok) return ""; + const data = (await res.json()) as { email?: string }; + return String(data.email || "").trim().toLowerCase(); +} + +async function findSupabaseUserIdByEmail(email: string) { + const supabaseUrl = String(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || "") + .trim() + .replace(/\/$/, ""); + const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim(); + if (!supabaseUrl || !serviceRoleKey) { + throw new Error("Supabase service role is not configured on Vercel"); + } + const res = await fetch( + `${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`, + { + headers: { + apikey: serviceRoleKey, + Authorization: `Bearer ${serviceRoleKey}`, + Accept: "application/json", + }, + cache: "no-store", + }, + ); + const data = (await res.json().catch(() => ({}))) as { + users?: Array<{ id?: string }>; + }; + if (!res.ok) throw new Error(`Supabase user lookup failed: ${JSON.stringify(data).slice(0, 200)}`); + const userId = String(data.users?.[0]?.id || "").trim(); + if (!userId) { + const error = new Error(`user not found: ${email}`); + (error as Error & { status?: number }).status = 404; + throw error; + } + return { supabaseUrl, serviceRoleKey, userId }; +} + +async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, authEmail?: string | null) { + const adminEmail = String(authEmail || (await getBearerEmail(req)) || "") + .trim() + .toLowerCase(); + const allowedEmails = parseAdminEmails(); + if (!adminEmail) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!allowedEmails.includes(adminEmail)) { + return NextResponse.json({ error: "ops admin required" }, { status: 403 }); + } + + const body = JSON.parse(bodyText || "{}") as { + email?: string; + plan_code?: string; + days?: number; + }; + const email = String(body.email || "").trim().toLowerCase(); + const planCode = String(body.plan_code || "pro_monthly").trim(); + const days = Math.max(1, Math.min(365, Number(body.days || 30))); + if (!email) return NextResponse.json({ error: "email is required" }, { status: 400 }); + if (planCode !== "pro_monthly") { + return NextResponse.json({ error: "invalid plan_code" }, { status: 400 }); + } + + try { + const { supabaseUrl, serviceRoleKey, userId } = await findSupabaseUserIdByEmail(email); + const now = new Date(); + const expires = new Date(now.getTime() + days * 86_400_000); + const payload = { + user_id: userId, + email, + plan_code: planCode, + "status": "active", + starts_at: now.toISOString(), + expires_at: expires.toISOString(), + source: "ops_manual_grant_next_fallback", + created_at: now.toISOString(), + updated_at: now.toISOString(), + }; + const insert = await fetch(`${supabaseUrl}/rest/v1/subscriptions`, { + method: "POST", + headers: { + apikey: serviceRoleKey, + Authorization: `Bearer ${serviceRoleKey}`, + "Content-Type": "application/json", + Prefer: "return=representation", + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + const raw = await insert.text(); + if (!insert.ok) { + return NextResponse.json( + { error: "Supabase insert failed", detail: raw.slice(0, 300) }, + { status: 500 }, + ); + } + return NextResponse.json({ + ok: true, + user_id: userId, + plan_code: planCode, + days, + expires_at: expires.toISOString(), + fallback: "next_supabase_direct", + }); + } catch (error) { + const status = Number((error as Error & { status?: number }).status || 500); + return NextResponse.json({ error: String(error) }, { status }); + } +} export async function POST(req: NextRequest) { - if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); const body = await req.text(); - const headers: Record = { ...auth.headers as Record, "Content-Type": "application/json" }; - // Ops endpoints: pass entitlement token as Bearer for robust admin auth. - if (ENTITLEMENT_TOKEN) { - headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`; + if (!API_BASE) { + return grantSubscriptionDirectly(req, body, auth.authEmail); } + const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, { - method: "POST", headers, body, cache: "no-store", + method: "POST", + headers: { ...(auth.headers as Record), "Content-Type": "application/json" }, + body, + cache: "no-store", }); const raw = await res.text(); - const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); + if (res.status === 404) { + const fallback = await grantSubscriptionDirectly(req, body, auth.authEmail); + return applyAuthResponseCookies(fallback, auth.response); + } + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); return applyAuthResponseCookies(response, auth.response); - } catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" }); } + } catch (e) { + return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" }); + } } diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index d5c5d6f3..3d53a609 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -25,6 +25,18 @@ export function runTests() { path.join(projectRoot, "app", "api", "analytics", "events", "route.ts"), "utf8", ); + const grantRouteSource = fs.readFileSync( + path.join( + projectRoot, + "app", + "api", + "ops", + "subscriptions", + "grant", + "route.ts", + ), + "utf8", + ); const subscriptionsPageSource = fs.readFileSync( path.join( projectRoot, @@ -84,4 +96,10 @@ export function runTests() { subscriptionsPageSource.includes("/api/ops/subscriptions/extend"), "ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale", ); + assert( + grantRouteSource.includes("grantSubscriptionDirectly") && + grantRouteSource.includes("res.status === 404") && + grantRouteSource.includes('"status": "active"'), + "ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", + ); }