better subscribe

This commit is contained in:
songtianlun 2025-08-05 22:43:18 +08:00
parent bbdfb54c84
commit c5c69645c5
32 changed files with 2587 additions and 351 deletions

View 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. **一致性**: 所有套餐配置都在数据库中统一管理

View File

@ -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 端点

View 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
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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
View 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)
})

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }
)
}
}

View File

@ -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: {

View File

@ -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: {

View File

@ -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) {

View 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 }
)
}
}

View File

@ -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 })
}

View File

@ -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`

View 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 }
)
}
}

View File

@ -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) {

View File

@ -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> = {}

View File

@ -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)
}

View File

@ -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 */}

View File

@ -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>
) : (
<>
{/* 简单的账单信息显示 */}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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

View File

@ -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 // 免费版没有月度额度
}

View File

@ -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',
}

View 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'
}
}

View 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)
}

View File

@ -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)
}