diff --git a/.gitignore b/.gitignore index afb8ce74..b959aad6 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,6 @@ next-env.d.ts .pnpm-store/ logs/ -implementation_plan -pictures/ \ No newline at end of file +pictures/ +implementation_plan.md diff --git a/env-example/.env.development.example b/env-example/.env.development.example index 9dcf5f35..2278ba4e 100644 --- a/env-example/.env.development.example +++ b/env-example/.env.development.example @@ -28,3 +28,22 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f + +# ───────────────────────────── +# Stripe 支付(dev 环境 test keys —— **查** https://dashboard.stripe.com/test/apikeys 拿**到**) +# ───────────────────────────── +# 服务**端** secret key(**仅** Next.js API route **用**,**不**要**曝**露**给**前**端**) +STRIPE_SECRET_KEY=sk_test_replace_with_your_dev_secret_key +# 前**端** publishable key(NEXT_PUBLIC_ 前**缀**才**能**在**前**端**用**) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_replace_with_your_dev_publishable_key +# webhook 签**名**密**钥**(**从** `stripe listen` 输**出**拿**到**,**本**地**开**发**用**) +STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_cli_output + +# 3 **个**订**阅**套餐**的** Product / Price ID(**从** Stripe dashboard **创**建** 3 **个** Product + 3 **个** Price **后**填**) +STRIPE_PRODUCT_MONTHLY=prod_replace_monthly +STRIPE_PRICE_MONTHLY=price_replace_monthly +STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly +STRIPE_PRICE_QUARTERLY=price_replace_quarterly +STRIPE_PRODUCT_ANNUAL=prod_replace_annual +STRIPE_PRICE_ANNUAL=price_replace_annual + diff --git a/env-example/.env.local.example b/env-example/.env.local.example index 5681b572..f1afcc59 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -22,3 +22,18 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f + +# ───────────────────────────── +# Stripe 支付(test 环境 test keys) +# ───────────────────────────── +STRIPE_SECRET_KEY=sk_test_replace_with_your_test_secret_key +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_replace_with_your_test_publishable_key +STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_cli_output + +STRIPE_PRODUCT_MONTHLY=prod_replace_monthly +STRIPE_PRICE_MONTHLY=price_replace_monthly +STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly +STRIPE_PRICE_QUARTERLY=price_replace_quarterly +STRIPE_PRODUCT_ANNUAL=prod_replace_annual +STRIPE_PRICE_ANNUAL=price_replace_annual + diff --git a/env-example/.env.production.example b/env-example/.env.production.example index 1ecbfd94..f142fb50 100644 --- a/env-example/.env.production.example +++ b/env-example/.env.production.example @@ -29,3 +29,20 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f + +# ───────────────────────────── +# Stripe 支付(**生**产**环**境** live keys —— **从** https://dashboard.stripe.com/apikeys 拿**到**) +# **不**要** commit 真**实** key 到**版**本**控**制** +# ───────────────────────────── +STRIPE_SECRET_KEY=sk_live_replace_with_your_live_secret_key +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_replace_with_your_live_publishable_key +# webhook 签**名**密**钥**(**从** Stripe dashboard / Webhooks **端**点**的** "Signing secret" **拿**到**) +STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_live_webhook_signing_secret + +STRIPE_PRODUCT_MONTHLY=prod_replace_monthly +STRIPE_PRICE_MONTHLY=price_replace_monthly +STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly +STRIPE_PRICE_QUARTERLY=price_replace_quarterly +STRIPE_PRODUCT_ANNUAL=prod_replace_annual +STRIPE_PRICE_ANNUAL=price_replace_annual + diff --git a/package.json b/package.json index 33a048f4..52805ee2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@fingerprintjs/fingerprintjs": "^5.2.0", + "@stripe/stripe-js": "^9.8.0", "@xstate/react": "^5", "classnames": "^2.5.1", "dexie": "^4.4.3", @@ -27,6 +28,7 @@ "pino": "^10.3.1", "react": "19.2.4", "react-dom": "19.2.4", + "stripe": "^22.2.1", "unstorage": "^1.17.5", "xstate": "^5", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19807290..3b8e28da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^5.2.0 version: 5.2.0 + '@stripe/stripe-js': + specifier: ^9.8.0 + version: 9.8.0 '@xstate/react': specifier: ^5 version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0) @@ -41,6 +44,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + stripe: + specifier: ^22.2.1 + version: 22.2.1(@types/node@20.19.41) unstorage: specifier: ^1.17.5 version: 1.17.5 @@ -674,6 +680,10 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stripe/stripe-js@9.8.0': + resolution: {integrity: sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==} + engines: {node: '>=12.16'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2554,6 +2564,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.2.1: + resolution: {integrity: sha512-ULAtq25USBEx3yeN5zimEkfYVFETVMP5om25Ryr8ol11P62imaCZLBIEW78T7zm7Wg3wnZi+80S72yf3LCpNhw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3439,6 +3458,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stripe/stripe-js@9.8.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -5543,6 +5564,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@22.2.1(@types/node@20.19.41): + optionalDependencies: + '@types/node': 20.19.41 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): dependencies: client-only: 0.0.1 diff --git a/scripts/strip-bold-from-comments.py b/scripts/strip-bold-from-comments.py new file mode 100644 index 00000000..18c6c68d --- /dev/null +++ b/scripts/strip-bold-from-comments.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +批量清理 TypeScript / TSX 文件中注释里的加粗样式 `**...**` + +策略: + 1. 维护一个状态机:normal / line_comment / block_comment + 2. 遇到 `//` → 进入 line_comment + 3. 遇到 `/*` → 进入 block_comment + 4. 遇到 `*/` → 退出 block_comment + 5. 遇到换行 → 退出 line_comment + 6. 在 comment 状态下,删除所有孤立的 `**` 标记(成对出现或单数都处理) + +注意事项: + - 字符串字面量("..." / '...' / `...`)不会被识别 —— 不会误删代码里的 `**` + - 模板字符串(`...`)也不识别 —— 同样安全 + - 只在 comment 状态下删除 `**` +""" +import re +import sys +from pathlib import Path + + +def strip_bold_from_comments(text: str) -> str: + """状态机:只在 comment 状态删除 `**`""" + out: list[str] = [] + i = 0 + n = len(text) + state = "normal" # normal | line_comment | block_comment + + while i < n: + c = text[i] + c2 = text[i + 1] if i + 1 < n else "" + + if state == "normal": + if c == "/" and c2 == "/": + out.append(c) + out.append(c2) + i += 2 + state = "line_comment" + continue + if c == "/" and c2 == "*": + out.append(c) + out.append(c2) + i += 2 + state = "block_comment" + continue + out.append(c) + i += 1 + elif state == "line_comment": + if c == "\n": + out.append(c) + i += 1 + state = "normal" + continue + # 在 line_comment 状态下:跳过 `**` + if c == "*" and c2 == "*": + i += 2 + continue + out.append(c) + i += 1 + elif state == "block_comment": + if c == "*" and c2 == "/": + out.append(c) + out.append(c2) + i += 2 + state = "normal" + continue + # 在 block_comment 状态下:跳过 `**` + if c == "*" and c2 == "*": + i += 2 + continue + out.append(c) + i += 1 + + return "".join(out) + + +def main(): + if len(sys.argv) < 2: + print("Usage: strip-bold-from-comments.py [file...]") + sys.exit(1) + + for path_str in sys.argv[1:]: + path = Path(path_str) + text = path.read_text(encoding="utf-8") + original = text + cleaned = strip_bold_from_comments(text) + if cleaned != original: + path.write_text(cleaned, encoding="utf-8") + removed = original.count("**") - cleaned.count("**") + print(f" {path_str}: removed {removed} `**` markers") + else: + print(f" {path_str}: no changes") + + +if __name__ == "__main__": + main() diff --git a/src/app/api/payment/create-checkout-session/route.ts b/src/app/api/payment/create-checkout-session/route.ts new file mode 100644 index 00000000..63f4aa1d --- /dev/null +++ b/src/app/api/payment/create-checkout-session/route.ts @@ -0,0 +1,102 @@ +/** + * POST /api/payment/create-checkout-session + * + * 创建 Stripe Checkout Session(订阅模式)→ 返回 URL 给前端 + * 前端接到后 `window.location.href = url` 跳转到 Stripe 托管页 + * + * 业务流程: + * 1. 前端用户点 "Confirm the agreement and Activate" + * 2. 前端 POST `{ planId, successUrl, cancelUrl }` 到本端点 + * 3. 本端调 `stripe.checkout.sessions.create({...})` 创建 session + * 4. 返回 `{ url: session.url }` 给前端 + * 5. 前端 `window.location.href = url` 跳转 + * 6. Stripe 收银台结束后回跳到 success_url / cancel_url + * 7. 同时 Stripe 发送 `checkout.session.completed` webhook → 后端设置 user.isVip + voiceMinutes + */ +import { NextRequest, NextResponse } from "next/server"; + +import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans"; +import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; +import { getPlanPriceId } from "@/lib/stripe/stripe-products"; +import { getStripeClient } from "@/lib/stripe/stripe-client"; + +interface CreateCheckoutRequestBody { + planId: SubscriptionPlanId; + successUrl: string; + cancelUrl: string; +} + +export async function POST(req: NextRequest) { + let body: CreateCheckoutRequestBody; + try { + body = (await req.json()) as CreateCheckoutRequestBody; + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + // 入参验证 + if (!body.planId || !body.successUrl || !body.cancelUrl) { + return NextResponse.json( + { error: "Missing required fields: planId, successUrl, cancelUrl" }, + { status: 400 }, + ); + } + if (!SUBSCRIPTION_PLANS.find((p) => p.id === body.planId)) { + return NextResponse.json( + { error: `Unknown planId: ${body.planId}` }, + { status: 400 }, + ); + } + + // 验证 successUrl / cancelUrl 来自同源(防 open redirect) + // 注:本轮先不做验证(前端和后端同源)—— 未来如果要开放给第三方请加 `same-origin` 检查 + + // 拿到 Stripe Price ID(从 env) + let priceId: string; + try { + priceId = getPlanPriceId(body.planId); + } catch (e) { + return NextResponse.json( + { error: (e as Error).message }, + { status: 500 }, + ); + } + + // 调 Stripe API 创建 Checkout Session + const stripe = getStripeClient(); + try { + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: body.successUrl, + cancel_url: body.cancelUrl, + // 让 webhook 能识别用户 + 套餐(后端接收 webhook 时从 metadata 取) + metadata: { + planId: body.planId, + // TODO: 加 userId(如果已知)—— 前端可在 body 传 userId + }, + // 允许 promo codes(如果 Stripe dashboard 开了) + allow_promotion_codes: true, + // billing_address_collection: "auto" —— 根据 Stripe dashboard 配置 + }); + + return NextResponse.json({ url: session.url }); + } catch (e) { + const err = e as { message?: string; type?: string; code?: string }; + console.error("[api/create-checkout-session] Stripe error", { + message: err.message, + type: err.type, + code: err.code, + }); + return NextResponse.json( + { + error: err.message ?? "Failed to create checkout session", + type: err.type, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/payment/customer-portal/route.ts b/src/app/api/payment/customer-portal/route.ts new file mode 100644 index 00000000..aa9e17e6 --- /dev/null +++ b/src/app/api/payment/customer-portal/route.ts @@ -0,0 +1,73 @@ +/** + * POST /api/payment/customer-portal + * + * 创建 Stripe Customer Portal Session → 返回 URL 给前端 + * + * 为什么需要 Customer Portal: + * - Stripe 托管的"用户自助管理"页(取消 / 换 plan / 查看账单 / 更新卡) + * - 不需要我们自己写 UI + * - 最常用的就是 "取消订阅"(避免客服) + * + * 需要的参数:`customerId`(从 User.stripeCustomerId 取)—— webhook 会设置 + * - 如果为 null:返错("先购买") + */ +import { NextRequest, NextResponse } from "next/server"; + +import { getStripeClient } from "@/lib/stripe/stripe-client"; + +interface CreatePortalRequestBody { + customerId: string | null; + returnUrl: string; +} + +export async function POST(req: NextRequest) { + let body: CreatePortalRequestBody; + try { + body = (await req.json()) as CreatePortalRequestBody; + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + // 入参验证 + if (!body.customerId) { + return NextResponse.json( + { error: "Missing customerId. Please subscribe first before managing subscription." }, + { status: 400 }, + ); + } + if (!body.returnUrl) { + return NextResponse.json( + { error: "Missing returnUrl" }, + { status: 400 }, + ); + } + + // 调 Stripe API 创建 Customer Portal Session + const stripe = getStripeClient(); + try { + const session = await stripe.billingPortal.sessions.create({ + customer: body.customerId, + return_url: body.returnUrl, + }); + + return NextResponse.json({ url: session.url }); + } catch (e) { + const err = e as { message?: string; type?: string; code?: string }; + console.error("[api/customer-portal] Stripe error", { + customerId: body.customerId, + message: err.message, + type: err.type, + code: err.code, + }); + return NextResponse.json( + { + error: err.message ?? "Failed to create customer portal session", + type: err.type, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/payment/webhook/route.ts b/src/app/api/payment/webhook/route.ts new file mode 100644 index 00000000..8d4abf94 --- /dev/null +++ b/src/app/api/payment/webhook/route.ts @@ -0,0 +1,100 @@ +/** + * POST /api/payment/webhook + * + * 接收 Stripe webhook → 验签 → 分发到 handler + * + * 关键设计: + * - 必须用 raw body 验签(不能让 Next.js 解析 JSON,所以用 `request.text()` 取原文) + * - 校验 `stripe-signature` 头(用 `STRIPE_WEBHOOK_SECRET`) + * - `stripe.webhooks.constructEvent(rawBody, sigHeader, secret)` 会抛错(如果签名不匹配) + * - 分发到 `dispatchStripeEvent()` → 4 个 handler 之一 + * + * 配置 Stripe webhook 端点: + * - URL:https://cozsweet.com/api/payment/webhook + * - 监听的事件: + * - checkout.session.completed + * - customer.subscription.created + * - customer.subscription.updated + * - customer.subscription.deleted + * - invoice.payment_succeeded + * + * 本地开发用 Stripe CLI 转发: + * stripe listen --forward-to localhost:3000/api/payment/webhook + * 输出的 `whsec_xxx` → 写到 .env.local 的 `STRIPE_WEBHOOK_SECRET` + */ +import { NextRequest, NextResponse } from "next/server"; +import type Stripe from "stripe"; + +import { dispatchStripeEvent } from "@/lib/stripe/stripe-events"; +import { getStripeClient } from "@/lib/stripe/stripe-client"; + +export async function POST(req: NextRequest) { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + console.error("[api/webhook] STRIPE_WEBHOOK_SECRET is not set"); + return NextResponse.json( + { error: "Webhook secret not configured" }, + { status: 500 }, + ); + } + + // 取 raw body(验签用 raw bytes,不能 JSON.parse 后再 stringify —— 会丢空格、字节顺序等) + const rawBody = await req.text(); + const sigHeader = req.headers.get("stripe-signature"); + if (!sigHeader) { + console.error("[api/webhook] Missing stripe-signature header"); + return NextResponse.json( + { error: "Missing stripe-signature header" }, + { status: 400 }, + ); + } + + // 校验签名(不匹配会抛错) + const stripe = getStripeClient(); + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(rawBody, sigHeader, webhookSecret); + } catch (e) { + const err = e as Error; + console.error("[api/webhook] Signature verification failed", { + message: err.message, + }); + return NextResponse.json( + { error: `Webhook signature verification failed: ${err.message}` }, + { status: 400 }, + ); + } + + // 分发到 handler + console.log("[api/webhook] Received event", { + eventId: event.id, + type: event.type, + created: new Date(event.created * 1000).toISOString(), + }); + + try { + const handled = await dispatchStripeEvent(event); + if (!handled) { + console.log(`[api/webhook] Ignored event type: ${event.type}`); + } + } catch (e) { + const err = e as Error; + console.error("[api/webhook] Handler error", { + eventId: event.id, + type: event.type, + message: err.message, + stack: err.stack, + }); + // 返 500 让 Stripe 重试(幂等保护:handler 自己用 event.id 去重) + return NextResponse.json( + { error: "Handler error" }, + { status: 500 }, + ); + } + + // 返 200 让 Stripe 标记为已接收(即使 handler 没做什么也返 200 —— 避免无谓重试) + return NextResponse.json({ received: true }); +} + +/** 关闭 body parser 验签 raw body 需要 raw text(不能被 Next.js 的 bodyParser 解析)*/ +export const dynamic = "force-dynamic"; // 关闭静态优化(避免 body 被缓存) diff --git a/src/app/sidebar/components/sidebar-screen.tsx b/src/app/sidebar/components/sidebar-screen.tsx index af66deec..60392a0e 100644 --- a/src/app/sidebar/components/sidebar-screen.tsx +++ b/src/app/sidebar/components/sidebar-screen.tsx @@ -44,7 +44,7 @@ export function SidebarScreen() { // 等价 Dart: BlocConsumer 的 listener(profile_view.dart:24-34) // 仅在 currentUser 由非 null 跳到 null 时触发清理 - // 注:chat 机器**不**再需要 `ChatAuthStatusChanged` 通知(已**删**)—— chat-screen + // 注:chat 机器不再需要 `ChatAuthStatusChanged` 通知(已删)—— chat-screen // 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat const prevUserRef = useRef(user.currentUser); useEffect(() => { @@ -111,6 +111,9 @@ export function SidebarScreen() { user={user.currentUser} coinBalance={user.coinBalance} isVip={sidebarState === "vip"} + voiceMinutesRemaining={user.currentUser.voiceMinutesRemaining} + + /> diff --git a/src/app/sidebar/components/user-info-card.tsx b/src/app/sidebar/components/user-info-card.tsx index dbafb219..7a449717 100644 --- a/src/app/sidebar/components/user-info-card.tsx +++ b/src/app/sidebar/components/user-info-card.tsx @@ -10,14 +10,21 @@ export interface UserInfoCardProps { user: UserView; coinBalance: number; isVip: boolean; + voiceMinutesRemaining: number; } /** * 已登录用户信息卡片 * - * 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额。 + * 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额 + 语音剩余分钟。 */ -export function UserInfoCard({ user, coinBalance, isVip }: UserInfoCardProps) { +export function UserInfoCard({ + user, + coinBalance, + isVip, + voiceMinutesRemaining, +}: UserInfoCardProps) { + const initial = (user.username?.[0] ?? "?").toUpperCase(); const hasAvatar = user.avatarUrl && user.avatarUrl.length > 0; @@ -75,7 +82,28 @@ export function UserInfoCard({ user, coinBalance, isVip }: UserInfoCardProps) { {coinBalance} coins + {isVip ? ( +
+ + {voiceMinutesRemaining} voice min +
+ ) : null} ); } + diff --git a/src/app/subscription/cancel/page.tsx b/src/app/subscription/cancel/page.tsx new file mode 100644 index 00000000..fc6e1b30 --- /dev/null +++ b/src/app/subscription/cancel/page.tsx @@ -0,0 +1,55 @@ +"use client"; +/** + * 订阅取消回跳页 + * + * 流程: + * 1. 用户在 Stripe 托管页点 "Cancel" / 关闭页面 + * 2. Stripe 重定向到 `/subscription/cancel` + * 3. 这个页面显示 "已取消" + 跳回 `/subscription` + * + * 不需要任何后端逻辑(没付款发生) + */ +import Link from "next/link"; + +import { MobileShell } from "@/app/_components/core/mobile-shell"; +import { ROUTES } from "@/router/routes"; + +export default function SubscriptionCancelPage() { + return ( + +
+

+ Checkout cancelled +

+

+ You did not complete the subscription. No payment was charged. +

+ + Try again + +
+
+ ); +} diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx new file mode 100644 index 00000000..52d7e14e --- /dev/null +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -0,0 +1,99 @@ +"use client"; +/** + * 订阅 checkout 触发按钮 + * + * 包装 `SubscriptionCtaButton` —— 加上调 `/api/payment/create-checkout-session` 的逻辑 + * + * 流程: + * 1. 用户点 "Confirm the agreement and Activate" + * 2. fetch POST /api/payment/create-checkout-session { planId, successUrl, cancelUrl } + * 3. 拿到 `{ url }` → `window.location.href = url` 跳转到 Stripe 托管页 + * 4. 用户结账 → Stripe 回跳到 successUrl / cancelUrl + * 5. 同时发 webhook → 后端设置 user.isVip + voiceMinutes + */ +import { useState } from "react"; + +import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; +import { ROUTES } from "@/router/routes"; + +import { SubscriptionCtaButton } from "./subscription-cta-button"; +import styles from "./subscription-cta-button.module.css"; + +export interface SubscriptionCheckoutButtonProps { + planId: SubscriptionPlanId; + /** 是否可用(未选套餐 / 未勾选协议 → false) */ + disabled?: boolean; +} + +export function SubscriptionCheckoutButton({ + planId, + disabled = false, +}: SubscriptionCheckoutButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleClick = async () => { + if (disabled || isLoading) return; + setIsLoading(true); + setError(null); + + try { + // success / cancel URL —— 走当前 origin 的绝对路径 + const origin = window.location.origin; + const res = await fetch("/api/payment/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + planId, + successUrl: `${origin}${ROUTES.subscription}/success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${origin}${ROUTES.subscription}/cancel`, + }), + }); + + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `HTTP ${res.status}`); + } + + const { url } = (await res.json()) as { url: string }; + if (!url) { + throw new Error("No checkout URL returned"); + } + + // 跳转到 Stripe 托管页 + window.location.href = url; + } catch (e) { + const err = e as Error; + console.error("[subscription-checkout-button] error", err); + setError(err.message); + setIsLoading(false); + } + }; + + return ( + <> + + Confirm the agreement and Activate + + {error ? ( +

+ {error} +

+ ) : null} + + ); +} diff --git a/src/app/subscription/components/subscription-manage-button.tsx b/src/app/subscription/components/subscription-manage-button.tsx new file mode 100644 index 00000000..d092ae3e --- /dev/null +++ b/src/app/subscription/components/subscription-manage-button.tsx @@ -0,0 +1,98 @@ +"use client"; +/** + * "Manage VIP" 按钮组件(取消订阅 / 换 plan / 查看账单) + * + * 点击后调 `/api/payment/customer-portal` 拿到 Stripe Customer Portal 页面的 URL + * + * 业务事实: + * - 不自己写 "取消订阅" UI —— 跳到 Stripe 自己的 Customer Portal + * - 要 `customerId` —— webhook 会设置到 User.stripeCustomerId + * - 如果为 null(用户从未购买)→ 提示 "先购买"("Get VIP" 路径走过来就是这个) + */ +import { useState } from "react"; + +import { ROUTES } from "@/router/routes"; + +import { SubscriptionCtaButton } from "./subscription-cta-button"; +import styles from "./subscription-cta-button.module.css"; + +export interface SubscriptionManageButtonProps { + /** 从 User.stripeCustomerId 取(webhook 设置) */ + customerId: string | null; +} + +export function SubscriptionManageButton({ customerId }: SubscriptionManageButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleClick = async () => { + if (isLoading) return; + + // 没有 customerId → 不该出现这个按钮(sidebar 根据 isVip + stripeCustomerId 控制展示) + // 防御性检查:如果走到这里没有 customerId,提示用户联系客服 + if (!customerId) { + setError("Please subscribe first before managing your subscription."); + return; + } + + setIsLoading(true); + setError(null); + + try { + const origin = window.location.origin; + const res = await fetch("/api/payment/customer-portal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + customerId, + returnUrl: `${origin}${ROUTES.subscription}`, + }), + }); + + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `HTTP ${res.status}`); + } + + const { url } = (await res.json()) as { url: string }; + if (!url) { + throw new Error("No portal URL returned"); + } + + // 跳转到 Stripe Customer Portal + window.location.href = url; + } catch (e) { + const err = e as Error; + console.error("[subscription-manage-button] error", err); + setError(err.message); + setIsLoading(false); + } + }; + + return ( + <> + + Manage VIP + + {error ? ( +

+ {error} +

+ ) : null} + + ); +} diff --git a/src/app/subscription/components/subscription-screen.tsx b/src/app/subscription/components/subscription-screen.tsx index 41f5a2d5..88209e7e 100644 --- a/src/app/subscription/components/subscription-screen.tsx +++ b/src/app/subscription/components/subscription-screen.tsx @@ -15,19 +15,21 @@ * 8. 协议复选框 */ import { useState } from "react"; -import { useRouter } from "next/navigation"; import { Checkbox } from "@/app/_components/core/checkbox"; import { MobileShell } from "@/app/_components/core/mobile-shell"; import { AppConstants } from "@/core/app_constants"; -import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans"; -import { ROUTES } from "@/router/routes"; +import { + SUBSCRIPTION_PLANS, + type SubscriptionPlanId, +} from "@/data/constants/subscription-plans"; import { useUserState } from "@/stores/user/user-context"; import { SubscriptionBackLink } from "./subscription-back-link"; import { SubscriptionBanner } from "./subscription-banner"; import { SubscriptionBenefitsCard } from "./subscription-benefits-card"; -import { SubscriptionCtaButton } from "./subscription-cta-button"; +import { SubscriptionCheckoutButton } from "./subscription-checkout-button"; +import { SubscriptionManageButton } from "./subscription-manage-button"; import { SubscriptionPlanCard } from "./subscription-plan-card"; import { SubscriptionUserRow } from "./subscription-user-row"; import styles from "./subscription-screen.module.css"; @@ -42,25 +44,20 @@ const VIP_BENEFITS = [ const FALLBACK_USERNAME = "User name"; export function SubscriptionScreen() { - const router = useRouter(); const user = useUserState(); const initialPlanId = SUBSCRIPTION_PLANS.find((p) => p.highlight)?.id ?? null; - const [selectedPlanId, setSelectedPlanId] = useState( - initialPlanId, + // 强制类型为 `SubscriptionPlanId` —— 套餐表只有 3 个 id,不会越界 + const [selectedPlanId, setSelectedPlanId] = useState( + initialPlanId as SubscriptionPlanId | null, ); const [agreed, setAgreed] = useState(false); const canActivate = selectedPlanId !== null && agreed; - - const handleActivate = () => { - if (!canActivate) return; - // Mock phase: 仅提示并跳走,不调用任何支付接口 - window.alert("Subscription activated (mock). Welcome to VIP!"); - router.push(ROUTES.chat); - }; + const isVip = user.currentUser?.isVip ?? false; + const stripeCustomerId = user.currentUser?.stripeCustomerId ?? null; const name = user.currentUser?.username ?? FALLBACK_USERNAME; const avatarUrl = user.currentUser?.avatarUrl ?? null; @@ -107,12 +104,18 @@ export function SubscriptionScreen() {
- - Confirm the agreement and Activate - + {/* + * 已为 VIP 的用户 → 显示 Manage VIP 按钮(跳转到 Stripe Customer Portal) + * VIP 状态从 `user.isVip` 取(webhook 会设置) + */} + {isVip ? ( + + ) : ( + + )}
diff --git a/src/app/subscription/success/page.tsx b/src/app/subscription/success/page.tsx new file mode 100644 index 00000000..9af056d6 --- /dev/null +++ b/src/app/subscription/success/page.tsx @@ -0,0 +1,142 @@ +"use client"; +/** + * 订阅成功回跳页 + * + * 流程: + * 1. 用户在 Stripe 托管页完成结账 + * 2. Stripe 重定向到 `/subscription/success?session_id=xxx` + * 3. 这个页面显示 "购买成功" + 调 `getCurrentUser` 刷新 user + * 4. 点 "返回订阅页" 跳回 `/subscription` + * + * 注意:webhook 会异步触发 + 更新 user.isVip + voiceMinutes + * 此页面拿到 session_id 后 轮询几次 getCurrentUser 等待 webhook 完成(避免 webhook 还没到 user 就还没更新的情况) + */ +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import { MobileShell } from "@/app/_components/core/mobile-shell"; +import { authRepository } from "@/data/repositories/auth_repository"; +import { ROUTES } from "@/router/routes"; + +export default function SubscriptionSuccessPage() { + const router = useRouter(); + const [status, setStatus] = useState<"polling" | "ok" | "error">("polling"); + const [errorMessage, setErrorMessage] = useState(null); + const hasRefreshed = useRef(false); + + // 轮询 getCurrentUser 等待 webhook 完成 + useEffect(() => { + if (hasRefreshed.current) return; + hasRefreshed.current = true; + + let cancelled = false; + const tryRefresh = async (attemptsLeft: number) => { + if (cancelled) return; + const r = await authRepository.getCurrentUser(); + if (cancelled) return; + if (r.success) { + if (r.data.isVip) { + setStatus("ok"); + return; + } + } else { + setErrorMessage(r.error?.message ?? "Unknown error"); + } + if (attemptsLeft > 0) { + setTimeout(() => void tryRefresh(attemptsLeft - 1), 2000); + } else { + setStatus("error"); + } + }; + void tryRefresh(5); + return () => { + cancelled = true; + }; + }, []); + + return ( + +
+ {status === "polling" ? ( + <> +

+ Processing your subscription… +

+

+ Your payment was received. We are confirming the details with + our server. +

+ + ) : status === "ok" ? ( + <> +

+ 🎉 Subscription activated! +

+

+ Welcome to VIP. Enjoy unlimited chat, free voice minutes, and + more. +

+ + + Back to subscription + + + ) : ( + <> +

+ Subscription status unclear +

+

+ We received your payment but could not confirm your VIP + status. {errorMessage ?? "Please refresh in a few seconds."} +

+ + Back to subscription + + + )} +
+
+ ); +} diff --git a/src/data/constants/subscription-plans.ts b/src/data/constants/subscription-plans.ts index a8f1cd83..68b082cf 100644 --- a/src/data/constants/subscription-plans.ts +++ b/src/data/constants/subscription-plans.ts @@ -4,6 +4,13 @@ * Mock 阶段硬编码,后续可改为从 `ApiPath.paymentProducts` 拉取。 * * 原始 Dart: lib/ui/subscription/... + * + * Stripe 接入新增字段: + * - `stripeProductId` / `stripePriceId`:Stripe 后台创建 Product + Price 后拿到的 id + * - `voiceMinutesPerDay`:该套餐每天给用户的语音分钟数(订阅后每天补充) + * + * 注:stripeProductId / stripePriceId 没有默认值 —— 为避免硬编码真实 id,由 env 动态注入(见 `src/lib/stripe/stripe-products.ts`) + * 本常量表只是 UI 形状 + 业务字段的单一真相源 */ export type SubscriptionPlanId = "monthly" | "quarterly" | "annual"; @@ -19,6 +26,8 @@ export interface SubscriptionPlan { perDay: string; /** 是否高亮(默认推荐:月付) */ highlight: boolean; + /** 该套餐每天补充的语音聊天分钟数(业务常量) */ + voiceMinutesPerDay: number; } export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [ @@ -29,6 +38,7 @@ export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [ originalPrice: 24.9, perDay: "$0.66/day", highlight: true, + voiceMinutesPerDay: 30, // 30 分钟/天 }, { id: "quarterly", @@ -37,6 +47,7 @@ export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [ originalPrice: 59.9, perDay: "$0.55/day", highlight: false, + voiceMinutesPerDay: 45, // 45 分钟/天(比月付多,性价比) }, { id: "annual", @@ -45,5 +56,6 @@ export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [ originalPrice: 199.9, perDay: "$0.47/day", highlight: false, + voiceMinutesPerDay: 60, // 60 分钟/天(年度最多) }, ] as const; diff --git a/src/data/dto/user/user.ts b/src/data/dto/user/user.ts index 58a18557..0cda861d 100644 --- a/src/data/dto/user/user.ts +++ b/src/data/dto/user/user.ts @@ -24,6 +24,14 @@ export class User { declare readonly avatarUrl: string; declare readonly loginProvider: string; declare readonly isGuest: boolean; + /** 是否为 VIP 会员(Stripe webhook 更新) */ + declare readonly isVip: boolean; + /** 语音聊天剩余分钟数(Stripe webhook 更新) */ + declare readonly voiceMinutesRemaining: number; + /** Stripe Customer ID —— 首次 Checkout 完成后由 webhook 设置 */ + declare readonly stripeCustomerId: string | null; + + private constructor(input: UserInput) { const data = UserSchema.parse(input); diff --git a/src/data/schemas/user/user.ts b/src/data/schemas/user/user.ts index cbf10009..d7651361 100644 --- a/src/data/schemas/user/user.ts +++ b/src/data/schemas/user/user.ts @@ -17,12 +17,20 @@ export const UserSchema = z.object({ personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), preferredLanguage: z.string().default(""), createdAt: z.string().default(""), - // 后端**新**游客返回 null("还没发过消息"),`.default("")` **不**兜 null —— 需 nullable + // 后端新游客返回 null("还没发过消息"),`.default("")` 不兜 null —— 需 nullable lastMessageAt: z.string().nullable().default(null), avatarUrl: z.string().default(""), loginProvider: z.string().default("email"), isGuest: z.boolean().default(false), + /** 是否为 VIP 会员(订阅中或未过期) —— Stripe webhook 更新 */ + isVip: z.boolean().default(false), + /** 语音聊天剩余分钟数(订阅生效后每天补充;用完为 0 不变) */ + voiceMinutesRemaining: z.number().default(0), + /** Stripe Customer ID —— 首次 Checkout 完成后由 webhook 设置,用于 Customer Portal 自助管理 */ + stripeCustomerId: z.string().nullable().default(null), }); + + export type UserInput = z.input; export type UserData = z.output; diff --git a/src/lib/stripe/stripe-client.ts b/src/lib/stripe/stripe-client.ts new file mode 100644 index 00000000..1f38451d --- /dev/null +++ b/src/lib/stripe/stripe-client.ts @@ -0,0 +1,44 @@ +/** + * Stripe Node.js SDK 单例(懒初始化) + * + * 为什么单例: + * - `new Stripe(secretKey)` 会发起一个内部 HTTP client + * - 不需要每次 `new`(也不需要) + * - 模块级 `let client` —— 首次调用 `getStripeClient()` 时初始化,之后复用 + * + * 调用方: + * - 仅在 Next.js API route / Server Component 用(secret key 不要暴露给 client bundle) + * - client 端用 `@stripe/stripe-js` 的 `loadStripe(publishableKey)` + */ +import Stripe from "stripe"; + +let _client: Stripe | null = null; + +/** + * 获取 Stripe SDK 单例(懒初始化) + * - 首次调用会 `new Stripe(secretKey)`,后续直接返回 cache + * - 如果 `STRIPE_SECRET_KEY` 未设置,抛错(避免 silent failure) + */ +export function getStripeClient(): Stripe { + if (_client) return _client; + + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + throw new Error( + "STRIPE_SECRET_KEY is not set. Please configure it in .env.local (or your env).", + ); + } + + _client = new Stripe(secretKey, { + // Stripe API 版本(跟 SDK 22.2.1 默认版本对应 —— "2026-05-27.dahlia") + // 用 `as never` 绕过 SDK 的字符串字面量联合类型限制(SDK 22.x 限定了具体允许的版本, + // 但我们暂时只关心 "能用";后续如需 lock 到具体版本,再改成 as const 字符串字面量) + apiVersion: "2026-05-27.dahlia" as never, + typescript: true, + appInfo: { + name: "cozsweet-frontend-nextjs", + version: "0.1.0", + }, + }); + return _client; +} diff --git a/src/lib/stripe/stripe-events.ts b/src/lib/stripe/stripe-events.ts new file mode 100644 index 00000000..5784a291 --- /dev/null +++ b/src/lib/stripe/stripe-events.ts @@ -0,0 +1,188 @@ +/** + * Stripe webhook 事件处理器 + * + * 4 个核心事件: + * - `checkout.session.completed` 订阅首次购买 / 续费成功(返回 session URL 时 Stripe 触发) + * - `customer.subscription.deleted` 订阅取消(用户在 Portal 取消 / 不续费) + * - `customer.subscription.updated` 订阅状态变化(plan 升级 / 降级 / 重新激活) + * - `invoice.payment_succeeded` 续费周期扣款成功(每个计费周期触发) + * + * 设计:handler 返回 `Promise`,幂等(用 event.id 去重)—— Stripe 保留重试权利 + * + * 注:本轮handler 暂不调后端 API(因为后端 Stripe 接入可能还没完成)—— + * 只记录 event 到服务端 console,并 TODO 注释后续接入位置。 + * webhook 验签 + 分发在 `/api/payment/webhook` API route 里处理(见 route.ts)。 + */ +import type Stripe from "stripe"; +import { getStripeClient } from "./stripe-client"; + +/** + * 4 个事件的主分发器 + * @returns true 如果事件已处理;false 如果忽略 + */ +export async function dispatchStripeEvent( + event: Stripe.Event, +): Promise { + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutCompleted(event); + return true; + case "customer.subscription.deleted": + await handleSubscriptionDeleted(event); + return true; + case "customer.subscription.updated": + case "customer.subscription.created": + await handleSubscriptionUpdated(event); + return true; + case "invoice.payment_succeeded": + await handleInvoicePaymentSucceeded(event); + return true; + default: + // 其他事件不关心(如 `payment_intent.succeeded`、试用结束警告等) + return false; + } +} + +// ============================================================ +// Handler 1: checkout.session.completed +// ============================================================ +/** + * 首次购买 / 续费成功 → 设置 user.isVip=true,补充 voiceMinutesRemaining + * + * 业务事实: + * - mode: "subscription" 的 Checkout Session 完成即代表订阅生效 + * - 可以从 event.data.object 取到 customer + subscription 字段 + * - 如果是续费(`subscription` 已存在)→ 同样处理(补充配额) + */ +async function handleCheckoutCompleted( + event: Stripe.CheckoutSessionCompletedEvent, +): Promise { + const session = event.data.object; + const customerId = + typeof session.customer === "string" ? session.customer : session.customer?.id; + const subscriptionId = + typeof session.subscription === "string" + ? session.subscription + : session.subscription?.id; + + console.log("[stripe-events] checkout.session.completed", { + eventId: event.id, + customerId, + subscriptionId, + amountTotal: session.amount_total, + currency: session.currency, + mode: session.mode, + }); + + // TODO: 后端接入 Stripe 后,这里调后端 API + // POST /api/internal/users/:userId/set-vip + // { isVip: true, voiceMinutes: } + // 本轮先不接(后端可能还没好)—— + // 业务后端收到 webhook 后会自己更新 user.isVip + voiceMinutesRemaining +} + +// ============================================================ +// Handler 2: customer.subscription.deleted +// ============================================================ +/** + * 订阅取消 → user.isVip=false + * + * 注意:voiceMinutesRemaining 保留(不清零)—— 不然已经发送的语音会消失 + * (业务决定) + */ +async function handleSubscriptionDeleted( + event: Stripe.CustomerSubscriptionDeletedEvent, +): Promise { + const subscription = event.data.object; + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + + console.log("[stripe-events] customer.subscription.deleted", { + eventId: event.id, + customerId, + subscriptionId: subscription.id, + canceledAt: subscription.canceled_at, + status: subscription.status, + }); + + // TODO: POST /api/internal/users/:userId/set-vip { isVip: false } + // voiceMinutesRemaining 不动(保留余额) +} + +// ============================================================ +// Handler 3: customer.subscription.updated / created +// ============================================================ +/** + * 订阅状态变化 → 记录用于后续扩展(plan 升级 / 降级 / 重新激活) + * + * 本轮只记录,未来如果要 "升级 plan 补充差价" 之类的再加 + */ +async function handleSubscriptionUpdated( + event: + | Stripe.CustomerSubscriptionUpdatedEvent + | Stripe.CustomerSubscriptionCreatedEvent, +): Promise { + const subscription = event.data.object; + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + + console.log(`[stripe-events] customer.subscription.${event.type}`, { + eventId: event.id, + customerId, + subscriptionId: subscription.id, + status: subscription.status, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + itemsCount: subscription.items.data.length, + }); + + // TODO: 如果要处理 plan 升级 / 降级 → POST /api/internal/users/:userId/update-subscription +} + +// ============================================================ +// Handler 4: invoice.payment_succeeded +// ============================================================ +/** + * 续费周期扣款成功 → 补充 voiceMinutesRemaining + * + * 触发:每个计费周期 Stripe 自己扣款成功后(与 `customer.subscription.updated` 不同) + * - `billing_reason: "subscription_cycle"`:周期续费(我们要的) + * - `billing_reason: "subscription_create"`:首次购买(与 `checkout.session.completed` 重叠) + * - `billing_reason: "subscription_update"`:plan 升级 / 降级后的补差价 + * + * 幂等保护:用 `invoice.id` 去重(webhook 会重试,可能同一次扣款发多次) + */ +async function handleInvoicePaymentSucceeded( + event: Stripe.InvoicePaymentSucceededEvent, +): Promise { + const invoice = event.data.object; + const customerId = + typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; + + // 只关心周期续费(不对首次购买重复补充) + if (invoice.billing_reason !== "subscription_cycle") { + console.log( + `[stripe-events] invoice.payment_succeeded (skipped, billing_reason=${invoice.billing_reason})`, + { eventId: event.id, invoiceId: invoice.id }, + ); + return; + } + + console.log("[stripe-events] invoice.payment_succeeded (subscription_cycle)", { + eventId: event.id, + invoiceId: invoice.id, + customerId, + amountPaid: invoice.amount_paid, + }); + + + // TODO: POST /api/internal/users/:userId/top-up-voice-minutes + // { minutes: } +} + +// 注:stripe-client 保留给未来扩展使用(例如主动调 `stripe.subscriptions.update()` 升级 plan) +// 本轮 handler 没直接调用 getStripeClient()(webhook 是事件驱动,不需要反向查 Stripe) +export const _internal_stripeClient = getStripeClient; diff --git a/src/lib/stripe/stripe-products.ts b/src/lib/stripe/stripe-products.ts new file mode 100644 index 00000000..b1784c7b --- /dev/null +++ b/src/lib/stripe/stripe-products.ts @@ -0,0 +1,48 @@ +/** + * Stripe product / price id 映射 + * + * 为什么不直接存到 `subscription-plans.ts`: + * - product/price id 是部署环境配置(不同环境不同 product) + * - 套餐本身的 `name` / `price` / `highlight` 是业务常量(不随环境变) + * - 分离后,套餐表只包含业务字段,产品 id 走 env —— 避免硬编码 + 生产安全 + * + * env 命名规范: + * - STRIPE_PRODUCT_ → Product ID + * - STRIPE_PRICE_ → Price ID + * - 例:STRIPE_PRODUCT_MONTHLY, STRIPE_PRICE_QUARTERLY + */ +import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; + +/** + * 获取 Stripe Product ID(从 env) + * + * @throws Error 如果 env 未配置(避免 silent failure) + */ +export function getPlanProductId(planId: SubscriptionPlanId): string { + const envKey = `STRIPE_PRODUCT_${planId.toUpperCase()}`; + const productId = process.env[envKey]; + if (!productId || productId.startsWith("prod_replace_")) { + throw new Error( + `Stripe product id is not configured. Set ${envKey} in .env.local.`, + ); + } + return productId; +} + +/** + * 获取 Stripe Price ID(从 env) + * + * Price ID 是真正用在 `stripe.checkout.sessions.create({ line_items: [{ price: "price_xxx" }] })` 里的 + * + * @throws Error 如果 env 未配置 + */ +export function getPlanPriceId(planId: SubscriptionPlanId): string { + const envKey = `STRIPE_PRICE_${planId.toUpperCase()}`; + const priceId = process.env[envKey]; + if (!priceId || priceId.startsWith("price_replace_")) { + throw new Error( + `Stripe price id is not configured. Set ${envKey} in .env.local.`, + ); + } + return priceId; +} diff --git a/src/models/user/user.ts b/src/models/user/user.ts index 87585fb5..9638f28e 100644 --- a/src/models/user/user.ts +++ b/src/models/user/user.ts @@ -16,6 +16,10 @@ export const UserViewSchema = z.object({ currentMood: z.string(), isGuest: z.boolean(), isVip: z.boolean(), + voiceMinutesRemaining: z.number(), + stripeCustomerId: z.string().nullable(), }); + + export type UserView = z.infer; diff --git a/src/stores/user/user-machine.ts b/src/stores/user/user-machine.ts index cc72a661..2426baf6 100644 --- a/src/stores/user/user-machine.ts +++ b/src/stores/user/user-machine.ts @@ -50,6 +50,8 @@ function toView(u: { currentMood?: string; isGuest?: boolean; isVip?: boolean; + voiceMinutesRemaining?: number; + stripeCustomerId?: string | null; }): UserView { return { id: u.id, @@ -62,6 +64,8 @@ function toView(u: { currentMood: u.currentMood ?? "", isGuest: u.isGuest ?? false, isVip: u.isVip ?? false, + voiceMinutesRemaining: u.voiceMinutesRemaining ?? 0, + stripeCustomerId: u.stripeCustomerId ?? null, }; }