Skip to content

会员订阅模块

支付流程总览

text
┌─────── 用户购买流程 ────────┐
│                              │
│  1. 用户选择套餐             │
│  2. 选择支付方式             │
│  3. 创建订单(status=0)     │
│  4. 调用支付平台下单         │
│  5. 返回支付参数给客户端     │
│                              │
├─────── 用户支付 ─────────────┤
│                              │
│  Flutter 调起微信/支付宝     │
│  → 用户完成支付              │
│                              │
├─────── 支付回调 ─────────────┤
│                              │
│  6. 支付平台通知服务端       │
│  7. 验证签名(防伪造)       │
│  8. 更新订单状态(status=1) │
│  9. 激活会员                 │
│                              │
└──────────────────────────────┘

为什么需要"创建订单"和"支付回调"两步?

用户可能创建了订单但不支付(放弃、超时)。两步分离确保了:订单记录存在(用户可以查看历史)、支付成功后才激活会员(不会白白开通)。

套餐列表 API

ts
// 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

ts
// 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 }
})

金额安全——为什么从服务端读取价格?

ts
// ✅ 安全:从服务端数据库读取套餐价格
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 分钱。

订单号设计

ts
const orderNo = `ORD${Date.now()}${nanoid(6)}`
// 示例:ORD1703123456789abc123
组成部分作用说明
ORD 前缀可读性一眼看出是订单号,不是流水号
Date.now()时间排序13 位时间戳,按时间递增
nanoid(6)唯一性防止同一毫秒重复,6 位 = 56 亿种组合

为什么不用 UUID?

UUID 太长(36 字符),不适合作为用户可见的订单号。ORD + 时间戳 + nanoid 更短(约 25 字符)且仍然唯一。

防止重复下单

用户可能快速点击两次"购买"按钮,导致创建两个订单。解决方案:

ts
// 检查是否有未支付的相同套餐订单
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 }
}

微信支付集成

ts
// 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
}

金额单位转换

ts
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,可能丢失精度。

支付宝集成

ts
// 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 设计差异,注意不要混淆。

微信支付回调

ts
// 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: '成功' }
})

回调的幂等性

ts
if (!order || order.status === 1) return { code: 'SUCCESS', message: '成功' }

为什么已支付的订单直接返回成功? 支付平台可能会多次发送同一笔支付的回调(网络超时重试)。如果第二次回调时重复执行"激活会员"逻辑,会导致 VIP 时间被错误叠加。

幂等性

同一个请求执行一次和执行多次的效果相同。支付回调必须是幂等的,否则重复回调会导致数据异常。

会员续费逻辑

ts
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 天"。

回调验签(必须实现)

上面代码中 // ... 验证逻辑 是最关键的安全环节。如果不验证签名,攻击者可以伪造回调通知,不付款就激活会员:

ts
// 微信回调验签示例
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

ts
// 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

防止用户查看其他人的订单。userIdevent.context 获取(认证中间件写入),不可伪造。

常见问题

1. 用户支付了但回调没收到?

  • 检查 notify_url 是否可从公网访问
  • 检查 HTTPS 证书是否有效
  • 微信/支付宝有"手动重发通知"功能,可在后台触发
  • 建议实现"主动查询"兜底:定时检查 status=0 且创建超过 30 分钟的订单,主动查询支付平台

2. 订单超时未支付?

建议添加定时任务,将超时未支付的订单标记为已取消:

ts
// 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。前端展示金额时统一格式化:

ts
// 格式化金额
const formatPrice = (price: string | number) => {
  return Number(price).toFixed(2)
}

基于 Nuxt 4 官方文档整理编写