diff --git a/api/modules/order.js b/api/modules/order.js index edbd9e9..c41148c 100644 --- a/api/modules/order.js +++ b/api/modules/order.js @@ -23,5 +23,20 @@ export const orderApi = { method: 'POST', data: { identifier, package_ids, app_type: APP_TYPE } }); + }, + + pay(order_id, payment_method, password) { + const data = { + payment_method, + app_type: APP_TYPE + }; + if (password) { + data.password = password; + } + return request({ + url: `/api/c/v1/orders/${order_id}/pay`, + method: 'POST', + data + }); } }; \ No newline at end of file diff --git a/components/UserInfoCard.vue b/components/UserInfoCard.vue index bdfc8d5..d4f0109 100644 --- a/components/UserInfoCard.vue +++ b/components/UserInfoCard.vue @@ -8,7 +8,8 @@ {{ currentCardNo || '-' }} - IMEI:{{ deviceInfo.imei || '-' }} + IMEI:{{ deviceInfo.imei || '-' }} + 手机号:{{ deviceInfo.bound_phone || '-' }} {{ onlineStatus }} diff --git a/docs/微信支付集成文档.md b/docs/微信支付集成文档.md new file mode 100644 index 0000000..1d6f6a2 --- /dev/null +++ b/docs/微信支付集成文档.md @@ -0,0 +1,626 @@ +# 微信小程序支付集成文档 + +## 概述 + +本文档总结了套餐订购模块中的微信支付完整实现流程,可复用到其他项目中。 + +## 一、核心文件结构 + +``` +miniprogram/ +├── utils/ +│ └── payment.ts # 微信支付工具类 +├── api/ +│ └── order.ts # 订单API接口 +├── types/ +│ └── api.ts # API类型定义 +└── pages/ + └── package/ + └── package.ts # 套餐订购页面(业务实现) +``` + +## 二、支付流程 + +### 2.1 标准套餐支付流程(三步走) + +``` +1. 创建订单 → 2. 获取支付参数 → 3. 调起微信支付 +``` + +#### 第一步:创建订单 + +**接口:** `POST /api/c/v1/orders/create` + +**请求参数:** +```typescript +{ + app_type: 'miniapp', // 应用类型:miniapp(小程序) | official_account(公众号) + identifier: string, // 资产标识符(设备ID/ICCID等) + package_ids: number[] // 套餐ID列表 +} +``` + +**响应数据:** +```typescript +{ + order_type: 'package' | 'recharge', // 订单类型 + order: { + order_id: number, // 订单ID(用于后续支付) + order_no: string, + payment_status: 1, // 1:待支付 + total_amount: number, // 总金额(分) + created_at: string + }, + pay_config?: {...} // 强充订单才返回(见2.2) +} +``` + +#### 第二步:获取支付参数 + +**接口:** `POST /api/c/v1/orders/:id/pay` + +**请求参数:** +```typescript +{ + payment_method: 'wechat', // 支付方式:wechat | wallet + app_type: 'miniapp' // 应用类型(微信支付必传) +} +``` + +**响应数据:** +```typescript +{ + payment_method: 'wechat', + pay_config: { + app_id: string, // 微信AppID + timestamp: string, // 时间戳 + nonce_str: string, // 随机字符串 + package: string, // 预支付ID,格式:prepay_id=wx... + sign_type: string, // 签名类型(RSA/MD5/HMAC-SHA256) + pay_sign: string // 支付签名 + } +} +``` + +#### 第三步:调起微信支付 + +使用 `wx.requestPayment()` 调起支付: + +```typescript +wx.requestPayment({ + timeStamp: pay_config.timestamp, + nonceStr: pay_config.nonce_str, + package: pay_config.package, + signType: pay_config.sign_type, + paySign: pay_config.pay_sign, + success: (res) => { + // 支付成功 + }, + fail: (err) => { + // 支付失败或取消 + } +}) +``` + +### 2.2 强充订单支付流程(一步走) + +当订单返回 `order_type === 'recharge'` 且有 `pay_config` 时,表示强充订单: + +``` +创建订单(直接返回支付参数) → 调起微信支付 +``` + +**特点:** +- 创建订单时已返回完整 `pay_config` +- 无需再调用 `/orders/:id/pay` 接口 +- 直接使用返回的 `pay_config` 调起支付 + +**判断逻辑:** +```typescript +if (orderResult.order_type === 'recharge' && orderResult.pay_config) { + // 强充订单,直接支付 + await WxPayment.payWithCallback(orderResult.pay_config, callbacks); +} else { + // 普通订单,需要获取支付参数 + const payResult = await OrderApi.payOrder(orderId, {...}); + await WxPayment.payWithCallback(payResult.pay_config, callbacks); +} +``` + +## 三、核心代码实现 + +### 3.1 微信支付工具类(payment.ts) + +```typescript +/** + * 微信支付工具类 + */ +import { Logger } from './logger'; + +/** 微信支付配置 */ +export interface WxPayConfig { + timeStamp: string; + nonceStr: string; + package: string; + signType: 'RSA' | 'MD5' | 'HMAC-SHA256'; + paySign: string; +} + +/** 支付结果 */ +export interface PaymentResult { + success: boolean; + errMsg?: string; + userCancelled?: boolean; +} + +export class WxPayment { + /** + * 发起微信支付 + * @param payConfig 后端返回的支付配置 + * @returns 支付结果 + */ + static async pay(payConfig: { + app_id: string; + timestamp: string; + nonce_str: string; + package: string; + sign_type: string; + pay_sign: string; + }): Promise { + return new Promise((resolve) => { + // 1. 验证 package 参数格式 + if (!payConfig.package || !payConfig.package.startsWith('prepay_id=')) { + Logger.error('微信支付参数错误:package 格式不正确', { + package: payConfig.package, + expected: 'prepay_id=wx...' + }); + resolve({ + success: false, + errMsg: '支付参数获取失败,请稍后重试' + }); + return; + } + + Logger.info('发起微信支付', { + ...payConfig, + pay_sign: payConfig.pay_sign.substring(0, 10) + '...' // 脱敏 + }); + + // 2. 调用微信支付API + wx.requestPayment({ + timeStamp: payConfig.timestamp, + nonceStr: payConfig.nonce_str, + package: payConfig.package, + signType: payConfig.sign_type as any, + paySign: payConfig.pay_sign, + success: (res) => { + Logger.info('微信支付成功', res); + resolve({ success: true }); + }, + fail: (err) => { + Logger.error('微信支付失败', err); + + // 判断是否为用户取消 + const userCancelled = err.errMsg?.includes('cancel') || + err.errMsg?.includes('用户取消'); + + resolve({ + success: false, + errMsg: err.errMsg, + userCancelled + }); + } + }); + }); + } + + /** + * 支付并使用回调处理结果 + * @param payConfig 支付配置 + * @param callbacks 回调函数 + */ + static async payWithCallback( + payConfig: { + app_id: string; + timestamp: string; + nonce_str: string; + package: string; + sign_type: string; + pay_sign: string; + }, + callbacks?: { + onSuccess?: () => void; + onFail?: (errMsg: string) => void; + onCancel?: () => void; + } + ): Promise { + const result = await this.pay(payConfig); + + // 根据结果调用相应回调 + if (result.success) { + callbacks?.onSuccess?.(); + } else if (result.userCancelled) { + callbacks?.onCancel?.(); + } else { + callbacks?.onFail?.(result.errMsg || '支付失败'); + } + + return result; + } +} +``` + +### 3.2 订单API封装(order.ts) + +```typescript +import { Request } from '../utils/request'; +import type { + CreateOrderRequest, + CreateOrderResponse, + PayOrderRequest, + PayOrderResponse +} from '../types/api'; + +export class OrderApi { + /** + * 创建订单 + * POST /api/c/v1/orders/create + */ + static async createOrder(data: CreateOrderRequest): Promise { + return Request.post( + '/api/c/v1/orders/create', + data + ); + } + + /** + * 订单支付 + * POST /api/c/v1/orders/:id/pay + */ + static async payOrder(orderId: number, data: PayOrderRequest): Promise { + return Request.post( + `/api/c/v1/orders/${orderId}/pay`, + data + ); + } +} +``` + +### 3.3 业务层实现示例(package.ts) + +```typescript +import { OrderApi } from '../../api/order'; +import { WxPayment } from '../../utils/payment'; +import { Logger } from '../../utils/logger'; + +/** + * 创建订单并支付(微信支付) + */ +async createOrderAndPay() { + try { + const selectedPackage = this.data.packages[this.data.selectedIdx]; + + // 第一步:创建订单 + wx.showLoading({ title: '创建订单...', mask: true }); + + const orderResult = await OrderApi.createOrder({ + app_type: 'miniapp', + identifier: this.getIdentifier(), + package_ids: [selectedPackage.package_id] + }); + + // 判断是否为强充订单 + if (orderResult.order_type === 'recharge' && orderResult.pay_config) { + // 强充订单:直接使用返回的 pay_config 支付 + + // 验证 pay_config 有效性 + if (!orderResult.pay_config.package) { + Logger.error('强充订单支付配置无效', { + recharge_id: orderResult.recharge?.recharge_id + }); + wx.hideLoading(); + wx.showToast({ title: '支付参数获取失败', icon: 'none' }); + return; + } + + wx.hideLoading(); + this.closeModal(); + + // 调起支付 + await WxPayment.payWithCallback( + orderResult.pay_config, + { + onSuccess: () => { + this.handlePaySuccess(); + }, + onFail: (errMsg) => { + wx.showToast({ title: '支付失败', icon: 'none' }); + }, + onCancel: () => { + wx.showToast({ title: '已取消支付', icon: 'none' }); + } + } + ); + return; + } + + // 普通套餐订单:需要获取支付参数 + const orderId = orderResult.order.order_id; + + // 第二步:获取支付参数 + wx.showLoading({ title: '准备支付...', mask: true }); + + const payResult = await OrderApi.payOrder(orderId, { + payment_method: 'wechat', + app_type: 'miniapp' + }); + + Logger.info('订单支付接口返回:', { + order_id: orderId, + has_pay_config: !!payResult.pay_config + }); + + wx.hideLoading(); + this.closeModal(); + + // 验证 pay_config + if (!payResult.pay_config || !payResult.pay_config.package) { + Logger.error('订单支付接口返回配置无效', { + order_id: orderId + }); + wx.showToast({ title: '支付参数获取失败', icon: 'none' }); + return; + } + + // 第三步:调起微信支付 + await WxPayment.payWithCallback( + payResult.pay_config, + { + onSuccess: () => { + this.handlePaySuccess(); + }, + onFail: (errMsg) => { + wx.showToast({ title: '支付失败', icon: 'none' }); + }, + onCancel: () => { + wx.showToast({ title: '已取消支付', icon: 'none' }); + } + } + ); + + } catch (error: any) { + wx.hideLoading(); + this.closeModal(); + + Logger.error('创建订单或支付失败:', error); + + // 特殊错误处理:订单正在创建中 + if (error.code === 1008) { + wx.showModal({ + title: '提示', + content: '订单正在创建中,是否跳转到我的订单页面进行支付?', + confirmText: '去支付', + success: (res) => { + if (res.confirm) { + wx.navigateTo({ url: '/pages/orders/orders' }); + } + } + }); + return; + } + + // 其他错误提示 + const errorMsg = error.message || error.msg || '操作失败,请稍后重试'; + wx.showToast({ title: errorMsg, icon: 'none' }); + } +} + +/** + * 支付成功处理 + */ +handlePaySuccess() { + const pkg = this.data.packages[this.data.selectedIdx]; + this.setData({ selectedIdx: -1 }); + wx.showToast({ + title: `「${pkg.name}」支付成功`, + icon: 'success' + }); + // 可选:刷新数据、跳转页面等 +} +``` + +## 四、类型定义(api.ts) + +```typescript +/** 创建订单 - 请求 */ +export interface CreateOrderRequest { + app_type?: 'official_account' | 'miniapp'; // 应用类型(强充必传) + identifier: string; // 资产标识符 + package_ids: number[]; // 套餐ID列表 +} + +/** 创建订单 - 响应 */ +export interface CreateOrderResponse { + order_type: 'package' | 'recharge'; // 订单类型 + order: { + order_id: number; + order_no: string; + payment_status: 1 | 2 | 3 | 4; // 1:待支付 2:已支付 3:已取消 4:已退款 + total_amount: number; // 总金额(分) + created_at: string; + }; + pay_config?: { // 强充订单才返回 + app_id: string; + timestamp: string; + nonce_str: string; + package: string; // prepay_id=wx... + sign_type: string; + pay_sign: string; + } | null; + recharge?: { // 充值订单信息 + recharge_id: number; + recharge_no: string; + amount: number; + status: 0 | 1 | 2; // 0:待支付 1:已支付 2:已关闭 + auto_purchase_status: string; + }; + linked_package_info?: { // 强充关联套餐信息 + package_names: string[]; + total_package_amount: number; + wallet_credit: number; + force_recharge_amount: number; + }; +} + +/** 订单支付 - 请求 */ +export interface PayOrderRequest { + payment_method: 'wallet' | 'wechat'; // 支付方式 + app_type?: 'official_account' | 'miniapp'; // 微信支付必传 + password?: string; // 钱包支付必传 +} + +/** 订单支付 - 响应 */ +export interface PayOrderResponse { + payment_method: 'wallet' | 'wechat'; + pay_config?: { // 微信支付时返回 + app_id: string; + timestamp: string; + nonce_str: string; + package: string; + sign_type: string; + pay_sign: string; + }; +} +``` + +## 五、关键要点 + +### 5.1 支付参数验证 + +**必须验证 `package` 参数格式:** +```typescript +if (!payConfig.package || !payConfig.package.startsWith('prepay_id=')) { + // 参数无效,停止支付 + return; +} +``` + +### 5.2 支付结果判断 + +```typescript +// 成功 +success: (res) => { /* 支付成功 */ } + +// 失败/取消 +fail: (err) => { + // 判断是否用户取消 + const userCancelled = err.errMsg?.includes('cancel') || + err.errMsg?.includes('用户取消'); + + if (userCancelled) { + // 用户主动取消 + } else { + // 支付失败 + } +} +``` + +### 5.3 错误处理 + +1. **参数验证失败** - 提示用户并停止支付 +2. **用户取消支付** - 友好提示,不作为错误 +3. **支付失败** - 提示错误信息 +4. **订单创建中(1008)** - 引导用户到订单页 + +### 5.4 日志记录 + +关键节点必须记录日志: +- 支付参数(脱敏) +- 支付成功/失败 +- 参数验证失败 +- 异常情况 + +```typescript +Logger.info('发起微信支付', { + ...payConfig, + pay_sign: payConfig.pay_sign.substring(0, 10) + '...' // 脱敏 +}); +``` + +### 5.5 Loading状态管理 + +```typescript +// 创建订单前 +wx.showLoading({ title: '创建订单...', mask: true }); + +// 获取支付参数前 +wx.showLoading({ title: '准备支付...', mask: true }); + +// 调起支付前 +wx.hideLoading(); // 必须关闭,否则遮挡支付界面 +``` + +## 六、常见问题 + +### Q1: 为什么有两种支付流程? + +**A:** +- **标准流程(三步)**:普通套餐订购 +- **快捷流程(一步)**:强充订单,后端已预创建支付参数 + +### Q2: package 参数为什么要验证? + +**A:** 后端可能返回空值或格式错误,直接传给微信会导致支付崩溃。必须验证格式为 `prepay_id=wx...` + +### Q3: 如何区分用户取消和支付失败? + +**A:** 检查 `errMsg` 是否包含 `'cancel'` 或 `'用户取消'` 关键字。 + +### Q4: 为什么调起支付前要 hideLoading? + +**A:** Loading 遮罩会挡住微信支付界面,导致用户无法操作。 + +## 七、快速集成清单 + +要在新项目中集成微信支付,需要: + +- [ ] 复制 `utils/payment.ts` 工具类 +- [ ] 创建订单API接口(`createOrder`、`payOrder`) +- [ ] 定义类型文件(`api.ts`) +- [ ] 实现业务逻辑(参考 `createOrderAndPay`) +- [ ] 添加 Logger 日志工具(可选) +- [ ] 处理支付回调(成功、失败、取消) +- [ ] 添加错误处理逻辑 + +## 八、注意事项 + +1. **安全性** + - 日志中敏感信息(pay_sign)要脱敏 + - 永远不要在前端生成支付签名 + - 所有支付参数由后端返回 + +2. **用户体验** + - 支付过程中显示 Loading 提示 + - 调起支付前必须关闭 Loading + - 区分取消和失败,给出不同提示 + - 支付失败时保留订单,允许用户重试 + +3. **异常处理** + - 参数验证失败时友好提示 + - 网络错误时允许重试 + - 订单创建中(1008)引导到订单页 + - 所有异常都要记录日志 + +4. **测试要点** + - 正常支付流程 + - 用户取消支付 + - 网络异常 + - 参数异常 + - 强充订单流程 + - 普通订单流程 + +--- + +**文档版本:** v1.0 +**更新日期:** 2026-04-14 +**适用范围:** 微信小程序支付 diff --git a/pages/package-order/package-order.vue b/pages/package-order/package-order.vue index 7a22c6e..1fe71a6 100644 --- a/pages/package-order/package-order.vue +++ b/pages/package-order/package-order.vue @@ -24,18 +24,58 @@ - - - 套餐名称:{{ currentPackage?.package_name }} - 价格:¥{{ formatMoney(currentPackage?.retail_price) }} + + + + + 选择支付方式 + × + + + + + 套餐名称 + {{ currentPackage?.package_name }} + + + 支付金额 + ¥{{ formatMoney(currentPackage?.retail_price) }} + + + + + + + 💚 + 微信支付 + + + + + + + 💰 + + 账户余额 + 余额: ¥{{ formatMoney(walletBalance) }} + + + + + + + + + + - + @@ -128,4 +273,182 @@ .btn { margin-top: var(--space-sm); } } } + + .payment-popup { + width: 600rpx; + background: var(--bg-primary); + border-radius: var(--radius-large); + overflow: hidden; + + .popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 30rpx; + border-bottom: 1rpx solid var(--border-light); + + .popup-title { + font-size: 32rpx; + font-weight: 600; + color: var(--text-primary); + } + + .popup-close { + width: 48rpx; + height: 48rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + color: var(--text-tertiary); + cursor: pointer; + } + } + + .package-summary { + padding: 30rpx; + background: var(--gray-100); + border-bottom: 1rpx solid var(--border-light); + + .summary-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; + + &:last-child { + margin-bottom: 0; + } + + .label { + font-size: 26rpx; + color: var(--text-secondary); + } + + .value { + font-size: 26rpx; + color: var(--text-primary); + + &.price { + font-size: 32rpx; + font-weight: 700; + color: var(--warning); + } + } + } + } + + .payment-methods { + padding: 30rpx; + + .method-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24rpx; + margin-bottom: 20rpx; + border: 2rpx solid var(--border-light); + border-radius: var(--radius-medium); + cursor: pointer; + transition: all 0.3s; + + &:last-child { + margin-bottom: 0; + } + + &.active { + border-color: var(--primary); + background: rgba(0, 122, 255, 0.05); + } + + .method-left { + display: flex; + align-items: center; + gap: 20rpx; + + .method-icon { + width: 64rpx; + height: 64rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + border-radius: var(--radius-medium); + background: var(--gray-100); + } + + .method-info { + display: flex; + flex-direction: column; + gap: 8rpx; + } + + .method-name { + font-size: 28rpx; + font-weight: 500; + color: var(--text-primary); + } + + .wallet-balance { + font-size: 22rpx; + color: var(--text-tertiary); + } + } + + .method-radio { + width: 36rpx; + height: 36rpx; + border: 2rpx solid var(--gray-400); + border-radius: 50%; + position: relative; + transition: all 0.3s; + + &.checked { + border-color: var(--primary); + background: var(--primary); + + &::after { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: 20rpx; + font-weight: 700; + } + } + } + } + } + + .popup-footer { + display: flex; + gap: 20rpx; + padding: 30rpx; + border-top: 1rpx solid var(--border-light); + + .btn-apple { + flex: 1; + height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-medium); + font-size: 28rpx; + font-weight: 500; + border: none; + + &.btn-secondary { + background: var(--gray-200); + color: var(--text-primary); + } + + &.btn-primary { + background: var(--primary); + color: #fff; + } + } + } + } \ No newline at end of file