From e02644d6d742fe5014ca551c74d7df7595f28dbf Mon Sep 17 00:00:00 2001 From: Milky0217 Date: Fri, 17 Apr 2026 21:44:27 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E9=87=8D=E6=9E=84=E4=BB=98=E8=B4=B9?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md: - 支付系统章节新增三种支付模式(微信/邀请码/管理员) - 添加累积计算逻辑说明 - 说明永久会员使用 2099-12-31 而非 NULL IMPROVEMENTS.md: - 重构 12.1-12.7 章节为新的付费系统设计 - 添加 memberships 表设计(冗余表提高查询性能) - 添加 invitation_codes 表设计 - 详细说明累积计算逻辑和代码示例 - 更新邀请码接口设计(增加 paid_days 字段) - 更新后端/前端实现要点 - 邀请码生成规则改为 Y/M/P 类型标识 --- AGENTS.md | 23 ++- IMPROVEMENTS.md | 372 +++++++++++++++++++++++++++++++----------------- 2 files changed, 256 insertions(+), 139 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc8df8f..62595a5 100644 --- a/AGENTS.md +++ b/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()` 函数中 ## 代码模式 diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 4de53bc..8ff00a4 100644 --- a/IMPROVEMENTS.md +++ b/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>, + pkg_days: Option, // None 表示永久 +) -> Option> { + 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::::from_timestamp(2099, 12, 31)), // 永久会员 } } + +/// 确认订单支付 +pub async fn confirm_payment_order( + pool: &PgPool, + order_no: &str, + user_id: i32, +) -> Result>, 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 { - // 查询邀请码 - // 检查是否已使用 - // 检查是否过期 - // 标记为已使用 - // 更新用户付费状态 +/// 兑换邀请码 +pub async fn redeem_invitation_code( + pool: &PgPool, + user_id: i32, + code: &str, +) -> Result { + // 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::::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> { - // 生成随机码 - // 批量插入数据库 +/// 生成邀请码 +pub async fn generate_invitation_codes( + pool: &PgPool, + package_type: &str, + count: i32, + expires_in_days: Option, +) -> Result, 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 | 前端邀请码入口 | 低 | ---