From c5c69645c501b775a9bb201bec35b5a7a7b750ee Mon Sep 17 00:00:00 2001 From: songtianlun Date: Tue, 5 Aug 2025 22:43:18 +0800 Subject: [PATCH] better subscribe --- docs/pro-plan-configuration.md | 200 +++++++ docs/stripe-setup.md | 25 +- docs/subscription-pro-detection.md | 107 ++++ package-lock.json | 520 ++++++++++++++++++ package.json | 8 +- prisma/schema.prisma | 42 +- prisma/seed.ts | 147 +++++ scripts/fix-pro-plan-name.ts | 80 +++ scripts/migrate-subscription-system.ts | 147 +++++ scripts/test-pro-plan-detection.ts | 113 ++++ scripts/test-subscription-system.ts | 138 +++++ src/app/api/admin/subscription-plans/route.ts | 215 ++++++++ src/app/api/prompts/[id]/route.ts | 15 +- src/app/api/prompts/[id]/versions/route.ts | 27 + src/app/api/prompts/route.ts | 10 + src/app/api/subscription-plans/route.ts | 16 + src/app/api/subscription/create/route.ts | 10 +- src/app/api/subscription/manage/route.ts | 65 +-- .../api/subscription/pro-price-id/route.ts | 24 + src/app/api/subscription/sync/route.ts | 58 +- src/app/api/users/profile/route.ts | 30 +- src/app/api/webhooks/stripe/route.ts | 39 +- src/app/pricing/page.tsx | 268 +++++---- src/app/subscription/page.tsx | 10 +- .../subscription/SubscribeButton.tsx | 54 +- .../subscription/SubscriptionStatus.tsx | 63 ++- src/hooks/useUser.ts | 3 +- src/lib/credits.ts | 6 +- src/lib/stripe.ts | 11 +- src/lib/subscription-service.ts | 280 ++++++++++ src/lib/subscription-utils.ts | 144 +++++ src/lib/subscription.ts | 63 +-- 32 files changed, 2587 insertions(+), 351 deletions(-) create mode 100644 docs/pro-plan-configuration.md create mode 100644 docs/subscription-pro-detection.md create mode 100644 prisma/seed.ts create mode 100644 scripts/fix-pro-plan-name.ts create mode 100644 scripts/migrate-subscription-system.ts create mode 100644 scripts/test-pro-plan-detection.ts create mode 100644 scripts/test-subscription-system.ts create mode 100644 src/app/api/admin/subscription-plans/route.ts create mode 100644 src/app/api/subscription-plans/route.ts create mode 100644 src/app/api/subscription/pro-price-id/route.ts create mode 100644 src/lib/subscription-service.ts create mode 100644 src/lib/subscription-utils.ts diff --git a/docs/pro-plan-configuration.md b/docs/pro-plan-configuration.md new file mode 100644 index 0000000..cf8347d --- /dev/null +++ b/docs/pro-plan-configuration.md @@ -0,0 +1,200 @@ +# Pro 套餐配置指南 + +## 概述 + +系统现在完全从数据库动态获取 Pro 套餐的配置,不再依赖环境变量 `NEXT_PUBLIC_STRIPE_PRO_PRICE_ID`。 + +## 核心原理 + +1. **动态查找**: 系统通过查找数据库中 `name = 'pro'` 的套餐来确定 Pro 套餐 +2. **价格判定**: 任何价格超过 $19 的套餐都被视为 Pro 级别 +3. **实时获取**: 订阅时动态获取 Pro 套餐的 Stripe 价格 ID + +## 配置步骤 + +### 1. 确保 Pro 套餐存在 + +运行种子脚本会自动创建 Pro 套餐: + +```bash +npm run db:seed +``` + +或者手动创建: + +```sql +INSERT INTO subscription_plans ( + id, name, "displayName", description, price, currency, interval, + "stripePriceId", "isActive", "sortOrder", features, limits, + "createdAt", "updatedAt" +) VALUES ( + 'pro', 'pro', 'Pro Plan', 'Advanced features for power users', + 19.9, 'usd', 'month', NULL, true, 2, + '{"promptLimit": true, "versionsPerPrompt": true, "prioritySupport": true}', + '{"maxVersionLimit": 10, "promptLimit": 5000, "creditMonthly": 20}', + NOW(), NOW() +); +``` + +### 2. 设置 Stripe 价格 ID + +在 Stripe Dashboard 中创建价格后,更新数据库: + +```sql +UPDATE subscription_plans +SET "stripePriceId" = 'price_your_actual_stripe_price_id' +WHERE name = 'pro'; +``` + +### 3. 验证配置 + +运行测试脚本验证配置: + +```bash +npx tsx scripts/test-pro-plan-detection.ts +``` + +## API 端点 + +### 获取 Pro 价格 ID + +```http +GET /api/subscription/pro-price-id +``` + +**成功响应**: +```json +{ + "priceId": "price_1RryBL9mNQExFr2OpYGPdBVJ" +} +``` + +**错误响应**: +```json +{ + "error": "Pro plan not found or not configured" +} +``` + +## 前端集成 + +### QuickUpgradeButton 组件 + +```typescript +import { QuickUpgradeButton } from '@/components/subscription/SubscribeButton' + +// 自动获取 Pro 套餐价格 ID + + Upgrade to Pro + +``` + +### 定价页面 + +定价页面会自动从数据库获取所有套餐,包括 Pro 套餐的价格 ID。 + +## 服务端方法 + +### SubscriptionService 方法 + +```typescript +import { SubscriptionService } from '@/lib/subscription-service' + +// 获取 Pro 套餐 +const proPlan = await SubscriptionService.getProPlan() + +// 获取 Pro 价格 ID +const proPriceId = await SubscriptionService.getProPriceId() + +// 判断套餐是否为 Pro +const isPro = SubscriptionService.isPlanPro(plan) + +// 判断用户是否为 Pro +const isUserPro = await SubscriptionService.isUserPro(userId) +``` + +## 故障排除 + +### 问题:订阅创建失败 + +**原因**: Pro 套餐未配置或 Stripe 价格 ID 未设置 + +**解决方案**: +1. 检查数据库中是否存在 `name = 'pro'` 的套餐 +2. 确认该套餐的 `stripePriceId` 字段不为空 +3. 验证 Stripe 价格 ID 是否有效 + +### 问题:API 返回 404 + +**原因**: 找不到名称为 "pro" 的套餐 + +**解决方案**: +```sql +-- 检查套餐名称 +SELECT id, name, "displayName" FROM subscription_plans WHERE "isActive" = true; + +-- 如果名称不正确,更新为 "pro" +UPDATE subscription_plans SET name = 'pro' WHERE id = 'pro'; +``` + +### 问题:前端显示 "Pro plan unavailable" + +**原因**: API 无法获取 Pro 价格 ID + +**解决方案**: +1. 检查 API 端点 `/api/subscription/pro-price-id` 是否正常 +2. 确认数据库连接正常 +3. 验证 Pro 套餐配置 + +## 测试工具 + +### 检测脚本 + +```bash +# 测试 Pro 套餐检测 +npx tsx scripts/test-pro-plan-detection.ts + +# 修复 Pro 套餐名称 +npx tsx scripts/fix-pro-plan-name.ts +``` + +### 手动测试 + +```bash +# 测试 API 端点 +curl http://localhost:3000/api/subscription/pro-price-id + +# 检查数据库 +npx prisma studio +``` + +## 迁移指南 + +### 从环境变量迁移 + +如果你之前使用 `NEXT_PUBLIC_STRIPE_PRO_PRICE_ID`: + +1. 将价格 ID 更新到数据库: + ```sql + UPDATE subscription_plans + SET "stripePriceId" = 'your_old_price_id' + WHERE name = 'pro'; + ``` + +2. 移除环境变量: + ```bash + # 从 .env 文件中删除这一行 + # NEXT_PUBLIC_STRIPE_PRO_PRICE_ID="price_..." + ``` + +3. 重新构建应用: + ```bash + npm run build + ``` + +## 优势 + +1. **灵活性**: 可以在数据库中动态修改 Pro 套餐配置 +2. **安全性**: 价格 ID 不再暴露在客户端环境变量中 +3. **可扩展性**: 支持多个 Pro 级别套餐 +4. **一致性**: 所有套餐配置都在数据库中统一管理 diff --git a/docs/stripe-setup.md b/docs/stripe-setup.md index 11cf85c..105fc95 100644 --- a/docs/stripe-setup.md +++ b/docs/stripe-setup.md @@ -43,13 +43,36 @@ # Stripe 配置 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_your_publishable_key_here" STRIPE_SECRET_KEY="sk_test_your_secret_key_here" -NEXT_PUBLIC_STRIPE_PRO_PRICE_ID="price_your_pro_price_id_here" NEXT_PUBLIC_APP_URL="http://localhost:3000" # Webhook 密钥(稍后配置) STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret_here" ``` +**注意**: 不再需要 `NEXT_PUBLIC_STRIPE_PRO_PRICE_ID` 环境变量,价格 ID 现在从数据库动态获取。 + +### 3.1 配置 Pro 套餐价格 ID + +创建 Stripe 价格后,需要在数据库中更新 Pro 套餐的 `stripePriceId` 字段: + +```sql +-- 在数据库中更新 Pro 套餐的 Stripe 价格 ID +UPDATE subscription_plans +SET "stripePriceId" = 'price_your_actual_pro_price_id_here' +WHERE name = 'pro'; +``` + +或者使用管理员 API 更新: + +```bash +curl -X PUT http://localhost:3000/api/admin/subscription-plans \ + -H "Content-Type: application/json" \ + -d '{ + "id": "pro", + "stripePriceId": "price_your_actual_pro_price_id_here" + }' +``` + ## 4. Webhook 配置 ### 4.1 创建 Webhook 端点 diff --git a/docs/subscription-pro-detection.md b/docs/subscription-pro-detection.md new file mode 100644 index 0000000..d7edb8a --- /dev/null +++ b/docs/subscription-pro-detection.md @@ -0,0 +1,107 @@ +# Pro 套餐判定逻辑 + +## 概述 + +系统使用统一的价格判定逻辑来确定套餐是否为 Pro 级别,而不是依赖硬编码的套餐 ID。这提供了更大的灵活性,允许动态创建不同价格的套餐。 + +## 判定规则 + +### Pro 套餐判定 +```typescript +function isPlanPro(plan): boolean { + return plan.price > 19 +} +``` + +**规则**: 任何价格超过 $19 的套餐都被视为 Pro 级别 + +### 套餐层级 +```typescript +function getPlanTier(plan): 'free' | 'pro' | 'enterprise' { + if (!plan || plan.price === 0) return 'free' + if (plan.price > 50) return 'enterprise' // 为未来预留 + if (plan.price > 19) return 'pro' + return 'free' +} +``` + +**层级划分**: +- **Free**: $0 +- **Basic**: $0.01 - $19.00 (仍被视为免费层级) +- **Pro**: $19.01 - $50.00 +- **Enterprise**: $50.01+ (为未来扩展预留) + +## 实际应用示例 + +| 套餐名称 | 价格 | isPro() | getPlanTier() | 说明 | +|---------|------|---------|---------------|------| +| Free | $0 | false | free | 免费套餐 | +| Basic | $9.99 | false | free | 低价套餐,仍为免费层级 | +| Pro | $19.99 | true | pro | 标准 Pro 套餐 | +| Premium | $29.99 | true | pro | 高级 Pro 套餐 | +| Enterprise | $99.99 | true | enterprise | 企业级套餐 | + +## 使用方式 + +### 在组件中使用 +```typescript +import { isPlanPro, getPlanTier, getPlanTheme } from '@/lib/subscription-utils' + +// 判断是否为 Pro +const isPro = isPlanPro(plan) + +// 获取套餐层级 +const tier = getPlanTier(plan) + +// 获取对应的主题样式 +const theme = getPlanTheme(plan) +``` + +### 在服务端使用 +```typescript +import { SubscriptionService } from '@/lib/subscription-service' + +// 判断用户是否为 Pro +const isUserPro = await SubscriptionService.isUserPro(userId) + +// 判断套餐是否为 Pro +const isPro = SubscriptionService.isPlanPro(plan) +``` + +## 优势 + +1. **灵活性**: 可以创建任意价格的套餐,系统自动判定层级 +2. **一致性**: 所有 Pro 判定都使用统一的逻辑 +3. **可扩展性**: 易于添加新的套餐层级(如 Enterprise) +4. **维护性**: 修改判定规则只需要更新一个地方 + +## 样式主题 + +系统会根据套餐层级自动应用对应的主题样式: + +- **Free**: 灰色主题,Star 图标 +- **Pro**: 橙色/琥珀色主题,Crown 图标 +- **Enterprise**: 紫色主题,Building 图标 + +## 注意事项 + +1. 价格判定基于美元金额 +2. 判定逻辑支持部分套餐数据(只需要 `price` 字段) +3. 所有显示逻辑都应该使用这些工具函数,而不是硬编码套餐 ID +4. 修改价格阈值时需要考虑现有套餐的影响 + +## 迁移指南 + +如果需要修改 Pro 套餐的价格阈值: + +1. 更新 `src/lib/subscription-utils.ts` 中的判定逻辑 +2. 运行测试确保所有现有套餐仍然正确分类 +3. 更新文档中的示例 + +```typescript +// 例如,将 Pro 阈值改为 $25 +export function isPlanPro(plan: PlanLike): boolean { + if (!plan) return false + return plan.price > 25 // 从 19 改为 25 +} +``` diff --git a/package-lock.json b/package-lock.json index 927e28f..631b876 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "eslint": "^9", "eslint-config-next": "15.4.4", "tailwindcss": "^4", + "tsx": "^4.20.3", "typescript": "^5" }, "engines": { @@ -118,6 +119,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -3032,6 +3475,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3614,6 +4099,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6294,6 +6794,26 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 0cc8dfd..d8705d4 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,16 @@ "start": "next start", "lint": "next lint", "db:generate": "prisma generate", - "db:push": "prisma db push", + "db:push": "prisma db push && npm run db:seed", "db:migrate": "prisma migrate dev", "db:studio": "prisma studio", "db:reset": "prisma migrate reset", - "db:seed": "prisma db seed", + "db:seed": "tsx prisma/seed.ts", "postinstall": "prisma generate" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "dependencies": { "@prisma/client": "^6.12.0", "@stripe/stripe-js": "^7.8.0", @@ -46,6 +49,7 @@ "eslint": "^9", "eslint-config-next": "15.4.4", "tailwindcss": "^4", + "tsx": "^4.20.3", "typescript": "^5" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0eadae6..9831f87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,21 +21,55 @@ model User { bio String? language String @default("en") isAdmin Boolean @default(false) // 管理员标记 - versionLimit Int @default(3) // 版本数量限制,可在用户配置中设置 + versionLimit Int @default(3) // 用户自定义版本数量限制,不能超过套餐限制 + + // 新的订阅系统字段 + subscriptionPlanId String @default("free") // 关联的订阅套餐ID + + // 旧的订阅系统字段(保留用于迁移,后期会移除) subscribePlan String @default("free") // 订阅计划: "free", "pro" maxVersionLimit Int @default(3) // 基于订阅的最大版本限制 - promptLimit Int @default(500) // 提示词数量限制,更新为500 + promptLimit Int? // 提示词数量限制(已弃用,忽略此字段) creditBalance Float @default(0.0) // 信用余额,移除默认值 + stripeCustomerId String? @unique // Stripe 客户 ID createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - prompts Prompt[] - credits Credit[] + // 关联关系 + subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id]) + prompts Prompt[] + credits Credit[] @@map("users") } +// 订阅套餐模型 +model SubscriptionPlan { + id String @id // "free", "pro" 等 + name String // 套餐名称 + displayName String // 显示名称(支持国际化) + description String? // 套餐描述 + price Float @default(0) // 价格(美元) + currency String @default("usd") // 货币 + interval String @default("month") // 计费周期: "month", "year" + stripePriceId String? @unique // Stripe 价格 ID + isActive Boolean @default(true) // 是否激活 + sortOrder Int @default(0) // 排序顺序 + + // 权益配置 (JSON 格式存储) + features Json // 功能特性配置 + limits Json // 限制配置 + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // 关联关系 + users User[] + + @@map("subscription_plans") +} + model Prompt { id String @id @default(cuid()) name String diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..8525d3a --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,147 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function main() { + console.log('🌱 Starting database seeding...') + + // 检查是否已存在订阅套餐 + const existingPlans = await prisma.subscriptionPlan.count() + + if (existingPlans > 0) { + console.log('📦 Subscription plans already exist, skipping seed...') + return + } + + console.log('📦 Creating default subscription plans...') + + // 创建免费套餐 + const freePlan = await prisma.subscriptionPlan.create({ + data: { + id: 'free', + name: 'Free', + displayName: 'Free Plan', + description: 'Basic features for getting started with AI prompt management', + price: 0, + currency: 'usd', + interval: 'month', + stripePriceId: null, + isActive: true, + sortOrder: 1, + features: { + promptLimit: true, + versionsPerPrompt: true, + basicSupport: true, + publicSharing: true + }, + limits: { + maxVersionLimit: 3, + promptLimit: 500, + creditMonthly: 0 + } + } + }) + + // 创建 Pro 套餐 + const proPlan = await prisma.subscriptionPlan.create({ + data: { + id: 'pro', + name: 'pro', // 重要:名称必须是 "pro" 用于查找 + displayName: 'Pro Plan', + description: 'Advanced features for power users and professionals', + price: 19.9, + currency: 'usd', + interval: 'month', + stripePriceId: null, // 需要手动在数据库中设置 Stripe 价格 ID + isActive: true, + sortOrder: 2, + features: { + promptLimit: true, + versionsPerPrompt: true, + prioritySupport: true, + advancedAnalytics: true, + apiAccess: true, + teamCollaboration: true, + exportImport: true, + customBranding: true + }, + limits: { + maxVersionLimit: 10, + promptLimit: 5000, + creditMonthly: 20 + } + } + }) + + console.log(`✅ Created subscription plan: ${freePlan.displayName}`) + console.log(`✅ Created subscription plan: ${proPlan.displayName}`) + + // 更新现有用户的订阅套餐关联 + console.log('👥 Updating existing users...') + + // 获取所有用户,不管是否已有订阅套餐关联 + const users = await prisma.user.findMany({ + select: { + id: true, + subscribePlan: true, + subscriptionPlanId: true + } + }) + + // 过滤出需要更新的用户(没有正确的订阅套餐关联的) + const usersToUpdate = users.filter(user => + !user.subscriptionPlanId || + user.subscriptionPlanId === '' || + (user.subscribePlan === 'pro' && user.subscriptionPlanId !== 'pro') || + (user.subscribePlan === 'free' && user.subscriptionPlanId !== 'free') + ) + + if (usersToUpdate.length > 0) { + console.log(`📝 Found ${usersToUpdate.length} users to update`) + + for (const user of usersToUpdate) { + let planId = 'free' + + // 根据现有的 subscribePlan 字段确定新的套餐 ID + if (user.subscribePlan === 'pro') { + planId = 'pro' + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionPlanId: planId + } + }) + } + + console.log(`✅ Updated ${usersToUpdate.length} users with subscription plan associations`) + } else { + console.log('👥 No users need subscription plan updates') + } + + // 验证种子数据 + const planCount = await prisma.subscriptionPlan.count() + const usersWithPlans = await prisma.user.count({ + where: { + subscriptionPlanId: { + not: '' + } + } + }) + + console.log(`\n📊 Seeding completed:`) + console.log(` • ${planCount} subscription plans created`) + console.log(` • ${usersWithPlans} users have valid plan associations`) + console.log(`\n🎉 Database seeding finished successfully!`) +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error('❌ Seeding failed:', e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/scripts/fix-pro-plan-name.ts b/scripts/fix-pro-plan-name.ts new file mode 100644 index 0000000..efd3970 --- /dev/null +++ b/scripts/fix-pro-plan-name.ts @@ -0,0 +1,80 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function fixProPlanName() { + console.log('🔧 Fixing Pro plan name in database...') + + try { + // 查找当前的 Pro 套餐 + const currentProPlan = await prisma.subscriptionPlan.findFirst({ + where: { + OR: [ + { name: 'Pro' }, + { name: 'pro' }, + { id: 'pro' } + ] + } + }) + + if (!currentProPlan) { + console.log('❌ No Pro plan found in database') + return + } + + console.log(`📋 Found Pro plan:`) + console.log(` - ID: ${currentProPlan.id}`) + console.log(` - Current name: "${currentProPlan.name}"`) + console.log(` - Display name: ${currentProPlan.displayName}`) + + // 更新名称为小写 "pro" + if (currentProPlan.name !== 'pro') { + console.log('\n🔄 Updating name to "pro"...') + + const updatedPlan = await prisma.subscriptionPlan.update({ + where: { id: currentProPlan.id }, + data: { name: 'pro' } + }) + + console.log(`✅ Successfully updated plan name to: "${updatedPlan.name}"`) + } else { + console.log('✅ Plan name is already correct') + } + + // 验证更新 + console.log('\n🔍 Verifying update...') + const verifyPlan = await prisma.subscriptionPlan.findFirst({ + where: { name: 'pro' } + }) + + if (verifyPlan) { + console.log(`✅ Verification successful: Found plan with name "pro"`) + console.log(` - ID: ${verifyPlan.id}`) + console.log(` - Display name: ${verifyPlan.displayName}`) + console.log(` - Price: $${verifyPlan.price}`) + } else { + console.log('❌ Verification failed: No plan found with name "pro"') + } + + } catch (error) { + console.error('❌ Error fixing Pro plan name:', error) + throw error + } finally { + await prisma.$disconnect() + } +} + +// 运行修复 +if (require.main === module) { + fixProPlanName() + .then(() => { + console.log('\n🎉 Pro plan name fix completed!') + process.exit(0) + }) + .catch((error) => { + console.error('❌ Fix failed:', error) + process.exit(1) + }) +} + +export { fixProPlanName } diff --git a/scripts/migrate-subscription-system.ts b/scripts/migrate-subscription-system.ts new file mode 100644 index 0000000..7709d1f --- /dev/null +++ b/scripts/migrate-subscription-system.ts @@ -0,0 +1,147 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function migrateSubscriptionSystem() { + console.log('Starting subscription system migration...') + + try { + // 1. 创建默认的订阅套餐 + console.log('Creating default subscription plans...') + + // 免费套餐 + await prisma.subscriptionPlan.upsert({ + where: { id: 'free' }, + update: {}, + create: { + id: 'free', + name: 'Free', + displayName: 'Free Plan', + description: 'Basic features for getting started', + price: 0, + currency: 'usd', + interval: 'month', + stripePriceId: null, + isActive: true, + sortOrder: 1, + features: { + promptLimit: true, + versionsPerPrompt: true, + basicSupport: true + }, + limits: { + maxVersionLimit: 3, + promptLimit: 500, + creditMonthly: 0 + } + } + }) + + // Pro 套餐 + await prisma.subscriptionPlan.upsert({ + where: { id: 'pro' }, + update: {}, + create: { + id: 'pro', + name: 'pro', // 重要:名称必须是 "pro" 用于查找 + displayName: 'Pro Plan', + description: 'Advanced features for power users', + price: 19.9, + currency: 'usd', + interval: 'month', + stripePriceId: null, // 需要手动在数据库中设置 Stripe 价格 ID + isActive: true, + sortOrder: 2, + features: { + promptLimit: true, + versionsPerPrompt: true, + prioritySupport: true, + advancedAnalytics: true, + apiAccess: true + }, + limits: { + maxVersionLimit: 10, + promptLimit: 5000, + creditMonthly: 20 + } + } + }) + + console.log('Default subscription plans created successfully') + + // 2. 更新现有用户的订阅套餐关联 + console.log('Updating existing users...') + + // 获取所有用户 + const users = await prisma.user.findMany({ + select: { + id: true, + subscribePlan: true + } + }) + + console.log(`Found ${users.length} users to update`) + + // 批量更新用户的订阅套餐关联 + for (const user of users) { + let planId = 'free' + + // 根据现有的 subscribePlan 字段确定新的套餐 ID + if (user.subscribePlan === 'pro') { + planId = 'pro' + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionPlanId: planId + } + }) + } + + console.log('User subscription plan associations updated successfully') + + // 3. 验证迁移结果 + console.log('Verifying migration results...') + + const planCount = await prisma.subscriptionPlan.count() + const userCount = await prisma.user.count() + const usersWithValidPlans = await prisma.user.count({ + where: { + subscriptionPlanId: { + not: '' + } + } + }) + + console.log(`Migration completed successfully:`) + console.log(`- Created ${planCount} subscription plans`) + console.log(`- Updated ${userCount} users`) + console.log(`- ${usersWithValidPlans} users have valid plan associations`) + + if (userCount !== usersWithValidPlans) { + console.warn(`Warning: ${userCount - usersWithValidPlans} users don't have valid plan associations`) + } + + } catch (error) { + console.error('Migration failed:', error) + throw error + } finally { + await prisma.$disconnect() + } +} + +// 运行迁移 +if (require.main === module) { + migrateSubscriptionSystem() + .then(() => { + console.log('Migration completed successfully') + process.exit(0) + }) + .catch((error) => { + console.error('Migration failed:', error) + process.exit(1) + }) +} + +export { migrateSubscriptionSystem } diff --git a/scripts/test-pro-plan-detection.ts b/scripts/test-pro-plan-detection.ts new file mode 100644 index 0000000..bfb6dd2 --- /dev/null +++ b/scripts/test-pro-plan-detection.ts @@ -0,0 +1,113 @@ +import { PrismaClient } from '@prisma/client' +import { SubscriptionService } from '../src/lib/subscription-service' + +const prisma = new PrismaClient() + +async function testProPlanDetection() { + console.log('🧪 Testing Pro plan detection and price ID retrieval...') + + try { + // 1. 测试获取 Pro 套餐 + console.log('\n1. Testing Pro plan retrieval...') + const proPlan = await SubscriptionService.getProPlan() + + if (proPlan) { + console.log(`✅ Pro plan found:`) + console.log(` - ID: ${proPlan.id}`) + console.log(` - Name: ${proPlan.name}`) + console.log(` - Display Name: ${proPlan.displayName}`) + console.log(` - Price: $${proPlan.price}`) + console.log(` - Stripe Price ID: ${proPlan.stripePriceId || 'Not set'}`) + console.log(` - Active: ${proPlan.isActive}`) + } else { + console.log('❌ Pro plan not found') + } + + // 2. 测试获取 Pro 价格 ID + console.log('\n2. Testing Pro price ID retrieval...') + const proPriceId = await SubscriptionService.getProPriceId() + + if (proPriceId) { + console.log(`✅ Pro price ID found: ${proPriceId}`) + } else { + console.log('⚠️ Pro price ID not set (this is expected for new installations)') + } + + // 3. 测试 Pro 判定逻辑 + console.log('\n3. Testing Pro plan detection logic...') + if (proPlan) { + const isPro = SubscriptionService.isPlanPro(proPlan) + console.log(`✅ Pro plan detection: ${isPro} (expected: true for price > $19)`) + } + + // 4. 验证数据库中的套餐配置 + console.log('\n4. Validating database plan configuration...') + + const allPlans = await SubscriptionService.getAvailablePlans() + console.log(`📊 Found ${allPlans.length} active plans:`) + + allPlans.forEach(plan => { + const isPro = SubscriptionService.isPlanPro(plan) + console.log(` - ${plan.displayName} (${plan.name}): $${plan.price} - ${isPro ? 'Pro' : 'Free'} tier`) + }) + + // 5. 检查是否有名称为 "pro" 的套餐 + console.log('\n5. Checking for plans with name "pro"...') + const proNamedPlans = allPlans.filter(plan => plan.name === 'pro') + + if (proNamedPlans.length === 0) { + console.log('❌ No plans found with name "pro"') + console.log(' This will cause subscription creation to fail!') + } else if (proNamedPlans.length === 1) { + console.log(`✅ Found exactly one plan with name "pro": ${proNamedPlans[0].displayName}`) + } else { + console.log(`⚠️ Found ${proNamedPlans.length} plans with name "pro" - this may cause conflicts`) + proNamedPlans.forEach((plan, index) => { + console.log(` ${index + 1}. ${plan.displayName} (ID: ${plan.id})`) + }) + } + + // 6. 模拟 API 调用测试 + console.log('\n6. Testing API endpoint simulation...') + try { + const testPriceId = await SubscriptionService.getProPriceId() + if (testPriceId) { + console.log(`✅ API would return price ID: ${testPriceId}`) + } else { + console.log('⚠️ API would return 404 - Pro plan not configured') + } + } catch (error) { + console.log(`❌ API would return 500 - Error: ${error}`) + } + + console.log('\n🎉 Pro plan detection test completed!') + + // 7. 提供配置建议 + if (!proPriceId && proPlan) { + console.log('\n💡 Configuration needed:') + console.log(' To enable Pro subscriptions, set the stripePriceId for the Pro plan:') + console.log(` UPDATE subscription_plans SET "stripePriceId" = 'price_your_stripe_price_id' WHERE name = 'pro';`) + } + + } catch (error) { + console.error('❌ Test failed:', error) + throw error + } finally { + await prisma.$disconnect() + } +} + +// 运行测试 +if (require.main === module) { + testProPlanDetection() + .then(() => { + console.log('✅ All tests completed!') + process.exit(0) + }) + .catch((error) => { + console.error('❌ Tests failed:', error) + process.exit(1) + }) +} + +export { testProPlanDetection } diff --git a/scripts/test-subscription-system.ts b/scripts/test-subscription-system.ts new file mode 100644 index 0000000..51bb939 --- /dev/null +++ b/scripts/test-subscription-system.ts @@ -0,0 +1,138 @@ +import { PrismaClient } from '@prisma/client' +import { SubscriptionService } from '../src/lib/subscription-service' +import { isPlanPro, isPlanFree, getPlanTier } from '../src/lib/subscription-utils' + +const prisma = new PrismaClient() + +async function testSubscriptionSystem() { + console.log('🧪 Testing subscription system...') + + try { + // 1. 测试获取套餐 + console.log('\n1. Testing plan retrieval...') + const plans = await SubscriptionService.getAvailablePlans() + console.log(`✅ Found ${plans.length} plans:`) + plans.forEach(plan => { + const limits = plan.limits as any + const isPro = isPlanPro(plan) + const tier = getPlanTier(plan) + console.log(` - ${plan.displayName}: ${limits.promptLimit} prompts, ${limits.maxVersionLimit} versions (${tier}, isPro: ${isPro})`) + }) + + // 2. 测试获取特定套餐 + console.log('\n2. Testing specific plan retrieval...') + const freePlan = await SubscriptionService.getPlanById('free') + const proPlan = await SubscriptionService.getPlanById('pro') + + if (freePlan) { + console.log(`✅ Free plan found: ${freePlan.displayName}`) + } else { + console.log('❌ Free plan not found') + } + + if (proPlan) { + console.log(`✅ Pro plan found: ${proPlan.displayName}`) + } else { + console.log('❌ Pro plan not found') + } + + // 3. 测试用户权限(需要一个测试用户) + console.log('\n3. Testing user permissions...') + + // 查找第一个用户进行测试 + const testUser = await prisma.user.findFirst({ + select: { id: true, email: true, subscriptionPlanId: true } + }) + + if (testUser) { + console.log(`📝 Testing with user: ${testUser.email} (plan: ${testUser.subscriptionPlanId})`) + + try { + const permissions = await SubscriptionService.getUserPermissions(testUser.id) + console.log(`✅ User permissions:`) + console.log(` - Prompt limit: ${permissions.promptLimit}`) + console.log(` - Max version limit: ${permissions.maxVersionLimit}`) + console.log(` - Can create prompt: ${permissions.canCreatePrompt}`) + console.log(` - Features: ${permissions.features.join(', ')}`) + + // 测试订阅状态 + const subscriptionStatus = await SubscriptionService.getUserSubscriptionStatus(testUser.id) + console.log(`✅ Subscription status:`) + console.log(` - Active: ${subscriptionStatus.isActive}`) + console.log(` - Plan ID: ${subscriptionStatus.planId}`) + if (subscriptionStatus.stripeSubscriptionId) { + console.log(` - Stripe subscription: ${subscriptionStatus.stripeSubscriptionId}`) + } + + } catch (error) { + console.log(`❌ Error getting user permissions: ${error}`) + } + } else { + console.log('⚠️ No users found for testing') + } + + // 4. 验证数据完整性 + console.log('\n4. Validating data integrity...') + + const userCount = await prisma.user.count() + const usersWithPlans = await prisma.user.count({ + where: { + subscriptionPlanId: { + not: '' + } + } + }) + + console.log(`📊 Data integrity:`) + console.log(` - Total users: ${userCount}`) + console.log(` - Users with valid plans: ${usersWithPlans}`) + + if (userCount === usersWithPlans) { + console.log(`✅ All users have valid subscription plans`) + } else { + console.log(`⚠️ ${userCount - usersWithPlans} users don't have valid subscription plans`) + } + + // 5. 测试 Pro 判定逻辑 + console.log('\n5. Testing Pro plan detection logic...') + + // 测试不同价格的套餐判定 + const testPlans = [ + { price: 0, name: 'Free' }, + { price: 9.99, name: 'Basic' }, + { price: 19.99, name: 'Pro' }, + { price: 29.99, name: 'Premium' }, + { price: 99.99, name: 'Enterprise' } + ] + + testPlans.forEach(testPlan => { + const isPro = isPlanPro(testPlan) + const isFree = isPlanFree(testPlan) + const tier = getPlanTier(testPlan) + console.log(` - ${testPlan.name} ($${testPlan.price}): isPro=${isPro}, isFree=${isFree}, tier=${tier}`) + }) + + console.log('\n🎉 Subscription system test completed successfully!') + + } catch (error) { + console.error('❌ Test failed:', error) + throw error + } finally { + await prisma.$disconnect() + } +} + +// 运行测试 +if (require.main === module) { + testSubscriptionSystem() + .then(() => { + console.log('✅ All tests passed!') + process.exit(0) + }) + .catch((error) => { + console.error('❌ Tests failed:', error) + process.exit(1) + }) +} + +export { testSubscriptionSystem } diff --git a/src/app/api/admin/subscription-plans/route.ts b/src/app/api/admin/subscription-plans/route.ts new file mode 100644 index 0000000..803fd3e --- /dev/null +++ b/src/app/api/admin/subscription-plans/route.ts @@ -0,0 +1,215 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' +import { prisma } from '@/lib/prisma' +import { SubscriptionService } from '@/lib/subscription-service' + +// 验证管理员权限 +async function verifyAdmin() { + const cookieStore = await cookies() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value + }, + }, + } + ) + + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError || !user) { + return null + } + + const userData = await prisma.user.findUnique({ + where: { id: user.id }, + select: { isAdmin: true } + }) + + return userData?.isAdmin ? user : null +} + +// GET - 获取所有订阅套餐 +export async function GET() { + try { + const user = await verifyAdmin() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const plans = await SubscriptionService.getAvailablePlans() + return NextResponse.json({ plans }) + } catch (error) { + console.error('Error fetching subscription plans:', error) + return NextResponse.json( + { error: 'Failed to fetch subscription plans' }, + { status: 500 } + ) + } +} + +// POST - 创建新的订阅套餐 +export async function POST(request: NextRequest) { + try { + const user = await verifyAdmin() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { + id, + name, + displayName, + description, + price, + currency = 'usd', + interval = 'month', + stripePriceId, + isActive = true, + sortOrder = 0, + features, + limits + } = await request.json() + + // 验证必填字段 + if (!id || !name || !displayName || !features || !limits) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ) + } + + // 创建套餐 + const plan = await prisma.subscriptionPlan.create({ + data: { + id, + name, + displayName, + description, + price, + currency, + interval, + stripePriceId, + isActive, + sortOrder, + features, + limits + } + }) + + return NextResponse.json({ plan }) + } catch (error) { + console.error('Error creating subscription plan:', error) + return NextResponse.json( + { error: 'Failed to create subscription plan' }, + { status: 500 } + ) + } +} + +// PUT - 更新订阅套餐 +export async function PUT(request: NextRequest) { + try { + const user = await verifyAdmin() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { + id, + name, + displayName, + description, + price, + currency, + interval, + stripePriceId, + isActive, + sortOrder, + features, + limits + } = await request.json() + + if (!id) { + return NextResponse.json( + { error: 'Plan ID is required' }, + { status: 400 } + ) + } + + // 更新套餐 + const plan = await prisma.subscriptionPlan.update({ + where: { id }, + data: { + ...(name && { name }), + ...(displayName && { displayName }), + ...(description !== undefined && { description }), + ...(price !== undefined && { price }), + ...(currency && { currency }), + ...(interval && { interval }), + ...(stripePriceId !== undefined && { stripePriceId }), + ...(isActive !== undefined && { isActive }), + ...(sortOrder !== undefined && { sortOrder }), + ...(features && { features }), + ...(limits && { limits }) + } + }) + + return NextResponse.json({ plan }) + } catch (error) { + console.error('Error updating subscription plan:', error) + return NextResponse.json( + { error: 'Failed to update subscription plan' }, + { status: 500 } + ) + } +} + +// DELETE - 删除订阅套餐 +export async function DELETE(request: NextRequest) { + try { + const user = await verifyAdmin() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const planId = searchParams.get('id') + + if (!planId) { + return NextResponse.json( + { error: 'Plan ID is required' }, + { status: 400 } + ) + } + + // 检查是否有用户正在使用此套餐 + const usersCount = await prisma.user.count({ + where: { subscriptionPlanId: planId } + }) + + if (usersCount > 0) { + return NextResponse.json( + { error: 'Cannot delete plan with active users' }, + { status: 400 } + ) + } + + // 删除套餐 + await prisma.subscriptionPlan.delete({ + where: { id: planId } + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting subscription plan:', error) + return NextResponse.json( + { error: 'Failed to delete subscription plan' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/prompts/[id]/route.ts b/src/app/api/prompts/[id]/route.ts index a97473d..963efc1 100644 --- a/src/app/api/prompts/[id]/route.ts +++ b/src/app/api/prompts/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { getVersionsToDelete } from '@/lib/subscription' +import { SubscriptionService } from '@/lib/subscription-service' interface RouteParams { params: Promise<{ id: string }> @@ -153,25 +153,22 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { // 如果内容有变化,创建新版本并处理版本限制 if (shouldCreateVersion && content) { - const user = existingPrompt.user - const currentVersionCount = existingPrompt.versions.length - - // 检查是否需要删除旧版本以遵守版本限制 - const versionsToDelete = getVersionsToDelete(currentVersionCount, user) - + // 使用新的订阅服务检查版本限制 + const versionsToDelete = await SubscriptionService.getVersionsToDelete(userId, id) + if (versionsToDelete > 0) { // 删除最旧的版本 const oldestVersions = existingPrompt.versions .slice(-versionsToDelete) .map(v => v.id) - + await prisma.promptVersion.deleteMany({ where: { id: { in: oldestVersions } } }) } - + const nextVersion = (currentVersion?.version || 0) + 1 await prisma.promptVersion.create({ data: { diff --git a/src/app/api/prompts/[id]/versions/route.ts b/src/app/api/prompts/[id]/versions/route.ts index 40b22fb..1fd89c5 100644 --- a/src/app/api/prompts/[id]/versions/route.ts +++ b/src/app/api/prompts/[id]/versions/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { SubscriptionService } from '@/lib/subscription-service' interface RouteParams { params: Promise<{ id: string }> @@ -75,9 +76,35 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'Prompt not found' }, { status: 404 }) } + // 检查用户是否可以创建新版本 + const canCreateVersion = await SubscriptionService.canCreateVersion(userId, id) + if (!canCreateVersion) { + return NextResponse.json( + { error: 'Version limit reached. Please upgrade your plan or delete old versions.' }, + { status: 403 } + ) + } + // 获取下一个版本号 const nextVersion = (prompt.versions[0]?.version || 0) + 1 + // 检查是否需要删除旧版本 + const versionsToDelete = await SubscriptionService.getVersionsToDelete(userId, id) + if (versionsToDelete > 0) { + // 获取最旧的版本并删除 + const oldVersions = await prisma.promptVersion.findMany({ + where: { promptId: id }, + orderBy: { version: 'asc' }, + take: versionsToDelete + }) + + await prisma.promptVersion.deleteMany({ + where: { + id: { in: oldVersions.map(v => v.id) } + } + }) + } + // 创建新版本 const newVersion = await prisma.promptVersion.create({ data: { diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index e9f2531..88708ff 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { SubscriptionService } from '@/lib/subscription-service' // GET /api/prompts - 获取用户的 prompts 列表 export async function GET(request: NextRequest) { @@ -122,6 +123,15 @@ export async function POST(request: NextRequest) { ) } + // 检查用户是否可以创建新的提示词 + const canCreate = await SubscriptionService.canCreatePrompt(userId) + if (!canCreate) { + return NextResponse.json( + { error: 'Prompt limit reached. Please upgrade your plan to create more prompts.' }, + { status: 403 } + ) + } + // 创建或获取标签 const tagObjects = [] if (tags && tags.length > 0) { diff --git a/src/app/api/subscription-plans/route.ts b/src/app/api/subscription-plans/route.ts new file mode 100644 index 0000000..d23a2c6 --- /dev/null +++ b/src/app/api/subscription-plans/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' +import { SubscriptionService } from '@/lib/subscription-service' + +// GET - 获取所有可用的订阅套餐(公开接口) +export async function GET() { + try { + const plans = await SubscriptionService.getAvailablePlans() + return NextResponse.json({ plans }) + } catch (error) { + console.error('Error fetching subscription plans:', error) + return NextResponse.json( + { error: 'Failed to fetch subscription plans' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/subscription/create/route.ts b/src/app/api/subscription/create/route.ts index 55e157e..9650849 100644 --- a/src/app/api/subscription/create/route.ts +++ b/src/app/api/subscription/create/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -import { createOrGetStripeCustomer, createSubscriptionSession, STRIPE_CONFIG } from '@/lib/stripe' +import { createOrGetStripeCustomer, createSubscriptionSession } from '@/lib/stripe' import { prisma } from '@/lib/prisma' export async function POST(request: NextRequest) { @@ -38,8 +38,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - // 验证价格 ID - if (priceId !== STRIPE_CONFIG.products.pro.priceId) { + // 验证价格 ID - 检查是否是有效的套餐价格 + const plan = await prisma.subscriptionPlan.findFirst({ + where: { stripePriceId: priceId, isActive: true } + }) + + if (!plan) { return NextResponse.json({ error: 'Invalid price ID' }, { status: 400 }) } diff --git a/src/app/api/subscription/manage/route.ts b/src/app/api/subscription/manage/route.ts index 5b8626e..96c5554 100644 --- a/src/app/api/subscription/manage/route.ts +++ b/src/app/api/subscription/manage/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' import { - getCustomerSubscriptions, cancelSubscription, reactivateSubscription, createCustomerPortalSession } from '@/lib/stripe' +import { SubscriptionService } from '@/lib/subscription-service' import { prisma } from '@/lib/prisma' // GET - 获取用户订阅信息 @@ -32,39 +32,21 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // 获取用户的 Stripe 客户 ID - const userData = await prisma.user.findUnique({ - where: { id: user.id }, - select: { stripeCustomerId: true, subscribePlan: true } - }) + // 获取用户的订阅状态和权益 + const subscriptionStatus = await SubscriptionService.getUserSubscriptionStatus(user.id) + const permissions = await SubscriptionService.getUserPermissions(user.id) - if (!userData?.stripeCustomerId) { - return NextResponse.json({ - plan: 'free', - status: 'active', - subscriptions: [] - }) - } - - // 获取 Stripe 订阅信息 - const subscriptions = await getCustomerSubscriptions(userData.stripeCustomerId) + // 获取套餐信息 + const plan = await SubscriptionService.getPlanById(subscriptionStatus.planId) return NextResponse.json({ - plan: userData.subscribePlan, - subscriptions: subscriptions.map(sub => { - const subData = sub as unknown as Record - const items = subData.items as { data: Array<{ price: { id: string; unit_amount: number; currency: string } }> } - return { - id: sub.id, - status: sub.status, - currentPeriodStart: subData.current_period_start as number, - currentPeriodEnd: subData.current_period_end as number, - cancelAtPeriodEnd: subData.cancel_at_period_end as boolean, - priceId: items?.data[0]?.price?.id, - amount: items?.data[0]?.price?.unit_amount, - currency: items?.data[0]?.price?.currency, - } - }) + subscription: subscriptionStatus, + permissions: permissions, + plan: plan ? { + id: plan.id, + displayName: plan.displayName, + price: plan.price + } : null }) } catch (error) { @@ -101,14 +83,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // 获取用户的 Stripe 客户 ID - const userData = await prisma.user.findUnique({ - where: { id: user.id }, - select: { stripeCustomerId: true } - }) + // 获取用户的订阅状态 + const subscriptionStatus = await SubscriptionService.getUserSubscriptionStatus(user.id) - if (!userData?.stripeCustomerId) { - return NextResponse.json({ error: 'No customer found' }, { status: 404 }) + if (!subscriptionStatus.stripeSubscriptionId) { + return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) } let result @@ -122,6 +101,16 @@ export async function POST(request: NextRequest) { break case 'portal': try { + // 获取用户的 Stripe 客户 ID + const userData = await prisma.user.findUnique({ + where: { id: user.id }, + select: { stripeCustomerId: true } + }) + + if (!userData?.stripeCustomerId) { + return NextResponse.json({ error: 'No customer found' }, { status: 404 }) + } + result = await createCustomerPortalSession( userData.stripeCustomerId, `${process.env.NEXT_PUBLIC_APP_URL}/subscription` diff --git a/src/app/api/subscription/pro-price-id/route.ts b/src/app/api/subscription/pro-price-id/route.ts new file mode 100644 index 0000000..ee37f5a --- /dev/null +++ b/src/app/api/subscription/pro-price-id/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' +import { SubscriptionService } from '@/lib/subscription-service' + +// GET - 获取 Pro 套餐的 Stripe 价格 ID +export async function GET() { + try { + const priceId = await SubscriptionService.getProPriceId() + + if (!priceId) { + return NextResponse.json( + { error: 'Pro plan not found or not configured' }, + { status: 404 } + ) + } + + return NextResponse.json({ priceId }) + } catch (error) { + console.error('Error fetching Pro price ID:', error) + return NextResponse.json( + { error: 'Failed to fetch Pro price ID' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/subscription/sync/route.ts b/src/app/api/subscription/sync/route.ts index 3dacca0..c74d9e4 100644 --- a/src/app/api/subscription/sync/route.ts +++ b/src/app/api/subscription/sync/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -import { getCustomerSubscriptions, STRIPE_CONFIG } from '@/lib/stripe' -import { prisma } from '@/lib/prisma' +import { SubscriptionService } from '@/lib/subscription-service' export async function POST() { try { @@ -26,58 +25,21 @@ export async function POST() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // 获取用户的 Stripe 客户 ID - const userData = await prisma.user.findUnique({ - where: { id: user.id }, - select: { stripeCustomerId: true } - }) + // 获取用户的实时订阅状态 + const subscriptionStatus = await SubscriptionService.getUserSubscriptionStatus(user.id) - if (!userData?.stripeCustomerId) { - return NextResponse.json({ error: 'No Stripe customer found' }, { status: 404 }) - } + // 更新用户的订阅套餐 + await SubscriptionService.updateUserSubscriptionPlan(user.id, subscriptionStatus.planId) - // 获取 Stripe 订阅信息 - const subscriptions = await getCustomerSubscriptions(userData.stripeCustomerId) - - // 查找活跃的订阅 - const activeSubscription = subscriptions.find(sub => sub.status === 'active') - - let subscribePlan = 'free' - let maxVersionLimit = 3 - let promptLimit = 500 + // 获取用户权益 + const permissions = await SubscriptionService.getUserPermissions(user.id) - if (activeSubscription) { - const subData = activeSubscription as unknown as Record - const items = subData.items as { data: Array<{ price: { id: string } }> } - const priceId = items?.data[0]?.price?.id - - if (priceId === STRIPE_CONFIG.products.pro.priceId) { - subscribePlan = 'pro' - maxVersionLimit = 10 - promptLimit = 5000 - } - } - - // 更新用户订阅状态 - const updatedUser = await prisma.user.update({ - where: { id: user.id }, - data: { - subscribePlan, - maxVersionLimit, - promptLimit, - } - }) - - console.log(`Synced user ${user.id} subscription to ${subscribePlan}`) + console.log(`Synced user ${user.id} subscription to ${subscriptionStatus.planId}`) return NextResponse.json({ success: true, - plan: subscribePlan, - user: { - subscribePlan: updatedUser.subscribePlan, - maxVersionLimit: updatedUser.maxVersionLimit, - promptLimit: updatedUser.promptLimit - } + subscription: subscriptionStatus, + permissions: permissions }) } catch (error) { diff --git a/src/app/api/users/profile/route.ts b/src/app/api/users/profile/route.ts index 7cfb2ce..02d33b6 100644 --- a/src/app/api/users/profile/route.ts +++ b/src/app/api/users/profile/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { getMaxVersionLimit } from '@/lib/subscription' +import { SubscriptionService } from '@/lib/subscription-service' // GET /api/users/profile - 获取用户配置 export async function GET(request: NextRequest) { @@ -76,17 +76,25 @@ export async function PUT(request: NextRequest) { // 验证版本限制不能超过订阅计划的最大限制 let finalVersionLimit = versionLimit if (versionLimit !== undefined) { - const maxVersionLimit = getMaxVersionLimit(currentUser.subscribePlan) - if (versionLimit > maxVersionLimit) { - return NextResponse.json( - { - error: 'Version limit exceeds subscription plan maximum', - maxAllowed: maxVersionLimit - }, - { status: 400 } - ) + try { + const permissions = await SubscriptionService.getUserPermissions(userId) + const maxVersionLimit = permissions.maxVersionLimit + + if (versionLimit > maxVersionLimit) { + return NextResponse.json( + { + error: 'Version limit exceeds subscription plan maximum', + maxAllowed: maxVersionLimit + }, + { status: 400 } + ) + } + finalVersionLimit = Math.max(1, Math.min(versionLimit, maxVersionLimit)) + } catch (error) { + console.error('Error getting user permissions:', error) + // 如果获取权限失败,使用默认限制 + finalVersionLimit = Math.max(1, Math.min(versionLimit, 3)) } - finalVersionLimit = Math.max(1, Math.min(versionLimit, maxVersionLimit)) } const updateData: Record = {} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index d018a0f..262594a 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { stripe, STRIPE_CONFIG } from '@/lib/stripe' import { prisma } from '@/lib/prisma' import { headers } from 'next/headers' +import { SubscriptionService } from '@/lib/subscription-service' export async function POST(request: NextRequest) { try { @@ -84,12 +85,6 @@ async function handleSubscriptionUpdate(subscription: Record) { const items = subscription.items as { data: Array<{ price: { id: string } }> } const priceId = items?.data[0]?.price?.id - // 根据价格 ID 确定订阅计划 - let subscribePlan = 'free' - if (priceId === STRIPE_CONFIG.products.pro.priceId) { - subscribePlan = 'pro' - } - // 查找用户 const user = await prisma.user.findFirst({ where: { stripeCustomerId: customerId } @@ -100,19 +95,17 @@ async function handleSubscriptionUpdate(subscription: Record) { return } - // 更新用户订阅状态 - await prisma.user.update({ - where: { id: user.id }, - data: { - subscribePlan: status === 'active' ? subscribePlan : 'free', - // 根据订阅计划更新限制 - maxVersionLimit: subscribePlan === 'pro' ? 10 : 3, - promptLimit: subscribePlan === 'pro' ? 5000 : 500, - } - }) - + // 根据订阅状态确定套餐 + let planId = 'free' + if (status === 'active' || status === 'trialing') { + // 根据 Stripe 价格 ID 获取套餐 ID + planId = await SubscriptionService.getPlanIdByStripePriceId(priceId) + } + // 更新用户订阅套餐 + await SubscriptionService.updateUserSubscriptionPlan(user.id, planId) + console.log(`Updated user ${user.id} subscription to plan: ${planId}`) } catch (error) { console.error('Error handling subscription update:', error) } @@ -133,17 +126,9 @@ async function handleSubscriptionDeleted(subscription: Record) } // 将用户降级为免费计划 - await prisma.user.update({ - where: { id: user.id }, - data: { - subscribePlan: 'free', - maxVersionLimit: 3, - promptLimit: 500, - } - }) - - + await SubscriptionService.updateUserSubscriptionPlan(user.id, 'free') + console.log(`Reset user ${user.id} to free plan`) } catch (error) { console.error('Error handling subscription deletion:', error) } diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 0e77b30..f6acc95 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -6,13 +6,42 @@ import { useUser } from '@/hooks/useUser' import { Header } from '@/components/layout/Header' import { Button } from '@/components/ui/button' import { Check, Crown, Star } from 'lucide-react' -import { SUBSCRIPTION_PLANS } from '@/lib/subscription' import { SubscribeButton } from '@/components/subscription/SubscribeButton' +import { useEffect, useState } from 'react' +import type { SubscriptionPlan } from '@prisma/client' +import { + isPlanPro, + isPlanFree, + getPlanLimits, + getPlanFeatures, + formatPlanPrice, + getPlanTheme +} from '@/lib/subscription-utils' export default function PricingPage() { const { user } = useAuth() const { userData } = useUser() const t = useTranslations('pricing') + const [plans, setPlans] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchPlans() + }, []) + + const fetchPlans = async () => { + try { + const response = await fetch('/api/subscription-plans') + if (response.ok) { + const data = await response.json() + setPlans(data.plans || []) + } + } catch (error) { + console.error('Error fetching plans:', error) + } finally { + setLoading(false) + } + } const handleGetStarted = () => { if (user) { @@ -22,9 +51,25 @@ export default function PricingPage() { } } - const isCurrentPlan = (planKey: string) => { + const isCurrentPlan = (planId: string) => { if (!userData) return false - return userData.subscribePlan === planKey + return userData.subscriptionPlanId === planId + } + + + + if (loading) { + return ( +
+
+
+
+
+

