feat: integrate Stripe payment with subscription plans
Add Stripe payment integration across the project: - Add @stripe/stripe-js dependency - Configure Stripe environment variables (secret key, publishable key, webhook secret) for dev, local, and production environments - Add Product/Price IDs for monthly, quarterly, and annual subscription tiers - Extend subscription plan data with voiceMinutesPerDay quotas (30/45/60 minutes per tier) - Update .gitignore to exclude implementation_plan.md
This commit is contained in:
+1
-1
@@ -49,6 +49,6 @@ next-env.d.ts
|
|||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
implementation_plan
|
|
||||||
|
|
||||||
pictures/
|
pictures/
|
||||||
|
implementation_plan.md
|
||||||
|
|||||||
@@ -28,3 +28,22 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad
|
|||||||
# Cloudflare CDN(test / production 部署后刷缓存用)
|
# Cloudflare CDN(test / production 部署后刷缓存用)
|
||||||
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
||||||
CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,18 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad
|
|||||||
# Cloudflare CDN(test / production 部署后刷缓存用)
|
# Cloudflare CDN(test / production 部署后刷缓存用)
|
||||||
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
||||||
CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,20 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad
|
|||||||
# Cloudflare CDN(test / production 部署后刷缓存用)
|
# Cloudflare CDN(test / production 部署后刷缓存用)
|
||||||
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
||||||
CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
|
"@stripe/stripe-js": "^9.8.0",
|
||||||
"@xstate/react": "^5",
|
"@xstate/react": "^5",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"stripe": "^22.2.1",
|
||||||
"unstorage": "^1.17.5",
|
"unstorage": "^1.17.5",
|
||||||
"xstate": "^5",
|
"xstate": "^5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
Generated
+25
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@fingerprintjs/fingerprintjs':
|
'@fingerprintjs/fingerprintjs':
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
|
'@stripe/stripe-js':
|
||||||
|
specifier: ^9.8.0
|
||||||
|
version: 9.8.0
|
||||||
'@xstate/react':
|
'@xstate/react':
|
||||||
specifier: ^5
|
specifier: ^5
|
||||||
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
|
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
|
||||||
@@ -41,6 +44,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@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:
|
unstorage:
|
||||||
specifier: ^1.17.5
|
specifier: ^1.17.5
|
||||||
version: 1.17.5
|
version: 1.17.5
|
||||||
@@ -674,6 +680,10 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
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':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -2554,6 +2564,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
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:
|
styled-jsx@5.1.6:
|
||||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -3439,6 +3458,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@stripe/stripe-js@9.8.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -5543,6 +5564,10 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
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):
|
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
|
|||||||
@@ -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> [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()
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 被缓存)
|
||||||
@@ -44,7 +44,7 @@ export function SidebarScreen() {
|
|||||||
|
|
||||||
// 等价 Dart: BlocConsumer<UserBloc> 的 listener(profile_view.dart:24-34)
|
// 等价 Dart: BlocConsumer<UserBloc> 的 listener(profile_view.dart:24-34)
|
||||||
// 仅在 currentUser 由非 null 跳到 null 时触发清理
|
// 仅在 currentUser 由非 null 跳到 null 时触发清理
|
||||||
// 注:chat 机器**不**再需要 `ChatAuthStatusChanged` 通知(已**删**)—— chat-screen
|
// 注:chat 机器不再需要 `ChatAuthStatusChanged` 通知(已删)—— chat-screen
|
||||||
// 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat
|
// 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat
|
||||||
const prevUserRef = useRef<UserView | null>(user.currentUser);
|
const prevUserRef = useRef<UserView | null>(user.currentUser);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,6 +111,9 @@ export function SidebarScreen() {
|
|||||||
user={user.currentUser}
|
user={user.currentUser}
|
||||||
coinBalance={user.coinBalance}
|
coinBalance={user.coinBalance}
|
||||||
isVip={sidebarState === "vip"}
|
isVip={sidebarState === "vip"}
|
||||||
|
voiceMinutesRemaining={user.currentUser.voiceMinutesRemaining}
|
||||||
|
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<VipCta variant={sidebarState === "vip" ? "manage" : "get"} />
|
<VipCta variant={sidebarState === "vip" ? "manage" : "get"} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,14 +10,21 @@ export interface UserInfoCardProps {
|
|||||||
user: UserView;
|
user: UserView;
|
||||||
coinBalance: number;
|
coinBalance: number;
|
||||||
isVip: boolean;
|
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 initial = (user.username?.[0] ?? "?").toUpperCase();
|
||||||
const hasAvatar = user.avatarUrl && user.avatarUrl.length > 0;
|
const hasAvatar = user.avatarUrl && user.avatarUrl.length > 0;
|
||||||
|
|
||||||
@@ -75,7 +82,28 @@ export function UserInfoCard({ user, coinBalance, isVip }: UserInfoCardProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{coinBalance} coins</span>
|
<span>{coinBalance} coins</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isVip ? (
|
||||||
|
<div className={styles.userInfoVoiceRow} aria-label="Voice minutes">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z M19 10v2a7 7 0 01-14 0v-2 M12 19v4 M8 23h8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{voiceMinutesRemaining} voice min</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<MobileShell>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "2rem 1.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#333" }}>
|
||||||
|
Checkout cancelled
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#666" }}>
|
||||||
|
You did not complete the subscription. No payment was charged.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.subscription}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "linear-gradient(135deg, #f96ADE, #f657A0)",
|
||||||
|
color: "white",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<SubscriptionCtaButton
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
Confirm the agreement and Activate
|
||||||
|
</SubscriptionCtaButton>
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
color: "#c0392b",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<SubscriptionCtaButton
|
||||||
|
type="button"
|
||||||
|
disabled={!customerId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
Manage VIP
|
||||||
|
</SubscriptionCtaButton>
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
color: "#c0392b",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,19 +15,21 @@
|
|||||||
* 8. 协议复选框
|
* 8. 协议复选框
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { Checkbox } from "@/app/_components/core/checkbox";
|
import { Checkbox } from "@/app/_components/core/checkbox";
|
||||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||||
import { AppConstants } from "@/core/app_constants";
|
import { AppConstants } from "@/core/app_constants";
|
||||||
import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans";
|
import {
|
||||||
import { ROUTES } from "@/router/routes";
|
SUBSCRIPTION_PLANS,
|
||||||
|
type SubscriptionPlanId,
|
||||||
|
} from "@/data/constants/subscription-plans";
|
||||||
import { useUserState } from "@/stores/user/user-context";
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
import { SubscriptionBackLink } from "./subscription-back-link";
|
import { SubscriptionBackLink } from "./subscription-back-link";
|
||||||
import { SubscriptionBanner } from "./subscription-banner";
|
import { SubscriptionBanner } from "./subscription-banner";
|
||||||
import { SubscriptionBenefitsCard } from "./subscription-benefits-card";
|
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 { SubscriptionPlanCard } from "./subscription-plan-card";
|
||||||
import { SubscriptionUserRow } from "./subscription-user-row";
|
import { SubscriptionUserRow } from "./subscription-user-row";
|
||||||
import styles from "./subscription-screen.module.css";
|
import styles from "./subscription-screen.module.css";
|
||||||
@@ -42,25 +44,20 @@ const VIP_BENEFITS = [
|
|||||||
const FALLBACK_USERNAME = "User name";
|
const FALLBACK_USERNAME = "User name";
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen() {
|
||||||
const router = useRouter();
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
const initialPlanId =
|
const initialPlanId =
|
||||||
SUBSCRIPTION_PLANS.find((p) => p.highlight)?.id ?? null;
|
SUBSCRIPTION_PLANS.find((p) => p.highlight)?.id ?? null;
|
||||||
|
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
|
// 强制类型为 `SubscriptionPlanId` —— 套餐表只有 3 个 id,不会越界
|
||||||
initialPlanId,
|
const [selectedPlanId, setSelectedPlanId] = useState<SubscriptionPlanId | null>(
|
||||||
|
initialPlanId as SubscriptionPlanId | null,
|
||||||
);
|
);
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
|
||||||
const canActivate = selectedPlanId !== null && agreed;
|
const canActivate = selectedPlanId !== null && agreed;
|
||||||
|
const isVip = user.currentUser?.isVip ?? false;
|
||||||
const handleActivate = () => {
|
const stripeCustomerId = user.currentUser?.stripeCustomerId ?? null;
|
||||||
if (!canActivate) return;
|
|
||||||
// Mock phase: 仅提示并跳走,不调用任何支付接口
|
|
||||||
window.alert("Subscription activated (mock). Welcome to VIP!");
|
|
||||||
router.push(ROUTES.chat);
|
|
||||||
};
|
|
||||||
|
|
||||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||||
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
||||||
@@ -107,12 +104,18 @@ export function SubscriptionScreen() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className={styles.ctaSlot}>
|
<div className={styles.ctaSlot}>
|
||||||
<SubscriptionCtaButton
|
{/*
|
||||||
|
* 已为 VIP 的用户 → 显示 Manage VIP 按钮(跳转到 Stripe Customer Portal)
|
||||||
|
* VIP 状态从 `user.isVip` 取(webhook 会设置)
|
||||||
|
*/}
|
||||||
|
{isVip ? (
|
||||||
|
<SubscriptionManageButton customerId={stripeCustomerId} />
|
||||||
|
) : (
|
||||||
|
<SubscriptionCheckoutButton
|
||||||
|
planId={selectedPlanId ?? "monthly"}
|
||||||
disabled={!canActivate}
|
disabled={!canActivate}
|
||||||
onClick={handleActivate}
|
/>
|
||||||
>
|
)}
|
||||||
Confirm the agreement and Activate
|
|
||||||
</SubscriptionCtaButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.agreementSlot}>
|
<div className={styles.agreementSlot}>
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<MobileShell>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "2rem 1.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "polling" ? (
|
||||||
|
<>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 600 }}>
|
||||||
|
Processing your subscription…
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#666" }}>
|
||||||
|
Your payment was received. We are confirming the details with
|
||||||
|
our server.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : status === "ok" ? (
|
||||||
|
<>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#1f8b3a" }}>
|
||||||
|
🎉 Subscription activated!
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#666" }}>
|
||||||
|
Welcome to VIP. Enjoy unlimited chat, free voice minutes, and
|
||||||
|
more.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(ROUTES.chat)}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "linear-gradient(135deg, #f96ADE, #f657A0)",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start chatting
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.subscription}
|
||||||
|
style={{ color: "#888", fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
Back to subscription
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#c0392b" }}>
|
||||||
|
Subscription status unclear
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#666" }}>
|
||||||
|
We received your payment but could not confirm your VIP
|
||||||
|
status. {errorMessage ?? "Please refresh in a few seconds."}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.subscription}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "#f0f0f0",
|
||||||
|
color: "#333",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to subscription
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,13 @@
|
|||||||
* Mock 阶段硬编码,后续可改为从 `ApiPath.paymentProducts` 拉取。
|
* Mock 阶段硬编码,后续可改为从 `ApiPath.paymentProducts` 拉取。
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/subscription/...
|
* 原始 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";
|
export type SubscriptionPlanId = "monthly" | "quarterly" | "annual";
|
||||||
|
|
||||||
@@ -19,6 +26,8 @@ export interface SubscriptionPlan {
|
|||||||
perDay: string;
|
perDay: string;
|
||||||
/** 是否高亮(默认推荐:月付) */
|
/** 是否高亮(默认推荐:月付) */
|
||||||
highlight: boolean;
|
highlight: boolean;
|
||||||
|
/** 该套餐每天补充的语音聊天分钟数(业务常量) */
|
||||||
|
voiceMinutesPerDay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [
|
export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [
|
||||||
@@ -29,6 +38,7 @@ export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [
|
|||||||
originalPrice: 24.9,
|
originalPrice: 24.9,
|
||||||
perDay: "$0.66/day",
|
perDay: "$0.66/day",
|
||||||
highlight: true,
|
highlight: true,
|
||||||
|
voiceMinutesPerDay: 30, // 30 分钟/天
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "quarterly",
|
id: "quarterly",
|
||||||
@@ -37,6 +47,7 @@ export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [
|
|||||||
originalPrice: 59.9,
|
originalPrice: 59.9,
|
||||||
perDay: "$0.55/day",
|
perDay: "$0.55/day",
|
||||||
highlight: false,
|
highlight: false,
|
||||||
|
voiceMinutesPerDay: 45, // 45 分钟/天(比月付多,性价比)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "annual",
|
id: "annual",
|
||||||
@@ -45,5 +56,6 @@ export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [
|
|||||||
originalPrice: 199.9,
|
originalPrice: 199.9,
|
||||||
perDay: "$0.47/day",
|
perDay: "$0.47/day",
|
||||||
highlight: false,
|
highlight: false,
|
||||||
|
voiceMinutesPerDay: 60, // 60 分钟/天(年度最多)
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ export class User {
|
|||||||
declare readonly avatarUrl: string;
|
declare readonly avatarUrl: string;
|
||||||
declare readonly loginProvider: string;
|
declare readonly loginProvider: string;
|
||||||
declare readonly isGuest: boolean;
|
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) {
|
private constructor(input: UserInput) {
|
||||||
const data = UserSchema.parse(input);
|
const data = UserSchema.parse(input);
|
||||||
|
|||||||
@@ -17,12 +17,20 @@ export const UserSchema = z.object({
|
|||||||
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS),
|
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS),
|
||||||
preferredLanguage: z.string().default(""),
|
preferredLanguage: z.string().default(""),
|
||||||
createdAt: z.string().default(""),
|
createdAt: z.string().default(""),
|
||||||
// 后端**新**游客返回 null("还没发过消息"),`.default("")` **不**兜 null —— 需 nullable
|
// 后端新游客返回 null("还没发过消息"),`.default("")` 不兜 null —— 需 nullable
|
||||||
lastMessageAt: z.string().nullable().default(null),
|
lastMessageAt: z.string().nullable().default(null),
|
||||||
avatarUrl: z.string().default(""),
|
avatarUrl: z.string().default(""),
|
||||||
loginProvider: z.string().default("email"),
|
loginProvider: z.string().default("email"),
|
||||||
isGuest: z.boolean().default(false),
|
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<typeof UserSchema>;
|
export type UserInput = z.input<typeof UserSchema>;
|
||||||
export type UserData = z.output<typeof UserSchema>;
|
export type UserData = z.output<typeof UserSchema>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void>`,幂等(用 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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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: <plan's voiceMinutesPerDay> }
|
||||||
|
// 本轮先不接(后端可能还没好)——
|
||||||
|
// 业务后端收到 webhook 后会自己更新 user.isVip + voiceMinutesRemaining
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Handler 2: customer.subscription.deleted
|
||||||
|
// ============================================================
|
||||||
|
/**
|
||||||
|
* 订阅取消 → user.isVip=false
|
||||||
|
*
|
||||||
|
* 注意:voiceMinutesRemaining 保留(不清零)—— 不然已经发送的语音会消失
|
||||||
|
* (业务决定)
|
||||||
|
*/
|
||||||
|
async function handleSubscriptionDeleted(
|
||||||
|
event: Stripe.CustomerSubscriptionDeletedEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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: <plan's voiceMinutesPerDay> }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注:stripe-client 保留给未来扩展使用(例如主动调 `stripe.subscriptions.update()` 升级 plan)
|
||||||
|
// 本轮 handler 没直接调用 getStripeClient()(webhook 是事件驱动,不需要反向查 Stripe)
|
||||||
|
export const _internal_stripeClient = getStripeClient;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Stripe product / price id 映射
|
||||||
|
*
|
||||||
|
* 为什么不直接存到 `subscription-plans.ts`:
|
||||||
|
* - product/price id 是部署环境配置(不同环境不同 product)
|
||||||
|
* - 套餐本身的 `name` / `price` / `highlight` 是业务常量(不随环境变)
|
||||||
|
* - 分离后,套餐表只包含业务字段,产品 id 走 env —— 避免硬编码 + 生产安全
|
||||||
|
*
|
||||||
|
* env 命名规范:
|
||||||
|
* - STRIPE_PRODUCT_<PLAN_ID_UPPER> → Product ID
|
||||||
|
* - STRIPE_PRICE_<PLAN_ID_UPPER> → 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;
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ export const UserViewSchema = z.object({
|
|||||||
currentMood: z.string(),
|
currentMood: z.string(),
|
||||||
isGuest: z.boolean(),
|
isGuest: z.boolean(),
|
||||||
isVip: z.boolean(),
|
isVip: z.boolean(),
|
||||||
|
voiceMinutesRemaining: z.number(),
|
||||||
|
stripeCustomerId: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type UserView = z.infer<typeof UserViewSchema>;
|
export type UserView = z.infer<typeof UserViewSchema>;
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function toView(u: {
|
|||||||
currentMood?: string;
|
currentMood?: string;
|
||||||
isGuest?: boolean;
|
isGuest?: boolean;
|
||||||
isVip?: boolean;
|
isVip?: boolean;
|
||||||
|
voiceMinutesRemaining?: number;
|
||||||
|
stripeCustomerId?: string | null;
|
||||||
}): UserView {
|
}): UserView {
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@@ -62,6 +64,8 @@ function toView(u: {
|
|||||||
currentMood: u.currentMood ?? "",
|
currentMood: u.currentMood ?? "",
|
||||||
isGuest: u.isGuest ?? false,
|
isGuest: u.isGuest ?? false,
|
||||||
isVip: u.isVip ?? false,
|
isVip: u.isVip ?? false,
|
||||||
|
voiceMinutesRemaining: u.voiceMinutesRemaining ?? 0,
|
||||||
|
stripeCustomerId: u.stripeCustomerId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user