better subscribe
This commit is contained in:
parent
bbdfb54c84
commit
c5c69645c5
200
docs/pro-plan-configuration.md
Normal file
200
docs/pro-plan-configuration.md
Normal file
@ -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
|
||||
<QuickUpgradeButton className="btn-primary">
|
||||
Upgrade to Pro
|
||||
</QuickUpgradeButton>
|
||||
```
|
||||
|
||||
### 定价页面
|
||||
|
||||
定价页面会自动从数据库获取所有套餐,包括 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. **一致性**: 所有套餐配置都在数据库中统一管理
|
@ -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 端点
|
||||
|
107
docs/subscription-pro-detection.md
Normal file
107
docs/subscription-pro-detection.md
Normal file
@ -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
|
||||
}
|
||||
```
|
520
package-lock.json
generated
520
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
147
prisma/seed.ts
Normal file
147
prisma/seed.ts
Normal file
@ -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)
|
||||
})
|
80
scripts/fix-pro-plan-name.ts
Normal file
80
scripts/fix-pro-plan-name.ts
Normal file
@ -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 }
|
147
scripts/migrate-subscription-system.ts
Normal file
147
scripts/migrate-subscription-system.ts
Normal file
@ -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 }
|
113
scripts/test-pro-plan-detection.ts
Normal file
113
scripts/test-pro-plan-detection.ts
Normal file
@ -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 }
|
138
scripts/test-subscription-system.ts
Normal file
138
scripts/test-subscription-system.ts
Normal file
@ -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 }
|
215
src/app/api/admin/subscription-plans/route.ts
Normal file
215
src/app/api/admin/subscription-plans/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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) {
|
||||
|
16
src/app/api/subscription-plans/route.ts
Normal file
16
src/app/api/subscription-plans/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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 })
|
||||
}
|
||||
|
||||
|
@ -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<string, unknown>
|
||||
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`
|
||||
|
24
src/app/api/subscription/pro-price-id/route.ts
Normal file
24
src/app/api/subscription/pro-price-id/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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<string, unknown>
|
||||
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) {
|
||||
|
@ -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<string, unknown> = {}
|
||||
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>)
|
||||
}
|
||||
|
||||
// 将用户降级为免费计划
|
||||
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)
|
||||
}
|
||||
|
@ -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<SubscriptionPlan[]>([])
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-4 text-muted-foreground">Loading pricing plans...</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -44,126 +89,109 @@ export default function PricingPage() {
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{/* Free Plan */}
|
||||
<div className="bg-card p-8 rounded-lg shadow-sm border relative">
|
||||
{isCurrentPlan('free') && (
|
||||
<div className="absolute top-4 right-4 bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300 px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{t('currentPlan')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="p-3 rounded-full bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400">
|
||||
<Star className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{t('free.title')}
|
||||
</h3>
|
||||
<div className="text-4xl font-bold text-card-foreground mb-2">
|
||||
{t('free.price')}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{t('free.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">
|
||||
{SUBSCRIPTION_PLANS.free.promptLimit} Prompt Limit
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">
|
||||
{SUBSCRIPTION_PLANS.free.maxVersionLimit} Versions per Prompt
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={isCurrentPlan('free') ? 'outline' : 'default'}
|
||||
onClick={handleGetStarted}
|
||||
disabled={isCurrentPlan('free')}
|
||||
>
|
||||
{isCurrentPlan('free') ? t('currentPlan') : t('getStartedFree')}
|
||||
</Button>
|
||||
</div>
|
||||
{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 */}
|
||||
<div className="bg-primary p-8 rounded-lg shadow-sm text-primary-foreground relative">
|
||||
<div className="absolute top-4 right-4 bg-primary-foreground text-primary px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{t('popular')}
|
||||
</div>
|
||||
|
||||
{isCurrentPlan('pro') && (
|
||||
<div className="absolute top-4 left-4 bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300 px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{t('currentPlan')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="p-3 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400">
|
||||
<Crown className="w-6 h-6 text-white" />
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`bg-card p-8 rounded-lg shadow-sm border relative ${
|
||||
isPro ? `${theme.borderColor} shadow-lg` : ''
|
||||
}`}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="absolute top-4 right-4 bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300 px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{t('currentPlan')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPro && (
|
||||
<div className="absolute top-4 left-4 bg-primary text-primary-foreground px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{t('popular')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className={`p-3 rounded-full ${theme.iconGradient}`}>
|
||||
{isPro ? (
|
||||
<Crown className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<Star className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{plan.displayName}
|
||||
</h3>
|
||||
<div className="text-4xl font-bold text-card-foreground mb-2">
|
||||
{formatPlanPrice(plan, t)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">
|
||||
{limits.promptLimit} Prompt Limit
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">
|
||||
{limits.maxVersionLimit} Versions per Prompt
|
||||
</span>
|
||||
</li>
|
||||
{features.includes('prioritySupport') && (
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">Priority Support</span>
|
||||
</li>
|
||||
)}
|
||||
{features.includes('advancedAnalytics') && (
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">Advanced Analytics</span>
|
||||
</li>
|
||||
)}
|
||||
{features.includes('apiAccess') && (
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span className="text-card-foreground">API Access</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{isFree ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={isCurrent ? 'outline' : 'default'}
|
||||
onClick={handleGetStarted}
|
||||
disabled={isCurrent}
|
||||
>
|
||||
{isCurrent ? t('currentPlan') : t('getStartedFree')}
|
||||
</Button>
|
||||
) : (
|
||||
<SubscribeButton
|
||||
priceId={plan.stripePriceId || ''}
|
||||
planName={plan.displayName}
|
||||
className={`w-full ${isPro ? 'bg-primary hover:bg-primary/90' : ''}`}
|
||||
disabled={isCurrent}
|
||||
>
|
||||
{isCurrent ? t('currentPlan') : t('upgradePlan')}
|
||||
</SubscribeButton>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-2">
|
||||
{t('pro.title')}
|
||||
</h3>
|
||||
<div className="text-4xl font-bold mb-2">
|
||||
{t('pro.price')}
|
||||
</div>
|
||||
<p className="text-primary-foreground/80">
|
||||
{t('perMonth')}
|
||||
</p>
|
||||
<p className="text-primary-foreground/80 mt-2">
|
||||
{t('pro.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
|
||||
<span>
|
||||
{SUBSCRIPTION_PLANS.pro.promptLimit} Prompt Limit
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
|
||||
<span>
|
||||
{SUBSCRIPTION_PLANS.pro.maxVersionLimit} Versions per Prompt
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
|
||||
<span>Priority Support</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{isCurrentPlan('pro') ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full bg-primary-foreground text-primary"
|
||||
disabled
|
||||
>
|
||||
{t('currentPlan')}
|
||||
</Button>
|
||||
) : (
|
||||
<SubscribeButton
|
||||
priceId={process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || ''}
|
||||
planName="Pro"
|
||||
variant="outline"
|
||||
className="w-full bg-primary-foreground text-primary hover:bg-primary-foreground/90"
|
||||
>
|
||||
{t('upgradeToPro')}
|
||||
</SubscribeButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
|
@ -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() {
|
||||
<h3 className="font-semibold text-foreground mb-4">Quick Actions</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{currentPlan === 'free' ? (
|
||||
<SubscribeButton
|
||||
priceId={process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || ''}
|
||||
planName="Pro"
|
||||
className="flex items-center"
|
||||
>
|
||||
<QuickUpgradeButton className="flex items-center">
|
||||
<Crown className="w-4 h-4 mr-2" />
|
||||
{t('upgradePlan')}
|
||||
</SubscribeButton>
|
||||
</QuickUpgradeButton>
|
||||
) : (
|
||||
<>
|
||||
{/* 简单的账单信息显示 */}
|
||||
|
@ -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<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
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 (
|
||||
<Button variant="default" className={className} disabled>
|
||||
<LoadingSpinner className="w-4 h-4 mr-2" />
|
||||
Loading...
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !proPriceId) {
|
||||
return (
|
||||
<Button variant="outline" className={className} disabled>
|
||||
Pro plan unavailable
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SubscribeButton
|
||||
priceId={process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || ''}
|
||||
priceId={proPriceId}
|
||||
planName="Pro"
|
||||
className={className}
|
||||
variant="default"
|
||||
>
|
||||
Upgrade to Pro
|
||||
{children || 'Upgrade to Pro'}
|
||||
</SubscribeButton>
|
||||
)
|
||||
}
|
||||
|
@ -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 (
|
||||
<div className={cn("bg-card rounded-lg border border-border overflow-hidden", className)}>
|
||||
<div className={`p-4 border-b border-border ${isPro
|
||||
? 'bg-gradient-to-r from-amber-50/60 to-orange-50/60 dark:from-amber-950/10 dark:to-orange-950/10'
|
||||
: 'bg-gradient-to-r from-slate-50/60 to-gray-50/60 dark:from-slate-950/5 dark:to-gray-950/5'
|
||||
}`}>
|
||||
<div className={`p-4 border-b border-border ${theme.gradient}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-full ${isPro
|
||||
? 'bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400'
|
||||
: 'bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400'
|
||||
}`}>
|
||||
<div className={`p-2 rounded-full ${theme.iconGradient}`}>
|
||||
{isPro ? (
|
||||
<Crown className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
@ -84,10 +94,7 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{t('currentPlan')}
|
||||
</h3>
|
||||
<p className={`text-sm font-medium ${isPro
|
||||
? 'text-orange-700 dark:text-orange-300'
|
||||
: 'text-slate-600 dark:text-slate-400'
|
||||
}`}>
|
||||
<p className={`text-sm font-medium ${theme.textColor}`}>
|
||||
{isPro ? t('proPlan') : t('freePlan')}
|
||||
</p>
|
||||
</div>
|
||||
@ -95,7 +102,7 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{activeSubscription?.cancelAtPeriodEnd ? (
|
||||
{subscription?.cancelAtPeriodEnd ? (
|
||||
<div className="flex items-center text-orange-600 dark:text-orange-400">
|
||||
<AlertTriangle className="w-4 h-4 mr-1" />
|
||||
<span className="text-xs font-medium">Canceling</span>
|
||||
@ -116,26 +123,26 @@ export function SubscriptionStatus({ className, showDetails = true }: Subscripti
|
||||
<div>
|
||||
<span className="text-muted-foreground">Prompts:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{userData?.promptLimit || (isPro ? '5000' : '500')}
|
||||
{permissions?.promptLimit || (isPro ? '5000' : '500')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Versions:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{userData?.maxVersionLimit || (isPro ? '10' : '3')} per prompt
|
||||
{permissions?.maxVersionLimit || (isPro ? '10' : '3')} per prompt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSubscription && (
|
||||
{subscription?.currentPeriodEnd && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t('nextBilling')}:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{new Date(activeSubscription.currentPeriodEnd * 1000).toLocaleDateString()}
|
||||
{new Date(subscription.currentPeriodEnd).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{activeSubscription.cancelAtPeriodEnd && (
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
Subscription will end on the next billing date
|
||||
</div>
|
||||
|
@ -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
|
||||
|
@ -213,19 +213,21 @@ export async function consumeCredit(userId: string, amount: number): Promise<boo
|
||||
|
||||
/**
|
||||
* 获取订阅计划的限制
|
||||
* @deprecated 请使用 SubscriptionService.getUserPermissions() 替代
|
||||
*/
|
||||
export function getSubscriptionLimits(plan: string) {
|
||||
// 保留向后兼容性,但建议使用新的订阅服务
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
return {
|
||||
promptLimit: 500,
|
||||
promptLimit: 5000, // 修正为正确的 Pro 限制
|
||||
maxVersionLimit: 10,
|
||||
monthlyCredit: 20.0
|
||||
}
|
||||
case 'free':
|
||||
default:
|
||||
return {
|
||||
promptLimit: 20,
|
||||
promptLimit: 500, // 修正为正确的免费限制
|
||||
maxVersionLimit: 3,
|
||||
monthlyCredit: 0.0 // 免费版没有月度额度
|
||||
}
|
||||
|
@ -15,17 +15,8 @@ export const getStripe = () => {
|
||||
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',
|
||||
}
|
||||
|
||||
|
280
src/lib/subscription-service.ts
Normal file
280
src/lib/subscription-service.ts
Normal file
@ -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<boolean> {
|
||||
const plan = await this.getPlanById(planId)
|
||||
return this.isPlanPro(plan)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否为 Pro 用户
|
||||
*/
|
||||
static async isUserPro(userId: string): Promise<boolean> {
|
||||
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<SubscriptionPlan[]> {
|
||||
return await prisma.subscriptionPlan.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取套餐
|
||||
*/
|
||||
static async getPlanById(planId: string): Promise<SubscriptionPlan | null> {
|
||||
return await prisma.subscriptionPlan.findUnique({
|
||||
where: { id: planId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Pro 套餐(名称为 "pro")
|
||||
*/
|
||||
static async getProPlan(): Promise<SubscriptionPlan | null> {
|
||||
return await prisma.subscriptionPlan.findFirst({
|
||||
where: {
|
||||
name: 'pro',
|
||||
isActive: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Pro 套餐的 Stripe 价格 ID
|
||||
*/
|
||||
static async getProPriceId(): Promise<string | null> {
|
||||
const proPlan = await this.getProPlan()
|
||||
return proPlan?.stripePriceId || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的当前订阅状态(从 Stripe 实时获取)
|
||||
*/
|
||||
static async getUserSubscriptionStatus(userId: string): Promise<SubscriptionStatus> {
|
||||
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<string, unknown>
|
||||
|
||||
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<UserPermissions> {
|
||||
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<boolean> {
|
||||
const permissions = await this.getUserPermissions(userId)
|
||||
return permissions.canCreatePrompt
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否可以创建新版本
|
||||
*/
|
||||
static async canCreateVersion(userId: string, promptId: string): Promise<boolean> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { subscriptionPlanId: planId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Stripe 价格 ID 获取套餐 ID
|
||||
*/
|
||||
static async getPlanIdByStripePriceId(stripePriceId: string): Promise<string> {
|
||||
const plan = await prisma.subscriptionPlan.findFirst({
|
||||
where: { stripePriceId }
|
||||
})
|
||||
return plan?.id || 'free'
|
||||
}
|
||||
}
|
144
src/lib/subscription-utils.ts
Normal file
144
src/lib/subscription-utils.ts
Normal file
@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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)
|
||||
}
|
@ -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<boolean> {
|
||||
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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user