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
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
- 付费用户(活跃状态)无限制
@@ -141,14 +154,14 @@ is_paid_active = is_paid && (paid_expires_at.is_none() || paid_expires_at > Utc:
PUT /api/admin/users/{id}/payment
{
"is_paid": true,
"paid_expires_at": "2026-12-31T23:59:59Z" // 可选null 表示永久
"paid_expires_at": "2026-12-31T23:59:59Z"
}
```
### 支付处理器
- 文件:`src/handlers/payment.rs`
- `mock-confirm` 接口仅用于测试环境,未来替换为真实微信支付回调时只需修改此函数
- 套餐金额常量定义在 `payment.rs``get_package_info()` 函数中
- `mock-confirm` 接口仅用于测试环境
- 套餐金额常量定义在 `get_package_info()` 函数中
## 代码模式

View File

@@ -686,154 +686,199 @@ environment = "development"
- ❌ 前端付费 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
{
"success": true,
"data": {
"order_id": "wx_order_xxx",
"prepay_id": "wx_prepay_xxx", // 用于调起支付
"pay_sign": "...",
"expire_time": "2026-05-16T12:00:00Z"
}
}
```
1. **多订单支持**:一个用户可以有多条支付记录
2. **累积计算**:后续购买应累加有效期,而非覆盖
3. **冗余字段**`users` 表的 `is_paid` 和 `paid_expires_at` 用于快速查询
**需新增环境变量**
```
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"
}
}
}
```
**数据库变更**
### 12.2 数据库变更
```sql
-- 新增订单表
CREATE TABLE payment_orders (
-- 1. 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,
user_id INTEGER NOT NULL REFERENCES users(id),
order_no VARCHAR(64) UNIQUE NOT NULL, -- 内部订单号
wx_order_id VARCHAR(64), -- 微信订单号
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, -- 支付时间
user_id INTEGER UNIQUE REFERENCES users(id),
is_paid BOOLEAN DEFAULT false,
paid_expires_at TIMESTAMPTZ, -- 累积过期时间
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);
CREATE INDEX idx_payment_orders_order_no ON payment_orders(order_no);
-- 3. invitation_codes 表(新增)
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
// GET /api/user/quota
// Response
{
"success": true,
"data": {
"is_paid": true,
"is_paid_active": true,
"paid_expires_at": "2026-12-31T23:59:59Z",
"data_usage": {
"used": 45,
"limit": 20, // 非付费用户限制
"unlimited": false // 付费用户无限制
}
/// 确认订单时计算新到期时间
fn calculate_new_expires(
current_expires: Option<DateTime<Utc>>,
pkg_days: Option<i64>, // None 表示永久
) -> Option<DateTime<Utc>> {
let now = Utc::now();
let base_time = current_expires
.map(|e| std::cmp::max(e, now))
.unwrap_or(now);
match pkg_days {
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 定价建议
| 套餐 | 价格 | 有效期 | 说明 |
|------|------|--------|------|
| 套餐 | 价格 | 天数 | 说明 |
|------|------|------|------|
| 包月 | ¥9.9 | 30天 | 尝鲜用户 |
| 包年 | ¥59 | 365天 | 主流套餐 |
| 永久 | ¥199 | 永久 | 忠实用户 |
| 永久 | ¥199 | - | 设为2099-12-31 |
### 12.6 实施优先级
| 阶段 | 内容 | 复杂度 |
|------|------|--------|
| P1 | 后端:订单表 + 支付回调 | |
| P1 | 后端:下单接口 + 微信支付集成 | |
| P2 | 后端:配额查询 API | |
| P2 | 前端:升级页面 + 支付流程 | 中 |
| P3 | 前端:首页配额展示 | |
| P1 | 添加 memberships 表 | |
| P1 | 实现累积计算逻辑 | |
| P1 | 微信支付回调集成 | |
| P2 | invitation_codes 表 | 中 |
| P2 | 邀请码兑换接口 | |
| P2 | 前端付费 UI | 中 |
### 12.7 注意事项
1. **支付安全**:回调接口必须验证微信签名
2. **幂等性**:支付回调需处理重复通知
3. **退款处理**需实现退款接口和状态更新
4. **试用期**:可考虑新用户首月免费
1. **幂等性**:支付回调需处理重复通知(微信可能多次推送)
2. **永久会员**expires_at 设为 2099-12-31 而非 NULL
3. **事务处理**确认订单和更新会员状态应在同一事务中
4. **退款处理**:需实现退款接口,更新 membership
---
@@ -866,7 +911,7 @@ CREATE INDEX idx_invitation_codes_code ON invitation_codes(code);
POST /api/redeem-invitation-code
Request:
{
"code": "ASD2024VIP001"
"code": "ASD2024Y01A2B3C"
}
Response:
@@ -874,6 +919,7 @@ Response:
"success": true,
"data": {
"package_type": "yearly",
"paid_days": 365,
"expires_at": "2027-04-17T00:00:00Z"
}
}
@@ -901,8 +947,8 @@ Response:
"success": true,
"data": {
"codes": [
{"code": "ASD2024VIP001", "expires_at": "2027-04-17T00:00:00Z"},
{"code": "ASD2024VIP002", "expires_at": "2027-04-17T00:00:00Z"}
{"code": "ASD2024Y01A2B3C", "paid_days": 365, "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
// 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) {
wx.showModal({
title: '开通成功',
content: `恭喜!您已开通${res.data.package_type}会员`,
content: `恭喜!您已开通${res.data.paid_days}天会员`,
showCancel: false
});
// 刷新用户状态
this.fetchUserProfile();
}
}
@@ -958,19 +1058,23 @@ async onRedeemCode() {
### 邀请码生成规则建议
```
格式ASD + 年份 + 类型 + 随机6位
格式ASD + 年份 + 类型标识 + 6位随机
类型标识Y=年度, M=月度, P=永久
示例:
- ASD2024Y01A2B3C (年度会员)
- ASD2024M01X2Y3Z (月度会员)
- ASD2024P01Q2W3E (永久会员)
- ASD2024Y01A2B3C (年度会员 365天
- ASD2024M03X7Y9Z (月度会员 30天
- ASD2024P00A1B2C (永久会员)
```
### 实施优先级
| 阶段 | 内容 | 复杂度 |
|------|------|--------|
| P2 | 后端:邀请码表 + 使用/生成接口 | |
| P2 | 前端:邀请码输入入口 | |
| P1 | memberships 表迁移 | |
| P1 | 累积计算逻辑实现 | |
| P2 | invitation_codes 表 | 中 |
| P2 | 邀请码兑换/生成接口 | 中 |
| P2 | 前端邀请码入口 | 低 |
---