All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
369 lines
14 KiB
Markdown
369 lines
14 KiB
Markdown
# 微信公众号与微信支付集成 - 技术设计
|
||
|
||
## Context
|
||
|
||
当前系统已具备完整的个人客户体系(JWT 认证、手机号登录)和订单支付系统(订单模型、钱包支付、支付回调幂等处理),但缺少微信公众号和微信支付的真实 SDK 集成。
|
||
|
||
**现有基础设施**:
|
||
- ✅ 数据模型:`tb_personal_customer` 包含 `wx_open_id`、`wx_union_id` 字段(已建索引)
|
||
- ✅ 接口定义:`pkg/wechat/wechat.go` 定义了 `Service` 接口(当前为 Mock 实现)
|
||
- ✅ 支付回调:`POST /api/callback/wechat-pay` 已预留(基础参数验证,缺签名校验)
|
||
- ✅ 订单系统:完整的订单创建、支付状态更新、套餐激活流程(幂等设计)
|
||
|
||
**集成目标**:
|
||
- 使用 PowerWeChat v3 SDK 对接微信公众号和微信支付 API
|
||
- 支持个人客户通过微信 OAuth 登录/绑定
|
||
- 支持两种支付场景:JSAPI 支付(微信内)、H5 支付(浏览器)
|
||
- 补充支付回调的签名验证(PowerWeChat 自动处理)
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
1. 实现微信公众号 OAuth 2.0 授权流程,获取用户 OpenID/UnionID 和基本信息
|
||
2. 实现 H5 支付和 JSAPI 支付的订单创建和支付参数生成
|
||
3. 补充支付回调的签名验证,确保回调来源合法
|
||
4. 集成 Redis 缓存实现微信 Access Token 中控(多实例共享)
|
||
5. 配置管理遵循项目规范(Viper + 环境变量)
|
||
6. 完整的错误处理和日志记录
|
||
|
||
**Non-Goals:**
|
||
- 不实现微信模板消息、客服消息等公众号其他能力(按需后续扩展)
|
||
- 不实现微信 Native 支付(扫码支付)和 App 支付(当前无此场景)
|
||
- 不修改现有订单模型和支付流程(在现有基础上扩展)
|
||
- 不实现微信退款功能(后续单独实现)
|
||
|
||
## Decisions
|
||
|
||
### 决策 1:SDK 选型 - PowerWeChat v3
|
||
|
||
**选择理由**:
|
||
- ✅ 官方文档推荐,Go 生态成熟度高
|
||
- ✅ 支持所有公众号和支付 API(包括 H5、JSAPI、Native、App 支付)
|
||
- ✅ 内置签名验证、Token 中控、日志集成
|
||
- ✅ 支持 Redis 缓存(与项目现有 Redis 无缝集成)
|
||
- ✅ 活跃维护,GitHub 2.5k+ stars
|
||
|
||
**替代方案**:
|
||
- `silenceper/wechat`:功能相似,但文档较少,社区活跃度较低
|
||
- 自行封装微信 API:工作量大,维护成本高,签名验证易出错
|
||
|
||
### 决策 2:架构设计 - 遵循项目分层
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ pkg/wechat/ │
|
||
│ ├─ service.go 微信服务接口定义 │
|
||
│ ├─ official_account.go OfficialAccount 实现(OAuth) │
|
||
│ ├─ payment.go Payment 实现(H5/JSAPI 支付) │
|
||
│ └─ config.go PowerWeChat 实例初始化 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
↓ 依赖注入
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ internal/service/ │
|
||
│ ├─ personal_customer/service.go 调用 wechat.Service │
|
||
│ └─ order/service.go 调用 wechat.Payment │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
↓
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ internal/handler/ │
|
||
│ ├─ app/personal_customer.go OAuth 登录端点 │
|
||
│ ├─ h5/order.go 支付发起端点 │
|
||
│ └─ callback/payment.go 支付回调端点 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**依赖注入方式**:
|
||
- `pkg/wechat.Service` 在 `internal/bootstrap/services.go` 中初始化
|
||
- 注入到 `PersonalCustomerService` 和 `OrderService`
|
||
- Handler 通过 Service 调用微信能力
|
||
|
||
**选择理由**:
|
||
- ✅ 符合项目 Handler → Service → Store → Model 分层
|
||
- ✅ `pkg/wechat/` 作为基础设施层,独立于业务逻辑
|
||
- ✅ 通过接口隔离,便于测试(Mock 实现)
|
||
|
||
### 决策 3:配置管理 - Viper + 环境变量
|
||
|
||
**配置结构**:
|
||
```yaml
|
||
wechat:
|
||
official_account:
|
||
app_id: "" # JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID
|
||
app_secret: "" # JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET
|
||
token: "" # JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN
|
||
aes_key: "" # JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY
|
||
oauth_redirect_url: "" # JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL
|
||
|
||
payment:
|
||
app_id: "" # JUNHONG_WECHAT_PAYMENT_APP_ID
|
||
mch_id: "" # JUNHONG_WECHAT_PAYMENT_MCH_ID
|
||
api_v3_key: "" # JUNHONG_WECHAT_PAYMENT_API_V3_KEY
|
||
api_v2_key: "" # JUNHONG_WECHAT_PAYMENT_API_V2_KEY
|
||
cert_path: "" # JUNHONG_WECHAT_PAYMENT_CERT_PATH
|
||
key_path: "" # JUNHONG_WECHAT_PAYMENT_KEY_PATH
|
||
serial_no: "" # JUNHONG_WECHAT_PAYMENT_SERIAL_NO
|
||
notify_url: "" # JUNHONG_WECHAT_PAYMENT_NOTIFY_URL
|
||
```
|
||
|
||
**选择理由**:
|
||
- ✅ 遵循项目现有配置管理模式
|
||
- ✅ 敏感信息通过环境变量覆盖,不提交代码库
|
||
- ✅ Docker 部署无需挂载配置文件
|
||
|
||
**证书管理**:
|
||
- 证书文件路径通过环境变量配置(如 `/app/certs/apiclient_cert.pem`)
|
||
- Docker 部署时通过 Volume 挂载证书目录
|
||
- 启动时验证证书文件存在性,缺失则报错退出
|
||
|
||
### 决策 4:支付场景识别 - 客户端传参
|
||
|
||
```go
|
||
// JSAPI 支付请求
|
||
type WechatPayJSAPIRequest struct {
|
||
OpenID string `json:"openid" validate:"required"` // 用户 OpenID
|
||
}
|
||
|
||
// H5 支付请求
|
||
type WechatPayH5Request struct {
|
||
SceneInfo WechatH5SceneInfo `json:"scene_info"`
|
||
}
|
||
|
||
type WechatH5SceneInfo struct {
|
||
PayerClientIP string `json:"payer_client_ip" validate:"required"` // 用户终端 IP
|
||
H5Info struct {
|
||
Type string `json:"type"` // 场景类型(iOS, Android, Wap)
|
||
} `json:"h5_info"`
|
||
}
|
||
```
|
||
|
||
**选择理由**:
|
||
- ✅ 不在后端判断场景(User-Agent 不可靠)
|
||
- ✅ 前端明确调用对应端点:
|
||
- `/api/h5/orders/:id/wechat-pay/jsapi`(微信内)
|
||
- `/api/h5/orders/:id/wechat-pay/h5`(浏览器)
|
||
|
||
### 决策 5:支付回调处理 - 补充签名验证
|
||
|
||
**现有实现**:
|
||
```go
|
||
// internal/handler/callback/payment.go
|
||
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
|
||
var req WechatPayCallbackRequest
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||
}
|
||
|
||
// 调用 Service 处理支付(已实现幂等)
|
||
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodWechat); err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.Success(c, map[string]string{"return_code": "SUCCESS"})
|
||
}
|
||
```
|
||
|
||
**增强设计**:
|
||
```go
|
||
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
|
||
// 1. PowerWeChat 自动处理签名验证
|
||
res, err := h.wechatPayment.HandlePaidNotify(
|
||
c.Request(),
|
||
func(message *request.RequestNotify, transaction *models.Transaction, fail func(string)) interface{} {
|
||
// 2. 检查事件类型
|
||
if message.EventType != "TRANSACTION.SUCCESS" {
|
||
return true
|
||
}
|
||
|
||
// 3. 调用现有 Service(幂等处理)
|
||
orderNo := *transaction.OutTradeNo
|
||
err := h.orderService.HandlePaymentCallback(ctx, orderNo, model.PaymentMethodWechat)
|
||
if err != nil {
|
||
fail("payment processing failed")
|
||
return nil
|
||
}
|
||
|
||
return true
|
||
},
|
||
)
|
||
|
||
// 4. PowerWeChat 自动回复微信
|
||
return res.Write(c.Writer)
|
||
}
|
||
```
|
||
|
||
**选择理由**:
|
||
- ✅ PowerWeChat 自动验证签名(无需手动实现复杂的验签逻辑)
|
||
- ✅ 保留现有 `HandlePaymentCallback` 的幂等设计
|
||
- ✅ 统一错误处理和日志记录
|
||
|
||
### 决策 6:Token 中控 - Redis 缓存
|
||
|
||
**实现方式**:
|
||
```go
|
||
import "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
||
|
||
cache := kernel.NewRedisClient(&kernel.UniversalOptions{
|
||
Addrs: []string{config.Redis.Address},
|
||
Password: config.Redis.Password,
|
||
DB: config.Redis.DB,
|
||
})
|
||
|
||
officialAccountApp, err := officialAccount.NewOfficialAccount(&officialAccount.UserConfig{
|
||
AppID: config.Wechat.OfficialAccount.AppID,
|
||
Secret: config.Wechat.OfficialAccount.AppSecret,
|
||
Cache: cache, // 共享 Redis 实例
|
||
})
|
||
```
|
||
|
||
**Cache Key 格式**:
|
||
```
|
||
powerwechat.access_token.{MD5(appid+secret)}
|
||
```
|
||
|
||
**选择理由**:
|
||
- ✅ 多实例共享 Access Token,避免重复获取(每日限额 2000 次)
|
||
- ✅ 使用项目现有 Redis 实例,无需额外部署
|
||
- ✅ Token 过期自动刷新(PowerWeChat 内置处理)
|
||
|
||
### 决策 7:错误处理 - 统一错误码
|
||
|
||
**新增错误码**(`pkg/errors/codes.go`):
|
||
```go
|
||
// 微信相关错误码(1040-1049)
|
||
CodeWechatOAuthFailed = 1040 // 微信 OAuth 授权失败
|
||
CodeWechatUserInfoFailed = 1041 // 获取微信用户信息失败
|
||
CodeWechatPayFailed = 1042 // 微信支付发起失败
|
||
CodeWechatCallbackInvalid = 1043 // 微信回调签名验证失败
|
||
```
|
||
|
||
**错误消息格式**:
|
||
```go
|
||
return errors.Wrap(CodeWechatOAuthFailed, err, "微信授权失败,请重试")
|
||
```
|
||
|
||
**选择理由**:
|
||
- ✅ 符合项目错误处理规范(`pkg/errors/`)
|
||
- ✅ 客户端可根据错误码区分微信相关错误
|
||
- ✅ 敏感信息不对外暴露(详细错误写日志)
|
||
|
||
## Risks / Trade-offs
|
||
|
||
### 风险 1:微信 API 调用失败
|
||
|
||
**场景**:网络超时、微信服务异常、配置错误
|
||
|
||
**缓解措施**:
|
||
- 设置合理的 HTTP 超时(30 秒)
|
||
- 记录完整的请求/响应日志(`HttpDebug: true` 在测试环境)
|
||
- 错误消息包含 Request ID 便于排查
|
||
- 支付失败时订单状态保持 `pending`,用户可重试
|
||
|
||
### 风险 2:证书文件管理
|
||
|
||
**场景**:证书过期、文件路径错误、权限问题
|
||
|
||
**缓解措施**:
|
||
- 启动时验证证书文件可读性,不通过则退出
|
||
- 证书序列号配置错误时 PowerWeChat 会报错
|
||
- 文档中说明证书获取和更新流程
|
||
- 使用 Docker Secrets 或 Volume 挂载证书(避免镜像包含证书)
|
||
|
||
### 风险 3:支付回调重复通知
|
||
|
||
**场景**:微信可能多次发送同一支付成功通知
|
||
|
||
**缓解措施**:
|
||
- ✅ 现有 `HandlePaymentCallback` 已实现幂等(条件更新:`WHERE id = ? AND payment_status = ?`)
|
||
- ✅ 已支付订单返回成功,不报错
|
||
- 无需额外处理
|
||
|
||
### 风险 4:Access Token 缓存失效
|
||
|
||
**场景**:Redis 重启、缓存过期、网络问题
|
||
|
||
**缓解措施**:
|
||
- PowerWeChat 自动重新获取 Token(失败时重试)
|
||
- Redis 持久化配置确保重启后数据不丢失
|
||
- Token 获取失败记录错误日志
|
||
|
||
### 权衡 1:配置复杂度 vs 安全性
|
||
|
||
**权衡**:微信支付需要大量配置项(AppID、商户号、密钥、证书等)
|
||
|
||
**决策**:优先保证安全性
|
||
- 敏感信息全部通过环境变量配置
|
||
- 证书文件路径可配置,不硬编码
|
||
- 提供完整的配置文档和示例
|
||
|
||
### 权衡 2:功能完整性 vs 实现成本
|
||
|
||
**权衡**:微信支付支持多种场景(Native、App、小程序等)
|
||
|
||
**决策**:仅实现当前需求(H5 + JSAPI)
|
||
- Native、App 支付后续按需扩展
|
||
- 架构设计预留扩展性(新增支付类型只需加端点)
|
||
|
||
## Migration Plan
|
||
|
||
### 部署步骤
|
||
|
||
1. **配置环境变量**
|
||
```bash
|
||
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID="wx..."
|
||
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET="..."
|
||
export JUNHONG_WECHAT_PAYMENT_MCH_ID="..."
|
||
export JUNHONG_WECHAT_PAYMENT_API_V3_KEY="..."
|
||
export JUNHONG_WECHAT_PAYMENT_CERT_PATH="/app/certs/apiclient_cert.pem"
|
||
export JUNHONG_WECHAT_PAYMENT_KEY_PATH="/app/certs/apiclient_key.pem"
|
||
export JUNHONG_WECHAT_PAYMENT_SERIAL_NO="..."
|
||
export JUNHONG_WECHAT_PAYMENT_NOTIFY_URL="https://api.example.com/api/callback/wechat-pay"
|
||
```
|
||
|
||
2. **挂载证书文件**(Docker)
|
||
```yaml
|
||
volumes:
|
||
- ./wechat-certs:/app/certs:ro
|
||
```
|
||
|
||
3. **验证配置**
|
||
- 启动服务,检查日志无配置错误
|
||
- 调用健康检查端点(可选:新增 `/api/health/wechat` 验证微信 API 可达性)
|
||
|
||
4. **微信后台配置**
|
||
- 公众号后台:设置 OAuth 回调域名
|
||
- 商户平台:设置支付回调 URL 白名单
|
||
|
||
5. **灰度测试**
|
||
- 小范围用户测试微信登录和支付
|
||
- 验证支付回调正常触发
|
||
|
||
### 回滚策略
|
||
|
||
- 配置错误:修改环境变量重启即可
|
||
- 功能异常:移除微信支付选项,用户使用钱包支付
|
||
- 数据库:无数据库变更,无需回滚
|
||
|
||
### 监控指标
|
||
|
||
- 微信 OAuth 成功率/失败率
|
||
- 支付发起成功率/失败率
|
||
- 支付回调接收数量/验证失败数量
|
||
- Access Token 获取次数(监控是否频繁刷新)
|
||
|
||
## Open Questions
|
||
|
||
1. **证书更新流程**:商户证书每年需更新,是否需要热加载机制?
|
||
- 暂定:手动更新证书文件后重启服务(证书过期前提前通知)
|
||
|
||
2. **微信用户信息更新**:用户在微信更新昵称/头像后,系统如何同步?
|
||
- 暂定:每次 OAuth 登录时更新用户信息
|
||
- 后续可考虑定期同步任务
|
||
|
||
3. **支付超时处理**:订单创建后 30 分钟未支付,是否自动关闭?
|
||
- 暂定:前端超时提示用户,后端暂不自动关闭
|
||
- 后续可使用 Asynq 延迟任务实现自动关闭
|
||
|
||
4. **测试环境配置**:如何在测试环境使用微信沙盒?
|
||
- PowerWeChat 支持沙盒环境(配置 `Http.BaseURI` 为沙盒地址)
|
||
- 需要微信商户平台申请沙盒权限
|