Loading pricing plans...

+
+
+
+ ) } return ( @@ -44,126 +89,109 @@ export default function PricingPage() { {/* Pricing Cards */}
- {/* Free Plan */} -
- {isCurrentPlan('free') && ( -
- {t('currentPlan')} -
- )} - -
-
-
- -
-
-

- {t('free.title')} -

-
- {t('free.price')} -
-

- {t('free.description')} -

-
- -
    -
  • - - - {SUBSCRIPTION_PLANS.free.promptLimit} Prompt Limit - -
  • -
  • - - - {SUBSCRIPTION_PLANS.free.maxVersionLimit} Versions per Prompt - -
  • -
- - -
+ {plans.map((plan) => { + const limits = getPlanLimits(plan) + const features = getPlanFeatures(plan) + const isFree = isPlanFree(plan) + const isPro = isPlanPro(plan) + const isCurrent = isCurrentPlan(plan.id) + const theme = getPlanTheme(plan) - {/* Pro Plan */} -
-
- {t('popular')} -
- - {isCurrentPlan('pro') && ( -
- {t('currentPlan')} -
- )} - -
-
-
- + return ( +
+ {isCurrent && ( +
+ {t('currentPlan')} +
+ )} + + {isPro && ( +
+ {t('popular')} +
+ )} + +
+
+
+ {isPro ? ( + + ) : ( + + )} +
+
+

+ {plan.displayName} +

+
+ {formatPlanPrice(plan, t)} +
+

+ {plan.description} +

+ +
    +
  • + + + {limits.promptLimit} Prompt Limit + +
  • +
  • + + + {limits.maxVersionLimit} Versions per Prompt + +
  • + {features.includes('prioritySupport') && ( +
  • + + Priority Support +
  • + )} + {features.includes('advancedAnalytics') && ( +
  • + + Advanced Analytics +
  • + )} + {features.includes('apiAccess') && ( +
  • + + API Access +
  • + )} +
+ + {isFree ? ( + + ) : ( + + {isCurrent ? t('currentPlan') : t('upgradePlan')} + + )}
-

- {t('pro.title')} -

-
- {t('pro.price')} -
-

- {t('perMonth')} -

-

- {t('pro.description')} -

-
- -
    -
  • - - - {SUBSCRIPTION_PLANS.pro.promptLimit} Prompt Limit - -
  • -
  • - - - {SUBSCRIPTION_PLANS.pro.maxVersionLimit} Versions per Prompt - -
  • -
  • - - Priority Support -
  • -
- - {isCurrentPlan('pro') ? ( - - ) : ( - - {t('upgradeToPro')} - - )} -
+ ) + })}
{/* Additional Info */} diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index 1cff21b..745e926 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -11,7 +11,7 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner' import { Crown, Star, CreditCard, AlertTriangle } from 'lucide-react' import { SubscriptionStatus } from '@/components/subscription/SubscriptionStatus' -import { SubscribeButton } from '@/components/subscription/SubscribeButton' +import { QuickUpgradeButton } from '@/components/subscription/SubscribeButton' interface SubscriptionData { plan: string @@ -239,14 +239,10 @@ export default function SubscriptionPage() {

Quick Actions

{currentPlan === 'free' ? ( - + {t('upgradePlan')} - + ) : ( <> {/* 简单的账单信息显示 */} diff --git a/src/components/subscription/SubscribeButton.tsx b/src/components/subscription/SubscribeButton.tsx index efa4d3c..e0ec288 100644 --- a/src/components/subscription/SubscribeButton.tsx +++ b/src/components/subscription/SubscribeButton.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useAuth } from '@/hooks/useAuth' import { Button } from '@/components/ui/button' import { LoadingSpinner } from '@/components/ui/loading-spinner' @@ -94,17 +94,63 @@ export function SubscribeButton({ // 简化版本,用于快速升级 interface QuickUpgradeButtonProps { className?: string + children?: React.ReactNode } -export function QuickUpgradeButton({ className }: QuickUpgradeButtonProps) { +export function QuickUpgradeButton({ className, children }: QuickUpgradeButtonProps) { + const [proPriceId, setProPriceId] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + const fetchProPriceId = async () => { + try { + const response = await fetch('/api/subscription/pro-price-id') + if (response.ok) { + const data = await response.json() + if (data.priceId) { + setProPriceId(data.priceId) + } else { + setError('Pro plan not available') + } + } else { + setError('Failed to load Pro plan') + } + } catch (err) { + setError('Failed to load Pro plan') + } finally { + setLoading(false) + } + } + + fetchProPriceId() + }, []) + + if (loading) { + return ( + + ) + } + + if (error || !proPriceId) { + return ( + + ) + } + return ( - Upgrade to Pro + {children || 'Upgrade to Pro'} ) } diff --git a/src/components/subscription/SubscriptionStatus.tsx b/src/components/subscription/SubscriptionStatus.tsx index 6bffe22..b061c59 100644 --- a/src/components/subscription/SubscriptionStatus.tsx +++ b/src/components/subscription/SubscriptionStatus.tsx @@ -5,16 +5,30 @@ import { useUser } from '@/hooks/useUser' import { useTranslations } from 'next-intl' import { Crown, Star, AlertTriangle, CheckCircle } from 'lucide-react' import { cn } from '@/lib/utils' +import { getPlanTheme } from '@/lib/subscription-utils' interface SubscriptionData { - plan: string - status: string - subscriptions: Array<{ + subscription: { + isActive: boolean + planId: string + stripeSubscriptionId?: string + currentPeriodStart?: Date + currentPeriodEnd?: Date + cancelAtPeriodEnd?: boolean + status?: string + } + permissions: { + maxVersionLimit: number + promptLimit: number + features: string[] + canCreatePrompt: boolean + canCreateVersion: boolean + } + plan?: { id: string - status: string - currentPeriodEnd: number - cancelAtPeriodEnd: boolean - }> + displayName: string + price: number + } } interface SubscriptionStatusProps { @@ -58,22 +72,18 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti ) } - const currentPlan = userData?.subscribePlan || 'free' - const isPro = currentPlan === 'pro' - const activeSubscription = subscriptionData?.subscriptions?.find(sub => sub.status === 'active') + const planData = subscriptionData?.plan + const isPro = planData ? planData.price > 19 : false + const theme = getPlanTheme(planData) + const subscription = subscriptionData?.subscription + const permissions = subscriptionData?.permissions return (
-
+
-
+
{isPro ? ( ) : ( @@ -84,10 +94,7 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti

{t('currentPlan')}

-

+

{isPro ? t('proPlan') : t('freePlan')}

@@ -95,7 +102,7 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti {/* Status indicator */}
- {activeSubscription?.cancelAtPeriodEnd ? ( + {subscription?.cancelAtPeriodEnd ? (
Canceling @@ -116,26 +123,26 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti
Prompts: - {userData?.promptLimit || (isPro ? '5000' : '500')} + {permissions?.promptLimit || (isPro ? '5000' : '500')}
Versions: - {userData?.maxVersionLimit || (isPro ? '10' : '3')} per prompt + {permissions?.maxVersionLimit || (isPro ? '10' : '3')} per prompt
- {activeSubscription && ( + {subscription?.currentPeriodEnd && (
{t('nextBilling')}: - {new Date(activeSubscription.currentPeriodEnd * 1000).toLocaleDateString()} + {new Date(subscription.currentPeriodEnd).toLocaleDateString()}
- {activeSubscription.cancelAtPeriodEnd && ( + {subscription.cancelAtPeriodEnd && (
Subscription will end on the next billing date
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index f8714fc..dbd566b 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -12,9 +12,10 @@ interface UserData { language: string isAdmin: boolean versionLimit: number + subscriptionPlanId: string subscribePlan: string maxVersionLimit: number - promptLimit: number + promptLimit?: number // 已弃用,忽略此字段 creditBalance: number createdAt: Date updatedAt: Date diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 9bd3676..c05dfb8 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -213,19 +213,21 @@ export async function consumeCredit(userId: string, amount: number): Promise { return stripePromise } -// Stripe 产品和价格配置 +// Stripe 配置 export const STRIPE_CONFIG = { - products: { - pro: { - priceId: process.env.STRIPE_PRO_PRICE_ID || 'price_dummy', // 需要在 Stripe Dashboard 中创建 - name: 'Pro Plan', - amount: 1990, // $19.90 in cents - currency: 'usd', - interval: 'month' as const, - } - }, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || 'whsec_dummy', } diff --git a/src/lib/subscription-service.ts b/src/lib/subscription-service.ts new file mode 100644 index 0000000..8cc0174 --- /dev/null +++ b/src/lib/subscription-service.ts @@ -0,0 +1,280 @@ +import { prisma } from '@/lib/prisma' +import { getCustomerSubscriptions } from '@/lib/stripe' +import type { SubscriptionPlan } from '@prisma/client' +import { isPlanPro } from '@/lib/subscription-utils' + +// 订阅状态类型 +export interface SubscriptionStatus { + isActive: boolean + planId: string + stripeSubscriptionId?: string + currentPeriodStart?: Date + currentPeriodEnd?: Date + cancelAtPeriodEnd?: boolean + status?: string +} + +// 用户权益类型 +export interface UserPermissions { + maxVersionLimit: number + promptLimit: number + features: string[] + canCreatePrompt: boolean + canCreateVersion: boolean +} + +// 套餐配置类型 +export interface PlanLimits { + maxVersionLimit: number + promptLimit: number + creditMonthly: number +} + +export interface PlanFeatures { + [key: string]: boolean | string | number +} + +export class SubscriptionService { + /** + * 判断套餐是否为 Pro 级别(价格超过 19) + */ + static isPlanPro(plan: SubscriptionPlan | null): boolean { + return isPlanPro(plan) + } + + /** + * 根据套餐 ID 判断是否为 Pro 级别 + */ + static async isPlanIdPro(planId: string): Promise { + const plan = await this.getPlanById(planId) + return this.isPlanPro(plan) + } + + /** + * 判断用户是否为 Pro 用户 + */ + static async isUserPro(userId: string): Promise { + try { + const subscriptionStatus = await this.getUserSubscriptionStatus(userId) + return await this.isPlanIdPro(subscriptionStatus.planId) + } catch (error) { + console.error('Error checking if user is pro:', error) + return false + } + } + /** + * 获取所有可用的订阅套餐 + */ + static async getAvailablePlans(): Promise { + return await prisma.subscriptionPlan.findMany({ + where: { isActive: true }, + orderBy: { sortOrder: 'asc' } + }) + } + + /** + * 根据ID获取套餐 + */ + static async getPlanById(planId: string): Promise { + return await prisma.subscriptionPlan.findUnique({ + where: { id: planId } + }) + } + + /** + * 获取 Pro 套餐(名称为 "pro") + */ + static async getProPlan(): Promise { + return await prisma.subscriptionPlan.findFirst({ + where: { + name: 'pro', + isActive: true + } + }) + } + + /** + * 获取 Pro 套餐的 Stripe 价格 ID + */ + static async getProPriceId(): Promise { + const proPlan = await this.getProPlan() + return proPlan?.stripePriceId || null + } + + /** + * 获取用户的当前订阅状态(从 Stripe 实时获取) + */ + static async getUserSubscriptionStatus(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + stripeCustomerId: true, + subscriptionPlanId: true, + subscriptionPlan: true + } + }) + + if (!user) { + throw new Error('User not found') + } + + // 如果 plan 没有 stripePriceId,则直接返回当前套餐有效 + // 无需从 Stripe 实时获取 + if (!user.subscriptionPlan?.stripePriceId) { + return { + isActive: true, + planId: user.subscriptionPlanId || 'free' + } + } + + // 如果没有匹配到上述规则,则需要从 Stripe 获取实时状态 + // 如果此时没有 Stripe 客户 ID,则返免费套餐状态 + if (!user.stripeCustomerId) { + return { + isActive: true, + planId: 'free' + } + } + + try { + // 从 Stripe 获取实时订阅状态 + const subscriptions = await getCustomerSubscriptions(user.stripeCustomerId) + const activeSubscription = subscriptions.find(sub => + sub.status === 'active' || sub.status === 'trialing' + ) + + if (activeSubscription) { + // 根据 Stripe 价格 ID 确定套餐 + const items = activeSubscription.items?.data || [] + const priceId = items[0]?.price?.id + + // 查找对应的套餐 + const plan = await prisma.subscriptionPlan.findFirst({ + where: { stripePriceId: priceId } + }) + + const subData = activeSubscription as unknown as Record + + return { + isActive: true, + planId: plan?.id || 'free', + stripeSubscriptionId: activeSubscription.id, + currentPeriodStart: new Date((subData.current_period_start as number) * 1000), + currentPeriodEnd: new Date((subData.current_period_end as number) * 1000), + cancelAtPeriodEnd: subData.cancel_at_period_end as boolean, + status: activeSubscription.status + } + } + + // 没有活跃订阅,返回免费套餐 + return { + isActive: true, + planId: 'free' + } + } catch (error) { + console.error('Error fetching subscription status from Stripe:', error) + // Stripe 错误时,返回用户当前套餐状态 + return { + isActive: true, + planId: user.subscriptionPlanId || 'free' + } + } + } + + /** + * 获取用户的权益和限制 + */ + static async getUserPermissions(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { subscriptionPlan: true } + }) + + if (!user) { + throw new Error('User not found') + } + + // 获取实时订阅状态 + const subscriptionStatus = await this.getUserSubscriptionStatus(userId) + + // 获取对应的套餐配置 + const plan = await this.getPlanById(subscriptionStatus.planId) + if (!plan) { + throw new Error('Subscription plan not found') + } + + const limits = plan.limits as unknown as PlanLimits + const features = plan.features as unknown as PlanFeatures + + // 用户自定义的版本限制不能超过套餐限制 + const maxVersionLimit = Math.min(user.versionLimit, limits.maxVersionLimit) + + // 检查当前使用情况(完全基于套餐限制,忽略用户表中的 promptLimit) + const currentPromptCount = await prisma.prompt.count({ + where: { userId } + }) + + return { + maxVersionLimit, + promptLimit: limits.promptLimit, // 完全从套餐配置获取 + features: Object.keys(features).filter(key => features[key] === true), + canCreatePrompt: currentPromptCount < limits.promptLimit, // 使用套餐限制 + canCreateVersion: true // 这个需要在具体创建时检查 + } + } + + /** + * 检查用户是否可以创建新的提示词 + */ + static async canCreatePrompt(userId: string): Promise { + const permissions = await this.getUserPermissions(userId) + return permissions.canCreatePrompt + } + + /** + * 检查用户是否可以创建新版本 + */ + static async canCreateVersion(userId: string, promptId: string): Promise { + const permissions = await this.getUserPermissions(userId) + + // 获取当前版本数量 + const currentVersionCount = await prisma.promptVersion.count({ + where: { promptId } + }) + + return currentVersionCount < permissions.maxVersionLimit + } + + /** + * 计算需要删除的版本数量(当超出限制时) + */ + static async getVersionsToDelete(userId: string, promptId: string): Promise { + const permissions = await this.getUserPermissions(userId) + + const currentVersionCount = await prisma.promptVersion.count({ + where: { promptId } + }) + + return Math.max(0, currentVersionCount - permissions.maxVersionLimit + 1) + } + + /** + * 更新用户的订阅套餐(通常在 webhook 中调用) + */ + static async updateUserSubscriptionPlan(userId: string, planId: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { subscriptionPlanId: planId } + }) + } + + /** + * 根据 Stripe 价格 ID 获取套餐 ID + */ + static async getPlanIdByStripePriceId(stripePriceId: string): Promise { + const plan = await prisma.subscriptionPlan.findFirst({ + where: { stripePriceId } + }) + return plan?.id || 'free' + } +} diff --git a/src/lib/subscription-utils.ts b/src/lib/subscription-utils.ts new file mode 100644 index 0000000..f63cb53 --- /dev/null +++ b/src/lib/subscription-utils.ts @@ -0,0 +1,144 @@ +import type { SubscriptionPlan } from '@prisma/client' + +/** + * 订阅相关的工具函数 + */ + +// 支持部分套餐数据的类型 +type PlanLike = { price: number } | SubscriptionPlan | null | undefined + +/** + * 判断套餐是否为 Pro 级别(价格超过 19) + * 这是一个纯函数,可以在客户端和服务端使用 + */ +export function isPlanPro(plan: PlanLike): boolean { + if (!plan) return false + return plan.price > 19 +} + +/** + * 判断套餐是否为免费套餐(价格为 0) + */ +export function isPlanFree(plan: PlanLike): boolean { + if (!plan) return true + return plan.price === 0 +} + +/** + * 获取套餐的显示级别 + */ +export function getPlanTier(plan: PlanLike): 'free' | 'pro' | 'enterprise' { + if (!plan || plan.price === 0) return 'free' + if (plan.price > 50) return 'enterprise' // 为未来的企业版预留 + if (plan.price > 19) return 'pro' + return 'free' +} + +/** + * 获取套餐的图标组件名称 + */ +export function getPlanIcon(plan: PlanLike): 'Star' | 'Crown' | 'Building' { + const tier = getPlanTier(plan) + switch (tier) { + case 'enterprise': + return 'Building' + case 'pro': + return 'Crown' + case 'free': + default: + return 'Star' + } +} + +/** + * 获取套餐的颜色主题 + */ +export function getPlanTheme(plan: PlanLike): { + gradient: string + iconGradient: string + textColor: string + borderColor?: string +} { + const tier = getPlanTier(plan) + + switch (tier) { + case 'enterprise': + return { + gradient: 'bg-gradient-to-r from-purple-50/60 to-indigo-50/60 dark:from-purple-950/10 dark:to-indigo-950/10', + iconGradient: 'bg-gradient-to-br from-purple-500 to-indigo-500 dark:from-purple-400 dark:to-indigo-400', + textColor: 'text-purple-700 dark:text-purple-300', + borderColor: 'border-purple-200 dark:border-purple-800' + } + case 'pro': + return { + gradient: 'bg-gradient-to-r from-amber-50/60 to-orange-50/60 dark:from-amber-950/10 dark:to-orange-950/10', + iconGradient: 'bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400', + textColor: 'text-orange-700 dark:text-orange-300', + borderColor: 'border-primary' + } + case 'free': + default: + return { + gradient: 'bg-gradient-to-r from-slate-50/60 to-gray-50/60 dark:from-slate-950/5 dark:to-gray-950/5', + iconGradient: 'bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400', + textColor: 'text-slate-600 dark:text-slate-400' + } + } +} + +/** + * 格式化套餐价格显示 + */ +export function formatPlanPrice(plan: PlanLike & { interval?: string }, t?: (key: string) => string): string { + if (!plan || plan.price === 0) { + return t?.('free.price') || 'Free' + } + + const price = `$${plan.price}` + const planWithInterval = plan as { interval?: string } + const interval = planWithInterval.interval === 'year' ? '/year' : '/month' + + return `${price}${interval}` +} + +/** + * 获取套餐限制的类型安全访问器 + */ +export function getPlanLimits(plan: SubscriptionPlan | null | undefined): { + promptLimit: number + maxVersionLimit: number + creditMonthly: number +} { + if (!plan) { + return { + promptLimit: 500, + maxVersionLimit: 3, + creditMonthly: 0 + } + } + + const limits = plan.limits as Record + return { + promptLimit: (limits?.promptLimit as number) || 500, + maxVersionLimit: (limits?.maxVersionLimit as number) || 3, + creditMonthly: (limits?.creditMonthly as number) || 0 + } +} + +/** + * 获取套餐功能的类型安全访问器 + */ +export function getPlanFeatures(plan: SubscriptionPlan | null | undefined): string[] { + if (!plan) return [] + + const features = plan.features as Record + return Object.keys(features).filter(key => features[key] === true) +} + +/** + * 检查套餐是否包含特定功能 + */ +export function planHasFeature(plan: SubscriptionPlan | null | undefined, feature: string): boolean { + const features = getPlanFeatures(plan) + return features.includes(feature) +} diff --git a/src/lib/subscription.ts b/src/lib/subscription.ts index 48116f4..c0d20ff 100644 --- a/src/lib/subscription.ts +++ b/src/lib/subscription.ts @@ -1,31 +1,37 @@ -// 订阅计划配置 +// 兼容性:重新导出新的订阅服务 +export { SubscriptionService } from './subscription-service' + +// 订阅计划配置(已弃用,保留用于向后兼容) +// @deprecated 请使用 SubscriptionService.getAvailablePlans() 替代 export const SUBSCRIPTION_PLANS = { free: { name: 'Free', maxVersionLimit: 3, - promptLimit: 500, // 放宽限制到500 + promptLimit: 500, price: 0, features: [ - 'promptLimit', // 500 Prompt Limit - 'versionsPerPrompt' // 3 Versions per Prompt + 'promptLimit', + 'versionsPerPrompt' ] }, pro: { name: 'Pro', maxVersionLimit: 10, - promptLimit: 5000, // Pro版本5000个提示词限制 + promptLimit: 5000, price: 19.9, features: [ - 'promptLimit', // 5000 Prompt Limit - 'versionsPerPrompt', // 10 Versions per Prompt - 'prioritySupport' // Priority Support + 'promptLimit', + 'versionsPerPrompt', + 'prioritySupport' ] } } as const export type SubscriptionPlan = keyof typeof SUBSCRIPTION_PLANS -// 获取用户的版本限制 +// 兼容性函数(已弃用,保留用于向后兼容) + +// @deprecated 请使用 SubscriptionService.getUserPermissions() 替代 export function getUserVersionLimit(user: { versionLimit: number subscribePlan: string @@ -33,25 +39,23 @@ export function getUserVersionLimit(user: { }): number { const plan = user.subscribePlan as SubscriptionPlan const planConfig = SUBSCRIPTION_PLANS[plan] - + if (!planConfig) { - // 如果订阅计划无效,使用免费计划的限制 return Math.min(user.versionLimit, SUBSCRIPTION_PLANS.free.maxVersionLimit) } - - // 用户设置的限制不能超过订阅计划的最大限制 + return Math.min(user.versionLimit, planConfig.maxVersionLimit) } -// 获取用户的最大版本限制(基于订阅) +// @deprecated 请使用 SubscriptionService.getPlanById() 替代 export function getMaxVersionLimit(subscribePlan: string): number { const plan = subscribePlan as SubscriptionPlan const planConfig = SUBSCRIPTION_PLANS[plan] - + return planConfig?.maxVersionLimit || SUBSCRIPTION_PLANS.free.maxVersionLimit } -// 检查用户是否可以创建新版本 +// @deprecated 请使用 SubscriptionService.canCreateVersion() 替代 export function canCreateNewVersion( currentVersionCount: number, user: { @@ -64,7 +68,7 @@ export function canCreateNewVersion( return currentVersionCount < versionLimit } -// 计算需要删除的版本数量 +// @deprecated 请使用 SubscriptionService.getVersionsToDelete() 替代 export function getVersionsToDelete( currentVersionCount: number, user: { @@ -74,32 +78,19 @@ export function getVersionsToDelete( } ): number { const versionLimit = getUserVersionLimit(user) - return Math.max(0, currentVersionCount - versionLimit + 1) // +1 因为要创建新版本 + return Math.max(0, currentVersionCount - versionLimit + 1) } -// 获取用户的提示词限制 +// @deprecated 请使用 SubscriptionService.getUserPermissions() 替代 export function getPromptLimit(subscribePlan: string): number { const plan = subscribePlan as SubscriptionPlan const planConfig = SUBSCRIPTION_PLANS[plan] - + return planConfig?.promptLimit || SUBSCRIPTION_PLANS.free.promptLimit } -// 检查用户是否可以创建新提示词 +// @deprecated 请使用 SubscriptionService.canCreatePrompt() 替代 export async function canCreateNewPrompt(userId: string): Promise { - const { prisma } = await import('@/lib/prisma') - - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { subscribePlan: true } - }) - - if (!user) return false - - const promptLimit = getPromptLimit(user.subscribePlan) - const currentPromptCount = await prisma.prompt.count({ - where: { userId } - }) - - return currentPromptCount < promptLimit + const { SubscriptionService } = await import('./subscription-service') + return await SubscriptionService.canCreatePrompt(userId) } \ No newline at end of file