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:
23
AGENTS.md
23
AGENTS.md
@@ -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()` 函数中
|
||||
|
||||
## 代码模式
|
||||
|
||||
|
||||
372
IMPROVEMENTS.md
372
IMPROVEMENTS.md
@@ -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 | 前端邀请码入口 | 低 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user