docs: 重构付费系统设计文档

AGENTS.md:
- 支付系统章节新增三种支付模式(微信/邀请码/管理员)
- 添加累积计算逻辑说明
- 说明永久会员使用 2099-12-31 而非 NULL

IMPROVEMENTS.md:
- 重构 12.1-12.7 章节为新的付费系统设计
- 添加 memberships 表设计(冗余表提高查询性能)
- 添加 invitation_codes 表设计
- 详细说明累积计算逻辑和代码示例
- 更新邀请码接口设计(增加 paid_days 字段)
- 更新后端/前端实现要点
- 邀请码生成规则改为 Y/M/P 类型标识
This commit is contained in:
2026-04-17 21:44:27 +08:00
parent 75cdb1d222
commit e02644d6d7
2 changed files with 256 additions and 139 deletions

View File

@@ -126,11 +126,24 @@ title, date, hour, min, longitude, latitude, ...
## 支付系统 ## 支付系统
### 支付状态逻辑 ### 支付模式
| 模式 | 来源 | 处理方式 |
|------|------|---------|
| 微信支付 | `payment_orders` | 收到微信回调后确认,累积计算有效期 |
| 邀请码 | `invitation_codes` | 核销后直接激活,累积计算有效期 |
| 管理员开通 | 直接 UPDATE | 后台手动设置 |
### 累积计算逻辑
用户多次购买时,有效期会累加而非覆盖:
```rust ```rust
is_paid_active = is_paid && (paid_expires_at.is_none() || paid_expires_at > Utc::now()) // 新到期时间 = MAX(当前到期, 现在) + 本次套餐天数
let base_time = std::cmp::max(current_expires, Utc::now());
let new_expires = base_time + days(pkg_days);
``` ```
### 永久会员
永久会员的 `expires_at` 设为 `2099-12-31` 而非 NULL。
### 配额限制 ### 配额限制
- 未付费用户限制为 `FREE_USER_DATA_LIMIT` 条记录(环境变量,默认 20 - 未付费用户限制为 `FREE_USER_DATA_LIMIT` 条记录(环境变量,默认 20
- 付费用户(活跃状态)无限制 - 付费用户(活跃状态)无限制
@@ -141,14 +154,14 @@ is_paid_active = is_paid && (paid_expires_at.is_none() || paid_expires_at > Utc:
PUT /api/admin/users/{id}/payment PUT /api/admin/users/{id}/payment
{ {
"is_paid": true, "is_paid": true,
"paid_expires_at": "2026-12-31T23:59:59Z" // 可选null 表示永久 "paid_expires_at": "2026-12-31T23:59:59Z"
} }
``` ```
### 支付处理器 ### 支付处理器
- 文件:`src/handlers/payment.rs` - 文件:`src/handlers/payment.rs`
- `mock-confirm` 接口仅用于测试环境,未来替换为真实微信支付回调时只需修改此函数 - `mock-confirm` 接口仅用于测试环境
- 套餐金额常量定义在 `payment.rs``get_package_info()` 函数中 - 套餐金额常量定义在 `get_package_info()` 函数中
## 代码模式 ## 代码模式

View File

@@ -686,154 +686,199 @@ environment = "development"
- ❌ 前端付费 UI - ❌ 前端付费 UI
- ❌ 购买记录表 - ❌ 购买记录表
### 12.1 支付下单接口 ### 12.1 支付模式设计
**需求**:用户点击购买后,后端生成微信支付订单。 #### 三种支付模式
**建议实现** | 模式 | 来源 | 处理方式 |
|------|------|---------|
| 微信支付 | `payment_orders.status = 'paid'` | 调用微信支付 API收到回调后确认 |
| 邀请码 | `invitation_codes` | 核销码后直接激活 |
| 管理员开通 | 直接 UPDATE users | 后台手动设置 |
```rust #### 核心设计原则
// POST /api/payment/create-order
// Request
{
"package_type": "monthly" | "yearly" | "permanent"
}
// Response 1. **多订单支持**:一个用户可以有多条支付记录
{ 2. **累积计算**:后续购买应累加有效期,而非覆盖
"success": true, 3. **冗余字段**`users` 表的 `is_paid` 和 `paid_expires_at` 用于快速查询
"data": {
"order_id": "wx_order_xxx",
"prepay_id": "wx_prepay_xxx", // 用于调起支付
"pay_sign": "...",
"expire_time": "2026-05-16T12:00:00Z"
}
}
```
**需新增环境变量** ### 12.2 数据库变更
```
WECHAT_PAYMENT_MCHID=商户号
WECHAT_PAYMENT_SECRET=支付密钥
WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/payment/callback
```
### 12.2 支付回调接口
**需求**:接收微信支付成功回调,激活用户付费状态。
**建议实现**
```rust
// POST /api/payment/callback (微信支付服务器调用)
{
"event_type": "TRANSACTION.SUCCESS",
"resource": {
"order_id": "wx_order_xxx",
"out_trade_no": "internal_order_xxx",
"trade_state": "SUCCESS",
"amount": {
"total": 100, // 金额(分)
"currency": "CNY"
}
}
}
```
**数据库变更**
```sql ```sql
-- 新增订单表 -- 1. payment_orders 表添加新字段
CREATE TABLE payment_orders ( ALTER TABLE payment_orders ADD COLUMN wx_order_id VARCHAR(64); -- 微信订单号
ALTER TABLE payment_orders ADD COLUMN paid_days INTEGER; -- 本次套餐天数
ALTER TABLE payment_orders ADD COLUMN source VARCHAR(20) DEFAULT 'wechat'; -- 支付来源wechat/invitation/admin
-- 2. memberships 表(新增)- 会员状态冗余表,提高查询性能
CREATE TABLE memberships (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER UNIQUE REFERENCES users(id),
order_no VARCHAR(64) UNIQUE NOT NULL, -- 内部订单号 is_paid BOOLEAN DEFAULT false,
wx_order_id VARCHAR(64), -- 微信订单号 paid_expires_at TIMESTAMPTZ, -- 累积过期时间
package_type VARCHAR(20) NOT NULL, -- monthly/yearly/permanent
amount INTEGER NOT NULL, -- 金额(分)
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/paid/cancelled/refunded
paid_at TIMESTAMPTZ, -- 支付时间
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ -- 付费到期时间 updated_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE INDEX idx_payment_orders_user_id ON payment_orders(user_id); -- 3. invitation_codes 表(新增)
CREATE INDEX idx_payment_orders_order_no ON payment_orders(order_no); CREATE TABLE invitation_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL,
package_type VARCHAR(20) NOT NULL, -- monthly/yearly/permanent
paid_days INTEGER NOT NULL, -- 转换为天数
source VARCHAR(20) DEFAULT 'invitation',
used_by INTEGER REFERENCES users(id),
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- 邀请码过期时间
is_used BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_invitation_codes_code ON invitation_codes(code);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
``` ```
### 12.3 查询配额 API ### 12.3 累积计算逻辑
**需求**:前端显示用户已使用的数据条数和上限。 **核心算法**
**建议实现**
```rust ```rust
// GET /api/user/quota /// 确认订单时计算新到期时间
// Response fn calculate_new_expires(
{ current_expires: Option<DateTime<Utc>>,
"success": true, pkg_days: Option<i64>, // None 表示永久
"data": { ) -> Option<DateTime<Utc>> {
"is_paid": true, let now = Utc::now();
"is_paid_active": true, let base_time = current_expires
"paid_expires_at": "2026-12-31T23:59:59Z", .map(|e| std::cmp::max(e, now))
"data_usage": { .unwrap_or(now);
"used": 45,
"limit": 20, // 非付费用户限制 match pkg_days {
"unlimited": false // 付费用户无限制 Some(days) => Some(base_time + chrono::Duration::days(days)),
} None => Some(DateTime::<Utc>::from_timestamp(2099, 12, 31)), // 永久会员
} }
} }
/// 确认订单支付
pub async fn confirm_payment_order(
pool: &PgPool,
order_no: &str,
user_id: i32,
) -> Result<Option<DateTime<Utc>>, String> {
// 1. 查询订单
let order = get_order_by_no(pool, order_no).await?;
// 2. 检查权限和状态
if order.user_id != user_id {
return Err("无权操作此订单".to_string());
}
if order.status != "pending" {
return Err("订单状态异常".to_string());
}
// 3. 获取当前用户到期时间
let membership = get_membership(pool, user_id).await?;
let current_expires = membership.map(|m| m.paid_expires_at).flatten();
// 4. 计算新到期时间(累积)
let pkg = get_package_info(&order.package_type)?;
let new_expires = calculate_new_expires(current_expires, pkg.days);
// 5. 更新订单状态
update_order_status(pool, order_no, "paid", new_expires).await?;
// 6. 更新用户会员状态
update_membership(pool, user_id, true, new_expires).await?;
Ok(new_expires)
}
``` ```
**需新增**`db::get_user_quota(pool, user_id) -> QuotaInfo` **累积计算示例**
### 12.4 前端付费 UI | 操作 | 原到期时间 | 购买套餐 | 新到期时间 |
|------|----------|---------|-----------|
| 首次购买 | - | 包月(30天) | 现在+30天 |
| 第二次购买 | 5/1 | 包年(365天) | MAX(5/1, 现在)+365天 |
| 第三次购买 | 明年5/1 | 包月(30天) | 明年5/1+30天 |
**页面建议** ### 12.4 接口设计
#### 微信支付流程
```json
// 1. 创建订单
POST /api/payment/create-order
Request: { "package_type": "monthly" | "yearly" | "permanent" }
Response: {
"success": true,
"data": {
"order_id": "内部订单号",
"prepay_id": "微信预支付ID",
"package": "prepay_id=...",
"timestamp": "...",
"nonce_str": "...",
"pay_sign": "..."
}
}
// 2. 支付回调
POST /api/payment/callback
Request: {
"event_type": "TRANSACTION.SUCCESS",
"resource": {
"out_trade_no": "内部订单号",
"transaction_id": "微信订单号",
"trade_state": "SUCCESS"
}
}
Response: { "code": "SUCCESS" }
// 3. 邀请码兑换
POST /api/redeem-invitation-code
Request: { "code": "ASD2024Y01A2B3C" }
Response: {
"success": true,
"data": {
"package_type": "yearly",
"paid_days": 365,
"expires_at": "2027-04-17T00:00:00Z"
}
}
// 4. 管理员开通
PUT /api/admin/users/{id}/payment
Request: {
"is_paid": true,
"paid_expires_at": "2026-12-31T23:59:59Z"
}
``` ```
pages/
├── upgrade/
│ ├── upgrade.js # 购买页面
│ ├── upgrade.wxml # 套餐选择 + 支付按钮
│ └── upgrade.json
└── ...
```
**功能**
1. 展示套餐(包月/包年/永久)
2. 调用后端创建订单
3. 调起微信支付
4. 支付成功/失败提示
5. 跳转回首页
**首页配额展示**
- 非付费用户:显示「已用 X/20 条,开通付费解锁无限存储」
- 付费用户:显示「已用 X 条,无限存储」
### 12.5 定价建议 ### 12.5 定价建议
| 套餐 | 价格 | 有效期 | 说明 | | 套餐 | 价格 | 天数 | 说明 |
|------|------|--------|------| |------|------|------|------|
| 包月 | ¥9.9 | 30天 | 尝鲜用户 | | 包月 | ¥9.9 | 30天 | 尝鲜用户 |
| 包年 | ¥59 | 365天 | 主流套餐 | | 包年 | ¥59 | 365天 | 主流套餐 |
| 永久 | ¥199 | 永久 | 忠实用户 | | 永久 | ¥199 | - | 设为2099-12-31 |
### 12.6 实施优先级 ### 12.6 实施优先级
| 阶段 | 内容 | 复杂度 | | 阶段 | 内容 | 复杂度 |
|------|------|--------| |------|------|--------|
| P1 | 后端:订单表 + 支付回调 | | | P1 | 添加 memberships 表 | |
| P1 | 后端:下单接口 + 微信支付集成 | | | P1 | 实现累积计算逻辑 | |
| P2 | 后端:配额查询 API | | | P1 | 微信支付回调集成 | |
| P2 | 前端:升级页面 + 支付流程 | 中 | | P2 | invitation_codes 表 | 中 |
| P3 | 前端:首页配额展示 | | | P2 | 邀请码兑换接口 | |
| P2 | 前端付费 UI | 中 |
### 12.7 注意事项 ### 12.7 注意事项
1. **支付安全**:回调接口必须验证微信签名 1. **幂等性**:支付回调需处理重复通知(微信可能多次推送)
2. **幂等性**:支付回调需处理重复通知 2. **永久会员**expires_at 设为 2099-12-31 而非 NULL
3. **退款处理**需实现退款接口和状态更新 3. **事务处理**确认订单和更新会员状态应在同一事务中
4. **试用期**:可考虑新用户首月免费 4. **退款处理**:需实现退款接口,更新 membership
--- ---
@@ -866,7 +911,7 @@ CREATE INDEX idx_invitation_codes_code ON invitation_codes(code);
POST /api/redeem-invitation-code POST /api/redeem-invitation-code
Request: Request:
{ {
"code": "ASD2024VIP001" "code": "ASD2024Y01A2B3C"
} }
Response: Response:
@@ -874,6 +919,7 @@ Response:
"success": true, "success": true,
"data": { "data": {
"package_type": "yearly", "package_type": "yearly",
"paid_days": 365,
"expires_at": "2027-04-17T00:00:00Z" "expires_at": "2027-04-17T00:00:00Z"
} }
} }
@@ -901,8 +947,8 @@ Response:
"success": true, "success": true,
"data": { "data": {
"codes": [ "codes": [
{"code": "ASD2024VIP001", "expires_at": "2027-04-17T00:00:00Z"}, {"code": "ASD2024Y01A2B3C", "paid_days": 365, "expires_at": "2027-04-17T00:00:00Z"},
{"code": "ASD2024VIP002", "expires_at": "2027-04-17T00:00:00Z"} {"code": "ASD2024Y04D5E6F", "paid_days": 365, "expires_at": "2027-04-17T00:00:00Z"}
] ]
} }
} }
@@ -910,19 +956,74 @@ Response:
### 后端实现要点 ### 后端实现要点
```rust ```rust
// 1. 使用邀请码 /// 兑换邀请码
async fn redeem_invitation_code(pool: &PgPool, user_id: i32, code: &str) -> Result<PackageType> { pub async fn redeem_invitation_code(
// 查询邀请码 pool: &PgPool,
// 检查是否已使用 user_id: i32,
// 检查是否过期 code: &str,
// 标记为已使用 ) -> Result<InvitationRedeemResult, String> {
// 更新用户付费状态 // 1. 查询邀请码
let invite = get_invitation_code(pool, code).await?;
// 2. 检查是否已使用
if invite.is_used {
return Err("邀请码已使用".to_string());
}
// 3. 检查是否过期
if let Some(expires) = invite.expires_at {
if expires < Utc::now() {
return Err("邀请码已过期".to_string());
}
}
// 4. 获取当前用户会员状态
let membership = get_membership(pool, user_id).await?;
let current_expires = membership.map(|m| m.paid_expires_at).flatten();
// 5. 计算新到期时间(累积)
let base_time = current_expires
.map(|e| std::cmp::max(e, Utc::now()))
.unwrap_or_else(Utc::now);
let new_expires = if invite.package_type == "permanent" {
DateTime::<Utc>::from_timestamp(2099, 12, 31)
} else {
Some(base_time + chrono::Duration::days(invite.paid_days as i64))
};
// 6. 标记邀请码已使用
use_invitation_code(pool, code, user_id).await?;
// 7. 更新用户会员状态(累积)
update_membership(pool, user_id, true, new_expires).await?;
Ok(InvitationRedeemResult {
package_type: invite.package_type,
paid_days: invite.paid_days,
expires_at: new_expires,
})
} }
// 2. 生成邀请码 /// 生成邀请码
async fn generate_invitation_codes(pool: &PgPool, package_type: &str, count: i32, expires_days: i32) -> Result<Vec<String>> { pub async fn generate_invitation_codes(
// 生成随机码 pool: &PgPool,
// 批量插入数据库 package_type: &str,
count: i32,
expires_in_days: Option<i32>,
) -> Result<Vec<GeneratedCode>, String> {
let mut codes = Vec::new();
let paid_days = get_package_days(package_type)?;
for _ in 0..count {
let code = generate_random_code(package_type);
let expires_at = expires_in_days
.map(|d| Utc::now() + chrono::Duration::days(d as i64));
create_invitation_code(pool, &code, package_type, paid_days, expires_at).await?;
codes.push(GeneratedCode { code, paid_days, expires_at });
}
Ok(codes)
} }
``` ```
@@ -947,10 +1048,9 @@ async onRedeemCode() {
if (res.success) { if (res.success) {
wx.showModal({ wx.showModal({
title: '开通成功', title: '开通成功',
content: `恭喜!您已开通${res.data.package_type}会员`, content: `恭喜!您已开通${res.data.paid_days}天会员`,
showCancel: false showCancel: false
}); });
// 刷新用户状态
this.fetchUserProfile(); this.fetchUserProfile();
} }
} }
@@ -958,19 +1058,23 @@ async onRedeemCode() {
### 邀请码生成规则建议 ### 邀请码生成规则建议
``` ```
格式ASD + 年份 + 类型 + 随机6位 格式ASD + 年份 + 类型标识 + 6位随机
类型标识Y=年度, M=月度, P=永久
示例: 示例:
- ASD2024Y01A2B3C (年度会员) - ASD2024Y01A2B3C (年度会员 365天
- ASD2024M01X2Y3Z (月度会员) - ASD2024M03X7Y9Z (月度会员 30天
- ASD2024P01Q2W3E (永久会员) - ASD2024P00A1B2C (永久会员)
``` ```
### 实施优先级 ### 实施优先级
| 阶段 | 内容 | 复杂度 | | 阶段 | 内容 | 复杂度 |
|------|------|--------| |------|------|--------|
| P2 | 后端:邀请码表 + 使用/生成接口 | | | P1 | memberships 表迁移 | |
| P2 | 前端:邀请码输入入口 | | | P1 | 累积计算逻辑实现 | |
| P2 | invitation_codes 表 | 中 |
| P2 | 邀请码兑换/生成接口 | 中 |
| P2 | 前端邀请码入口 | 低 |
--- ---