会员订阅模块
支付流程总览
┌─────── 用户购买流程 ────────┐
│ │
│ 1. 用户选择套餐 │
│ 2. 选择支付方式 │
│ 3. 创建订单(status=0) │
│ 4. 调用支付平台下单 │
│ 5. 返回支付参数给客户端 │
│ │
├─────── 用户支付 ─────────────┤
│ │
│ Flutter 调起微信/支付宝 │
│ → 用户完成支付 │
│ │
├─────── 支付回调 ─────────────┤
│ │
│ 6. 支付平台通知服务端 │
│ 7. 验证签名(防伪造) │
│ 8. 更新订单状态(status=1) │
│ 9. 激活会员 │
│ │
└──────────────────────────────┘为什么需要"创建订单"和"支付回调"两步?
用户可能创建了订单但不支付(放弃、超时)。两步分离确保了:订单记录存在(用户可以查看历史)、支付成功后才激活会员(不会白白开通)。
套餐列表 API
// server/api/plans.get.ts
export default defineEventHandler(async () => {
const list = await db.select().from(plans)
.where(eq(plans.status, 1))
.orderBy(plans.sortOrder)
return list
})为什么 where(eq(plans.status, 1))?
只返回上架的套餐,下架的套餐前端不可见。管理后台的套餐列表 API 不加这个条件,可以看到所有套餐。
创建订单 API
// server/api/orders.post.ts
import { z } from 'zod'
import { nanoid } from 'nanoid'
const schema = z.object({
planId: z.number(),
paymentMethod: z.enum(['wechat', 'alipay']),
})
export default defineEventHandler(async (event) => {
const userId = event.context.userId
const { planId, paymentMethod } = await readValidatedBody(event, schema.parse)
// 查询套餐
const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1)
if (!plan) throw createError({ statusCode: 404, statusMessage: 'Not Found', message: '套餐不存在' })
if (plan.status !== 1) throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '套餐已下架' })
// 创建订单
const orderNo = `ORD${Date.now()}${nanoid(6)}`
const [order] = await db.insert(orders).values({
userId,
orderNo,
planId,
amount: plan.price,
paymentMethod,
}).returning()
// 根据支付方式生成支付参数
let paymentParams = {}
if (paymentMethod === 'wechat') {
paymentParams = await createWechatPayOrder(order, plan)
} else if (paymentMethod === 'alipay') {
paymentParams = await createAlipayOrder(order, plan)
}
return { order, paymentParams }
})金额安全——为什么从服务端读取价格?
// ✅ 安全:从服务端数据库读取套餐价格
const [plan] = await db.select().from(plans).where(eq(plans.id, planId))
amount: plan.price
// ❌ 危险:使用前端传入的价格
const { planId, amount } = await readBody(event)
// 攻击者可以传 amount: 0.01 来以 1 分钱购买 VIP核心原则:永远不要信任前端传入的价格。前端展示的价格仅供用户查看,实际扣款金额必须从服务端数据库读取。
攻击场景
攻击者拦截请求,将 amount 改为 0.01。如果服务端使用前端传入的金额,就只扣了 1 分钱。
订单号设计
const orderNo = `ORD${Date.now()}${nanoid(6)}`
// 示例:ORD1703123456789abc123| 组成部分 | 作用 | 说明 |
|---|---|---|
ORD 前缀 | 可读性 | 一眼看出是订单号,不是流水号 |
Date.now() | 时间排序 | 13 位时间戳,按时间递增 |
nanoid(6) | 唯一性 | 防止同一毫秒重复,6 位 = 56 亿种组合 |
为什么不用 UUID?
UUID 太长(36 字符),不适合作为用户可见的订单号。ORD + 时间戳 + nanoid 更短(约 25 字符)且仍然唯一。
防止重复下单
用户可能快速点击两次"购买"按钮,导致创建两个订单。解决方案:
// 检查是否有未支付的相同套餐订单
const existingOrder = await db.select().from(orders)
.where(and(
eq(orders.userId, userId),
eq(orders.planId, planId),
eq(orders.status, 0), // 待支付
gte(orders.createdAt, new Date(Date.now() - 30 * 60 * 1000)), // 30 分钟内
))
.limit(1)
if (existingOrder[0]) {
// 复用已有订单,不再创建新的
return { order: existingOrder[0], paymentParams: null }
}微信支付集成
// server/utils/wechat-pay.ts
import WxPay from 'wechatpay-node-v3'
export const getWechatPayClient = () => {
const config = useRuntimeConfig()
return new WxPay({
appid: config.wechatPayAppId,
mchid: config.wechatPayMchId,
publicKey: Buffer.from(config.wechatPayPublicKey, 'base64'),
privateKey: config.wechatPayPrivateKey,
})
}
export const createWechatPayOrder = async (order: any, plan: any) => {
const pay = getWechatPayClient()
const config = useRuntimeConfig()
const result = await pay.transactions_app({
description: plan.name,
out_trade_no: order.orderNo,
notify_url: `${config.public.apiBase}/payments/wechat/notify`,
amount: {
total: Number(order.amount) * 100, // 单位:分
currency: 'CNY',
},
})
return result
}金额单位转换
total: Number(order.amount) * 100 // 数据库存的是元,微信要求分为什么微信用"分"而不是"元"?
金额用整数避免浮点精度问题。数据库中 decimal(10,2) 存的是元(如 29.90),乘以 100 转为分(2990)传给微信。
Number(order.amount) 安全吗?
Drizzle 的 decimal 类型返回字符串(如 "29.90"),需要 Number() 转换。decimal(10,2) 最多 2 位小数,Number() 转换是安全的。不要用 parseFloat,可能丢失精度。
支付宝集成
// server/utils/alipay.ts
import AlipaySdk from 'alipay-sdk'
export const getAlipayClient = () => {
const config = useRuntimeConfig()
return new AlipaySdk({
appId: config.alipayAppId,
privateKey: config.alipayPrivateKey,
alipayPublicKey: config.alipayPublicKey,
})
}
export const createAlipayOrder = async (order: any, plan: any) => {
const alipay = getAlipayClient()
const config = useRuntimeConfig()
const result = await alipay.pageExec('alipay.trade.app.pay', {
bizContent: {
out_trade_no: order.orderNo,
total_amount: String(order.amount),
subject: plan.name,
product_code: 'QUICK_MSECURITY_PAY',
},
notify_url: `${config.public.apiBase}/payments/alipay/notify`,
})
return { orderStr: result }
}微信 vs 支付宝的金额单位差异
微信用分(整数),支付宝用元(字符串)。这是两个平台的 API 设计差异,注意不要混淆。
微信支付回调
// server/api/payments/wechat/notify.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// 验证签名
const pay = getWechatPayClient()
// ... 验证逻辑
const { out_trade_no, transaction_id } = body
// 更新订单状态
const [order] = await db.select().from(orders)
.where(eq(orders.orderNo, out_trade_no)).limit(1)
if (!order || order.status === 1) return { code: 'SUCCESS', message: '成功' }
await db.update(orders).set({
status: 1,
paymentNo: transaction_id,
paidAt: new Date(),
}).where(eq(orders.id, order.id))
// 激活会员
const [plan] = await db.select().from(plans).where(eq(plans.id, order.planId)).limit(1)
if (plan) {
const currentExpiry = await db.select({ vipExpiresAt: users.vipExpiresAt })
.from(users).where(eq(users.id, order.userId)).limit(1)
const baseDate = currentExpiry[0]?.vipExpiresAt && new Date(currentExpiry[0].vipExpiresAt) > new Date()
? new Date(currentExpiry[0].vipExpiresAt)
: new Date()
await db.update(users).set({
role: 'vip',
vipExpiresAt: new Date(baseDate.getTime() + plan.durationDays * 24 * 60 * 60 * 1000),
}).where(eq(users.id, order.userId))
}
return { code: 'SUCCESS', message: '成功' }
})回调的幂等性
if (!order || order.status === 1) return { code: 'SUCCESS', message: '成功' }为什么已支付的订单直接返回成功? 支付平台可能会多次发送同一笔支付的回调(网络超时重试)。如果第二次回调时重复执行"激活会员"逻辑,会导致 VIP 时间被错误叠加。
幂等性
同一个请求执行一次和执行多次的效果相同。支付回调必须是幂等的,否则重复回调会导致数据异常。
会员续费逻辑
const baseDate = currentExpiry[0]?.vipExpiresAt && new Date(currentExpiry[0].vipExpiresAt) > new Date()
? new Date(currentExpiry[0].vipExpiresAt) // 还在 VIP 期,从过期时间续
: new Date() // 已过期,从现在开始| 当前状态 | baseDate | 新过期时间 | 说明 |
|---|---|---|---|
| 非会员 | new Date() | 今天 + 30 天 | 从现在起算 |
| VIP 未过期 | 原过期时间 | 原过期时间 + 30 天 | 续费叠加,不浪费剩余天数 |
| VIP 已过期 | new Date() | 今天 + 30 天 | 重新开通 |
为什么续费叠加很重要?
如果用户还有 10 天 VIP,再买 30 天套餐,应该得到 40 天 VIP,而不是 30 天。否则用户觉得"浪费了 10 天"。
回调验签(必须实现)
上面代码中 // ... 验证逻辑 是最关键的安全环节。如果不验证签名,攻击者可以伪造回调通知,不付款就激活会员:
// 微信回调验签示例
const headers = getRequestHeaders(event)
const signature = headers['wechatpay-signature']
const timestamp = headers['wechatpay-timestamp']
const nonce = headers['wechatpay-nonce']
// 构造验签串
const message = `${timestamp}\n${nonce}\n${body}\n`
// 使用微信平台证书验证签名
const isValid = pay.verifySign(signature, message)
if (!isValid) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '签名验证失败' })
}订单列表 API
// server/api/orders.get.ts
export default defineEventHandler(async (event) => {
const userId = event.context.userId
const query = getQuery(event)
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 10
const [list, [{ count }]] = await Promise.all([
db.select().from(orders)
.where(eq(orders.userId, userId))
.limit(pageSize).offset((page - 1) * pageSize)
.orderBy(desc(orders.createdAt)),
db.select({ count: sql`count(*)` }).from(orders).where(eq(orders.userId, userId)),
])
return { list, total: Number(count), page, pageSize }
})为什么用 eq(orders.userId, userId) 而不是让前端传 userId?
防止用户查看其他人的订单。userId 从 event.context 获取(认证中间件写入),不可伪造。
常见问题
1. 用户支付了但回调没收到?
- 检查
notify_url是否可从公网访问 - 检查 HTTPS 证书是否有效
- 微信/支付宝有"手动重发通知"功能,可在后台触发
- 建议实现"主动查询"兜底:定时检查
status=0且创建超过 30 分钟的订单,主动查询支付平台
2. 订单超时未支付?
建议添加定时任务,将超时未支付的订单标记为已取消:
// server/tasks/cancel-expired-orders.ts(需配合 Nitro scheduled tasks 或外部 cron)
// 将创建超过 30 分钟且仍为 status=0 的订单标记为 status=2(已取消)3. 金额精度问题?
数据库使用 decimal(10,2) 精确存储,JS 中 Number("29.90") 转换后可能显示为 29.9。前端展示金额时统一格式化:
// 格式化金额
const formatPrice = (price: string | number) => {
return Number(price).toFixed(2)
}