docs: 新增 Gateway 集成和微信公众号支付集成的 OpenSpec 规划文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 43s
This commit is contained in:
118
.sisyphus/drafts/gateway-integration-plan.md
Normal file
118
.sisyphus/drafts/gateway-integration-plan.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Draft: Gateway Integration 工作计划
|
||||
|
||||
## 用户需求确认
|
||||
|
||||
**目标**: 封装 Gateway API 为统一的能力模块,提供类型安全的接口、统一的错误处理和配置管理。
|
||||
|
||||
**核心交付物**:
|
||||
- Gateway 客户端封装(`internal/gateway/` 包)
|
||||
- 14 个 API 接口(流量卡 7 个 + 设备 7 个)
|
||||
- AES-128-ECB 加密 + MD5 签名机制
|
||||
- 配置集成(环境变量)
|
||||
- 错误码定义(1110-1119)
|
||||
- 依赖注入(Bootstrap)
|
||||
- 完整测试覆盖
|
||||
|
||||
**总任务数**: 51 个实施任务 + 10 个验收标准
|
||||
|
||||
## 初步任务分组
|
||||
|
||||
### Phase 1: 基础结构搭建(13 个任务)
|
||||
- 目录结构创建
|
||||
- 加密/签名工具实现
|
||||
- 客户端基础结构
|
||||
- DTO 定义
|
||||
|
||||
### Phase 2: API 接口封装(17 个任务)
|
||||
- 流量卡 API(7 个接口)
|
||||
- 设备 API(7 个接口)
|
||||
- 单元测试
|
||||
|
||||
### Phase 3: 配置和错误码集成(7 个任务)
|
||||
- Gateway 配置
|
||||
- Gateway 错误码
|
||||
|
||||
### Phase 4: 依赖注入和集成(6 个任务)
|
||||
- Bootstrap 初始化
|
||||
- Service 层集成
|
||||
|
||||
### Phase 5: 集成测试和文档(8 个任务)
|
||||
- 集成测试
|
||||
- 文档更新
|
||||
|
||||
## 用户决策(已确认)
|
||||
|
||||
### 1. Gateway 测试环境配置 ✅
|
||||
- **BaseURL**: `https://lplan.whjhft.com/openapi`
|
||||
- **AppID**: `60bgt1X8i7AvXqkd`
|
||||
- **AppSecret**: `BZeQttaZQt0i73moF`
|
||||
- **测试 ICCID**: `8986062580006141710`
|
||||
- **设备测试**: 不需要测试
|
||||
|
||||
### 2. 加密/签名算法验证 ✅
|
||||
- **文档来源**: Apifox 文档(https://omp5mq28pq.apifox.cn/7819761m0)
|
||||
- **加密方案**: AES-128-ECB + PKCS5Padding + Base64(密钥为 AppSecret 的 MD5)
|
||||
- **签名方案**: MD5(排序参数&key=AppSecret),大写输出
|
||||
- **安全警告**: ⚠️ 遗留系统,添加安全警告注释(ECB 模式泄漏 + MD5 碰撞风险)
|
||||
|
||||
### 3. Service 层集成范围 ✅
|
||||
- **集成位置**: `internal/service/iot_card/service.go`(已存在)
|
||||
- **集成方法**: 新增 `SyncCardStatus(ctx, iccid)` 方法作为示例
|
||||
- **范围**: 仅提供一个集成示例
|
||||
|
||||
### 4. 批量查询接口 ✅
|
||||
- **决定**: ❌ 完全不实施(连预留接口都不需要)
|
||||
- **理由**: 用户明确表示"根本就不会有批量查询接口"
|
||||
|
||||
### 5. 错误处理策略 ✅
|
||||
- **重试逻辑**: ✅ 需要自动重试(最多 3 次,指数退避:1s, 2s, 4s)
|
||||
- **降级策略**: ❌ 不需要
|
||||
- **超时处理**: 超时错误不重试,直接返回
|
||||
- **实现方式**: 在 `doRequest` 内置简单循环重试(无需第三方库)
|
||||
|
||||
## 研究发现
|
||||
|
||||
### 项目现有架构
|
||||
- **分层结构**: Handler → Service → Store → Model(严格分离)
|
||||
- **依赖注入**: Bootstrap 按顺序初始化(Stores → Services → Handlers)
|
||||
- **配置管理**: Viper 加载,支持环境变量覆盖(前缀 `JUNHONG_`)
|
||||
- **错误处理**: 统一错误码系统(1000-1999: 4xx, 2000-2999: 5xx)
|
||||
- **测试模式**: Table-driven tests + testutils(全局单例 DB/Redis)
|
||||
- **HTTP 客户端参考**: `/pkg/sms/` 使用接口 + 依赖注入模式
|
||||
|
||||
### 加密/签名安全警告
|
||||
- **AES-128-ECB**: ⚠️ 密码学上已被破解(模式泄漏),仅用于遗留系统
|
||||
- **MD5 签名**: ⚠️ 存在碰撞攻击漏洞,建议使用 HMAC-SHA256
|
||||
- **理由**: 如果 Gateway API 是遗留系统且无法更改,则必须使用 ECB + MD5
|
||||
|
||||
### 最佳实践建议
|
||||
1. **HTTP 客户端**: 使用 `http.Transport` 精细配置超时(Dial、TLS、ResponseHeader)
|
||||
2. **API 客户端**: 导出接口而非具体类型(可测试性)
|
||||
3. **集成测试**: 使用真实 Gateway 环境 + 测试 ICCID
|
||||
4. **错误处理**: 区分超时错误 vs 其他网络错误
|
||||
5. **重试策略**: 内置简单循环重试(无需第三方库)
|
||||
|
||||
### 现有代码结构
|
||||
- **iot_card Service**: 已存在于 `internal/service/iot_card/service.go`
|
||||
- 当前方法: ListStandalone, GetByICCID, AllocateCards, RecallCards, BatchSetSeriesBinding
|
||||
- 新增方法: SyncCardStatus(Gateway 集成示例)
|
||||
- **HTTP 客户端参考**: `pkg/sms/http_client.go`(无重试逻辑,使用 context 超时)
|
||||
- **重试模式**: 项目使用 Asynq 任务队列重试(常量:`DefaultRetryMax = 5`)
|
||||
|
||||
## 任务调整
|
||||
|
||||
### 删除任务
|
||||
- **Task 20**: "预留 BatchQuery(批量查询,未来扩展)" → ❌ 删除
|
||||
|
||||
### 新增任务
|
||||
- **Task 1.5**: "在 doRequest 中实现 HTTP 重试逻辑(3 次,指数退避)"
|
||||
- **Task 4.3**: "在 iot_card Service 中新增 SyncCardStatus 方法"
|
||||
|
||||
### 修改任务
|
||||
- **Task 5.1**: 使用真实配置进行集成测试(测试 ICCID: `8986062580006141710`)
|
||||
- **Task 2.1**: 移除 BatchQuery 实现
|
||||
- **Task 2.2**: 设备 API 只实现方法签名(测试时跳过真实调用)
|
||||
|
||||
### 调整后任务数
|
||||
- **实施任务**: 51 个(原 51 - 1 个删除 + 1 个新增 = 51)
|
||||
- **验收标准**: 10 个(保留但不作为实施任务)
|
||||
1457
.sisyphus/plans/gateway-integration.md
Normal file
1457
.sisyphus/plans/gateway-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
570
openspec/changes/gateway-integration/design.md
Normal file
570
openspec/changes/gateway-integration/design.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# 设计文档:Gateway API 统一封装
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 文件组织
|
||||
|
||||
```
|
||||
internal/gateway/
|
||||
├── client.go # Gateway 客户端主体(Client 结构体 + doRequest)
|
||||
├── crypto.go # 加密/签名工具函数(AES + MD5)
|
||||
├── flow_card.go # 流量卡 7 个 API 方法封装
|
||||
├── device.go # 设备 7 个 API 方法封装
|
||||
├── models.go # 请求/响应 DTO
|
||||
└── client_test.go # 单元测试和集成测试
|
||||
```
|
||||
|
||||
**设计理由**:
|
||||
- 按功能职责拆分,清晰易维护
|
||||
- 单文件长度控制在 100 行以内
|
||||
- 符合 Go 惯用法的扁平化包结构
|
||||
|
||||
### 客户端设计
|
||||
|
||||
```go
|
||||
// Client Gateway API 客户端
|
||||
type Client struct {
|
||||
baseURL string
|
||||
appID string
|
||||
appSecret string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient 创建 Gateway 客户端
|
||||
func NewClient(baseURL, appID, appSecret string) *Client
|
||||
|
||||
// WithTimeout 设置请求超时时间
|
||||
func (c *Client) WithTimeout(timeout time.Duration) *Client
|
||||
|
||||
// doRequest 统一处理请求(加密、签名、发送、解密)
|
||||
func (c *Client) doRequest(ctx context.Context, path string, businessData interface{}) ([]byte, error)
|
||||
```
|
||||
|
||||
**核心方法**:
|
||||
- `doRequest`:统一封装加密、签名、HTTP 请求、响应解析
|
||||
- 14 个 API 方法复用 `doRequest`
|
||||
|
||||
### 加密/签名机制
|
||||
|
||||
#### 1. AES-128-ECB 加密
|
||||
|
||||
```go
|
||||
// aesEncrypt 使用 AES-128-ECB 模式加密数据
|
||||
// 密钥:MD5(appSecret) 的原始字节数组(16字节)
|
||||
// 填充:PKCS5Padding
|
||||
// 编码:Base64
|
||||
func aesEncrypt(data []byte, appSecret string) (string, error) {
|
||||
// 1. 生成密钥:MD5(appSecret)
|
||||
h := md5.New()
|
||||
h.Write([]byte(appSecret))
|
||||
key := h.Sum(nil) // 16 字节
|
||||
|
||||
// 2. 创建 AES 加密器
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errors.CodeGatewayEncryptError, err)
|
||||
}
|
||||
|
||||
// 3. PKCS5 填充
|
||||
padding := block.BlockSize() - len(data)%block.BlockSize()
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
data = append(data, padText...)
|
||||
|
||||
// 4. ECB 模式加密
|
||||
encrypted := make([]byte, len(data))
|
||||
size := block.BlockSize()
|
||||
for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size {
|
||||
block.Encrypt(encrypted[bs:be], data[bs:be])
|
||||
}
|
||||
|
||||
// 5. Base64 编码
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. MD5 签名
|
||||
|
||||
```go
|
||||
// generateSign 生成 MD5 签名
|
||||
// 参数排序:appId、data、timestamp 按字母序
|
||||
// 格式:appId=xxx&data=xxx×tamp=xxx&key=appSecret
|
||||
// 输出:大写十六进制字符串
|
||||
func generateSign(appID, encryptedData string, timestamp int64, appSecret string) string {
|
||||
// 1. 构建签名字符串(参数按字母序)
|
||||
signStr := fmt.Sprintf("appId=%s&data=%s×tamp=%d&key=%s",
|
||||
appID, encryptedData, timestamp, appSecret)
|
||||
|
||||
// 2. MD5 加密
|
||||
h := md5.New()
|
||||
h.Write([]byte(signStr))
|
||||
|
||||
// 3. 转大写十六进制
|
||||
return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
|
||||
}
|
||||
```
|
||||
|
||||
### 请求流程
|
||||
|
||||
```
|
||||
业务数据(Go struct)
|
||||
↓ JSON 序列化
|
||||
业务数据(JSON string)
|
||||
↓ AES 加密
|
||||
加密数据(Base64 string)
|
||||
↓ 生成签名
|
||||
签名(MD5 大写)
|
||||
↓ 构建请求
|
||||
{
|
||||
"appId": "...",
|
||||
"data": "...",
|
||||
"sign": "...",
|
||||
"timestamp": ...
|
||||
}
|
||||
↓ HTTP POST
|
||||
Gateway API
|
||||
↓ 响应
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "成功",
|
||||
"data": {...},
|
||||
"trace_id": "..."
|
||||
}
|
||||
↓ 解析响应
|
||||
返回业务数据
|
||||
```
|
||||
|
||||
### API 封装示例
|
||||
|
||||
#### 流量卡状态查询
|
||||
|
||||
```go
|
||||
// QueryCardStatus 查询流量卡状态
|
||||
func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
|
||||
// 1. 构建业务数据
|
||||
businessData := map[string]interface{}{
|
||||
"params": map[string]interface{}{
|
||||
"cardNo": req.CardNo,
|
||||
},
|
||||
}
|
||||
|
||||
// 2. 调用统一请求方法
|
||||
resp, err := c.doRequest(ctx, "/flow-card/status", businessData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 解析响应
|
||||
var result CardStatusResp
|
||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析卡状态响应失败")
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 流量卡停机
|
||||
|
||||
```go
|
||||
// StopCard 流量卡停机
|
||||
func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error {
|
||||
businessData := map[string]interface{}{
|
||||
"params": map[string]interface{}{
|
||||
"cardNo": req.CardNo,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := c.doRequest(ctx, "/flow-card/cardStop", businessData)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## 数据模型设计
|
||||
|
||||
### 请求 DTO
|
||||
|
||||
```go
|
||||
// CardStatusReq 卡状态查询请求
|
||||
type CardStatusReq struct {
|
||||
CardNo string `json:"cardNo" validate:"required"` // 物联网卡号(ICCID)
|
||||
}
|
||||
|
||||
// CardOperationReq 卡操作请求(停机、复机)
|
||||
type CardOperationReq struct {
|
||||
CardNo string `json:"cardNo" validate:"required"` // 物联网卡号(ICCID)
|
||||
Extend string `json:"extend,omitempty"` // 扩展参数(广电国网)
|
||||
}
|
||||
|
||||
// FlowQueryReq 流量查询请求
|
||||
type FlowQueryReq struct {
|
||||
CardNo string `json:"cardNo" validate:"required"` // 物联网卡号(ICCID)
|
||||
}
|
||||
|
||||
// DeviceInfoReq 设备信息查询请求
|
||||
type DeviceInfoReq struct {
|
||||
CardNo string `json:"cardNo,omitempty"` // 物联网卡号(与 DeviceID 二选一)
|
||||
DeviceID string `json:"deviceId,omitempty"` // 设备编号(IMEI)
|
||||
}
|
||||
```
|
||||
|
||||
### 响应 DTO
|
||||
|
||||
```go
|
||||
// GatewayResponse Gateway 通用响应
|
||||
type GatewayResponse struct {
|
||||
Code int `json:"code"` // 业务状态码(200 = 成功)
|
||||
Msg string `json:"msg"` // 业务提示信息
|
||||
Data json.RawMessage `json:"data"` // 业务数据(原始 JSON)
|
||||
TraceID string `json:"trace_id"` // 链路追踪 ID
|
||||
}
|
||||
|
||||
// CardStatusResp 卡状态查询响应
|
||||
type CardStatusResp struct {
|
||||
ICCID string `json:"iccid"` // 卡号
|
||||
CardStatus string `json:"cardStatus"` // 卡状态(准备、正常、停机)
|
||||
Extend string `json:"extend"` // 扩展响应字段(广电国网)
|
||||
}
|
||||
|
||||
// FlowUsageResp 流量使用查询响应
|
||||
type FlowUsageResp struct {
|
||||
ICCID string `json:"iccid"` // 卡号
|
||||
Used float64 `json:"used"` // 已使用流量(MB)
|
||||
Unit string `json:"unit"` // 单位(MB)
|
||||
}
|
||||
|
||||
// DeviceInfoResp 设备信息响应
|
||||
type DeviceInfoResp struct {
|
||||
EquipmentID string `json:"equipmentId"` // 设备标识(IMEI)
|
||||
OnlineStatus string `json:"onlineStatus"` // 在线状态
|
||||
ClientNumber int `json:"clientNumber"` // 连接客户端数
|
||||
RSSI int `json:"rssi"` // 信号强度
|
||||
SSIDName string `json:"ssidName"` // WiFi 名称
|
||||
SSIDPassword string `json:"ssidPassword"` // WiFi 密码
|
||||
MAC string `json:"mac"` // MAC 地址
|
||||
UploadSpeed int `json:"uploadSpeed"` // 上行速率
|
||||
DownloadSpeed int `json:"downloadSpeed"` // 下行速率
|
||||
Version string `json:"version"` // 软件版本
|
||||
IP string `json:"ip"` // IP 地址
|
||||
WanIP string `json:"wanIp"` // 外网 IP
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理设计
|
||||
|
||||
### 错误码定义
|
||||
|
||||
在 `pkg/errors/codes.go` 中添加:
|
||||
|
||||
```go
|
||||
// Gateway 相关错误(1110-1119)
|
||||
const (
|
||||
CodeGatewayError = 1110 // Gateway 通用错误
|
||||
CodeGatewayEncryptError = 1111 // 数据加密失败
|
||||
CodeGatewaySignError = 1112 // 签名生成失败
|
||||
CodeGatewayTimeout = 1113 // 请求超时
|
||||
CodeGatewayInvalidResp = 1114 // 响应格式错误
|
||||
)
|
||||
```
|
||||
|
||||
在 `errorMessages` 中添加:
|
||||
|
||||
```go
|
||||
errorMessages = map[int]string{
|
||||
// ...
|
||||
CodeGatewayError: "Gateway 请求失败",
|
||||
CodeGatewayEncryptError: "数据加密失败",
|
||||
CodeGatewaySignError: "签名生成失败",
|
||||
CodeGatewayTimeout: "Gateway 请求超时",
|
||||
CodeGatewayInvalidResp: "Gateway 响应格式错误",
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理策略
|
||||
|
||||
```go
|
||||
// doRequest 中的错误处理
|
||||
func (c *Client) doRequest(ctx context.Context, path string, businessData interface{}) ([]byte, error) {
|
||||
// 1. 序列化业务数据
|
||||
dataBytes, err := sonic.Marshal(businessData)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
|
||||
}
|
||||
|
||||
// 2. 加密
|
||||
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
|
||||
if err != nil {
|
||||
return nil, err // 已在 aesEncrypt 中包装
|
||||
}
|
||||
|
||||
// 3. 生成签名
|
||||
timestamp := time.Now().Unix()
|
||||
sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret)
|
||||
|
||||
// 4. 构建请求
|
||||
reqBody := map[string]interface{}{
|
||||
"appId": c.appID,
|
||||
"data": encryptedData,
|
||||
"sign": sign,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
reqBytes, err := sonic.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化请求失败")
|
||||
}
|
||||
|
||||
// 5. 发送 HTTP 请求
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeGatewayError, err, "创建 HTTP 请求失败")
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json;charset=utf-8")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// 判断是否超时
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 6. 读取响应
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeGatewayError, err, "读取响应失败")
|
||||
}
|
||||
|
||||
// 7. 检查 HTTP 状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d, 响应: %s", resp.StatusCode, string(respBody)))
|
||||
}
|
||||
|
||||
// 8. 解析响应
|
||||
var gatewayResp GatewayResponse
|
||||
if err := sonic.Unmarshal(respBody, &gatewayResp); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
|
||||
}
|
||||
|
||||
// 9. 检查业务状态码
|
||||
if gatewayResp.Code != 200 {
|
||||
return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
|
||||
}
|
||||
|
||||
// 10. 返回业务数据
|
||||
return gatewayResp.Data, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 配置集成设计
|
||||
|
||||
### 配置结构
|
||||
|
||||
在 `pkg/config/config.go` 中添加:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Gateway GatewayConfig `mapstructure:"gateway"` // 新增
|
||||
// ...
|
||||
}
|
||||
|
||||
// GatewayConfig Gateway API 配置
|
||||
type GatewayConfig struct {
|
||||
BaseURL string `mapstructure:"base_url"` // Gateway API 基础 URL
|
||||
AppID string `mapstructure:"app_id"` // 应用 ID
|
||||
AppSecret string `mapstructure:"app_secret"` // 应用密钥
|
||||
Timeout int `mapstructure:"timeout"` // 超时时间(秒,默认 30)
|
||||
}
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
在 `pkg/config/defaults/config.yaml` 中添加:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
base_url: "https://lplan.whjhft.com/openapi"
|
||||
app_id: "60bgt1X8i7AvXqkd"
|
||||
app_secret: "BZeQttaZQt0i73moF"
|
||||
timeout: 30
|
||||
```
|
||||
|
||||
### 环境变量覆盖
|
||||
|
||||
```bash
|
||||
export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
|
||||
export JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
||||
export JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
||||
export JUNHONG_GATEWAY_TIMEOUT=30
|
||||
```
|
||||
|
||||
## 依赖注入设计
|
||||
|
||||
### Bootstrap 初始化
|
||||
|
||||
在 `internal/bootstrap/bootstrap.go` 中添加:
|
||||
|
||||
```go
|
||||
// Dependencies 系统依赖
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Redis *redis.Client
|
||||
QueueClient *asynq.Client
|
||||
Logger *zap.Logger
|
||||
Config *config.Config
|
||||
GatewayClient *gateway.Client // 新增
|
||||
}
|
||||
|
||||
// Bootstrap 初始化所有组件
|
||||
func Bootstrap(deps *Dependencies) (*Handlers, error) {
|
||||
// ... 现有初始化
|
||||
|
||||
// 初始化 Gateway 客户端
|
||||
gatewayClient := gateway.NewClient(
|
||||
deps.Config.Gateway.BaseURL,
|
||||
deps.Config.Gateway.AppID,
|
||||
deps.Config.Gateway.AppSecret,
|
||||
).WithTimeout(time.Duration(deps.Config.Gateway.Timeout) * time.Second)
|
||||
|
||||
deps.GatewayClient = gatewayClient
|
||||
|
||||
// ... 后续初始化
|
||||
}
|
||||
```
|
||||
|
||||
### Service 注入
|
||||
|
||||
```go
|
||||
// internal/service/iot_card/service.go
|
||||
type Service struct {
|
||||
store *postgres.IotCardStore
|
||||
gatewayClient *gateway.Client // 新增
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewService(store *postgres.IotCardStore, gatewayClient *gateway.Client, logger *zap.Logger) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncCardStatus 同步卡状态
|
||||
func (s *Service) SyncCardStatus(ctx context.Context, cardNo string) error {
|
||||
// 调用 Gateway API
|
||||
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
|
||||
CardNo: cardNo,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询卡状态失败")
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
return s.store.UpdateStatus(ctx, cardNo, resp.CardStatus)
|
||||
}
|
||||
```
|
||||
|
||||
## 测试设计
|
||||
|
||||
### 单元测试
|
||||
|
||||
```go
|
||||
// TestAESEncrypt 测试 AES 加密
|
||||
func TestAESEncrypt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
appSecret string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "正常加密",
|
||||
data: []byte(`{"params":{"cardNo":"898608070422D0010269"}}`),
|
||||
appSecret: "BZeQttaZQt0i73moF",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encrypted, err := aesEncrypt(tt.data, tt.appSecret)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("aesEncrypt() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && encrypted == "" {
|
||||
t.Error("aesEncrypt() 返回空字符串")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSign 测试签名生成
|
||||
func TestGenerateSign(t *testing.T) {
|
||||
appID := "60bgt1X8i7AvXqkd"
|
||||
encryptedData := "test_encrypted_data"
|
||||
timestamp := int64(1704067200)
|
||||
appSecret := "BZeQttaZQt0i73moF"
|
||||
|
||||
sign := generateSign(appID, encryptedData, timestamp, appSecret)
|
||||
|
||||
// 验证签名格式(32 位大写十六进制)
|
||||
if len(sign) != 32 {
|
||||
t.Errorf("签名长度错误: got %d, want 32", len(sign))
|
||||
}
|
||||
|
||||
if sign != strings.ToUpper(sign) {
|
||||
t.Error("签名应为大写")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
|
||||
```go
|
||||
// TestQueryCardStatus 测试卡状态查询
|
||||
func TestQueryCardStatus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("跳过集成测试")
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
client := gateway.NewClient(
|
||||
cfg.Gateway.BaseURL,
|
||||
cfg.Gateway.AppID,
|
||||
cfg.Gateway.AppSecret,
|
||||
).WithTimeout(30 * time.Second)
|
||||
|
||||
ctx := context.Background()
|
||||
resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEmpty(t, resp.ICCID)
|
||||
require.NotEmpty(t, resp.CardStatus)
|
||||
}
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
1. **HTTP 连接复用**:`http.Client` 复用 TCP 连接
|
||||
2. **超时控制**:通过 `context.WithTimeout` 控制请求超时
|
||||
3. **并发安全**:`Client` 结构体无状态,可安全并发调用
|
||||
4. **内存优化**:使用 `sonic` 进行高性能 JSON 序列化
|
||||
|
||||
## 安全性考虑
|
||||
|
||||
1. **AES-ECB 模式**:虽不推荐,但由 Gateway 强制要求
|
||||
2. **密钥管理**:AppSecret 通过环境变量注入,不硬编码
|
||||
3. **签名验证**:每个请求都进行签名,防止篡改
|
||||
4. **HTTPS**:生产环境使用 HTTPS 加密传输
|
||||
146
openspec/changes/gateway-integration/proposal.md
Normal file
146
openspec/changes/gateway-integration/proposal.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 提案:Gateway API 统一封装
|
||||
|
||||
## Why
|
||||
|
||||
当前项目需要调用外部 Gateway API 来实现物联网卡和设备的生命周期管理功能(状态查询、停复机、设备控制等)。Gateway API 具有以下特点:
|
||||
|
||||
1. **复杂的认证机制**:需要 AES-128-ECB 加密 + MD5 签名
|
||||
2. **多个接口**:14 个 API(流量卡 7 个 + 设备 7 个)
|
||||
3. **多场景调用**:Handler 层业务逻辑 + Asynq 定时任务批量同步
|
||||
4. **缺乏统一封装**:调用逻辑分散,加密签名重复实现
|
||||
|
||||
本变更旨在**封装 Gateway API 为统一的能力模块**,提供类型安全的接口、统一的错误处理和配置管理,供 Service 层和 Asynq 任务调用。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. Gateway 客户端封装
|
||||
- 新增 `internal/gateway/` 包,提供 Gateway API 的统一封装
|
||||
- 实现 AES-128-ECB 加密 + MD5 签名机制
|
||||
- 封装 14 个 API 接口(流量卡 7 个 + 设备 7 个)
|
||||
- 提供类型安全的请求/响应结构体
|
||||
|
||||
### 2. 配置集成
|
||||
- 在 `pkg/config/config.go` 中添加 `GatewayConfig` 配置结构
|
||||
- 支持环境变量配置:`JUNHONG_GATEWAY_BASE_URL`、`JUNHONG_GATEWAY_APP_ID`、`JUNHONG_GATEWAY_APP_SECRET`
|
||||
- 配置项包括:BaseURL、AppID、AppSecret、Timeout
|
||||
|
||||
### 3. 错误处理
|
||||
- 在 `pkg/errors/codes.go` 中定义 Gateway 相关错误码(1110-1119)
|
||||
- 统一错误处理:加密失败、签名失败、请求超时、响应格式错误
|
||||
|
||||
### 4. 依赖注入
|
||||
- 在 `internal/bootstrap/` 中初始化 Gateway 客户端
|
||||
- 注入到需要调用 Gateway API 的 Service
|
||||
|
||||
### 5. 测试覆盖
|
||||
- 单元测试:加密/签名函数验证
|
||||
- 集成测试:实际调用 Gateway API 验证
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `gateway-client`: Gateway API 统一客户端,提供 14 个接口的类型安全封装
|
||||
- `gateway-crypto`: AES-128-ECB 加密 + MD5 签名工具函数
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `config-management`: 添加 Gateway 配置支持
|
||||
- `error-handling`: 添加 Gateway 相关错误码
|
||||
- `dependency-injection`: 在 bootstrap 中初始化 Gateway 客户端
|
||||
|
||||
## Impact
|
||||
|
||||
### 代码变更
|
||||
|
||||
| 文件/目录 | 变更类型 | 说明 |
|
||||
|-----------|----------|------|
|
||||
| `internal/gateway/client.go` | 新增 | Gateway 客户端主体(Client 结构体 + doRequest) |
|
||||
| `internal/gateway/crypto.go` | 新增 | AES 加密 + MD5 签名函数 |
|
||||
| `internal/gateway/flow_card.go` | 新增 | 流量卡 7 个 API 方法封装 |
|
||||
| `internal/gateway/device.go` | 新增 | 设备 7 个 API 方法封装 |
|
||||
| `internal/gateway/models.go` | 新增 | 请求/响应 DTO 定义 |
|
||||
| `internal/gateway/client_test.go` | 新增 | 单元测试和集成测试 |
|
||||
| `pkg/config/config.go` | 修改 | 添加 GatewayConfig 结构体 |
|
||||
| `pkg/errors/codes.go` | 修改 | 添加 Gateway 错误码(1110-1119) |
|
||||
| `internal/bootstrap/bootstrap.go` | 修改 | 初始化 Gateway 客户端 |
|
||||
|
||||
### Gateway API 接口列表
|
||||
|
||||
**流量卡 API(7个)**:
|
||||
1. `/flow-card/status` - 流量卡状态查询
|
||||
2. `/flow-card/flow` - 流量使用查询
|
||||
3. `/flow-card/realname-status` - 实名认证状态查询
|
||||
4. `/flow-card/cardStop` - 流量卡停机
|
||||
5. `/flow-card/cardStart` - 流量卡复机
|
||||
6. `/flow-card/realname-link` - 获取实名认证跳转链接
|
||||
7. `/flow-card/batch-query` - 批量查询(未来扩展)
|
||||
|
||||
**设备 API(7个)**:
|
||||
1. `/device/info` - 获取设备信息
|
||||
2. `/device/slot-info` - 获取设备卡槽信息
|
||||
3. `/device/speed-limit` - 设置设备限速
|
||||
4. `/device/wifi` - 设置设备 WiFi
|
||||
5. `/device/switch-card` - 设备切换卡
|
||||
6. `/device/reset` - 设备恢复出厂设置
|
||||
7. `/device/reboot` - 设备重启
|
||||
|
||||
### 配置变更
|
||||
|
||||
**新增环境变量**:
|
||||
```bash
|
||||
JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
|
||||
JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
||||
JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
||||
JUNHONG_GATEWAY_TIMEOUT=30
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
- 无新增外部依赖
|
||||
- 使用标准库:`crypto/aes`、`crypto/md5`、`encoding/base64`、`net/http`
|
||||
|
||||
## 预期收益
|
||||
|
||||
| 指标 | 变更前 | 变更后 |
|
||||
|------|--------|--------|
|
||||
| Gateway 调用代码重复 | 每次调用重复加密签名 | 统一封装,零重复 |
|
||||
| 错误处理一致性 | 不一致 | 统一错误码 |
|
||||
| 类型安全 | 手动序列化,易出错 | 强类型 DTO,编译时检查 |
|
||||
| 测试覆盖率 | 0% | 90%+ |
|
||||
| 配置管理 | 硬编码 | 统一配置 |
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| AES-ECB 模式安全性 | 低(外部系统要求) | 文档注明,无法改变 |
|
||||
| 签名算法兼容性 | 中(签名不匹配导致认证失败) | 先实现端到端测试验证签名 |
|
||||
| Gateway 响应格式变更 | 中(解析失败) | 统一错误处理,兼容性版本 |
|
||||
|
||||
## 后续计划
|
||||
|
||||
1. **阶段 1(本次变更)**:
|
||||
- 实现 Gateway 客户端基础封装
|
||||
- 支持同步模式(14 个接口)
|
||||
- 集成到 Service 层
|
||||
|
||||
2. **阶段 2(未来优化)**:
|
||||
- 实现异步模式回调接口
|
||||
- 添加批量查询接口
|
||||
- 实现请求重试和超时控制
|
||||
|
||||
3. **阶段 3(性能优化)**:
|
||||
- 添加响应缓存(Redis)
|
||||
- 实现请求限流(防止 Gateway 过载)
|
||||
- 监控和告警集成
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] Gateway 客户端成功调用所有 14 个 API 接口
|
||||
- [ ] 加密/签名验证通过(与 Gateway 文档一致)
|
||||
- [ ] 错误处理覆盖所有异常场景(网络错误、响应格式错误等)
|
||||
- [ ] 单元测试覆盖率 ≥ 90%
|
||||
- [ ] 集成测试验证真实 Gateway API 调用
|
||||
- [ ] 配置通过环境变量成功加载
|
||||
- [ ] 文档完整(API 文档、使用示例、错误码说明)
|
||||
@@ -0,0 +1,220 @@
|
||||
# Gateway Client Specification
|
||||
|
||||
Gateway API 统一客户端,提供 14 个接口的类型安全封装。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Gateway 客户端结构
|
||||
|
||||
系统 SHALL 提供 `gateway.Client` 结构体,封装所有 Gateway API 调用。
|
||||
|
||||
客户端字段:
|
||||
- `baseURL string` - Gateway API 基础 URL
|
||||
- `appID string` - 应用 ID
|
||||
- `appSecret string` - 应用密钥
|
||||
- `httpClient *http.Client` - HTTP 客户端(支持连接复用)
|
||||
- `timeout time.Duration` - 请求超时时间
|
||||
|
||||
#### Scenario: 创建 Gateway 客户端
|
||||
|
||||
- **WHEN** 调用 `gateway.NewClient(baseURL, appID, appSecret)`
|
||||
- **THEN** 返回已初始化的 `Client` 实例
|
||||
- **AND** HTTP 客户端配置正确(支持 Keep-Alive)
|
||||
|
||||
#### Scenario: 配置超时时间
|
||||
|
||||
- **WHEN** 调用 `client.WithTimeout(30 * time.Second)`
|
||||
- **THEN** 客户端的 `timeout` 字段更新为 30 秒
|
||||
- **AND** 返回客户端自身(支持链式调用)
|
||||
|
||||
### Requirement: 统一请求方法
|
||||
|
||||
系统 SHALL 提供 `doRequest` 方法,统一处理加密、签名、HTTP 请求和响应解析。
|
||||
|
||||
#### Scenario: 成功的 API 调用
|
||||
|
||||
- **WHEN** 调用 `doRequest(ctx, "/flow-card/status", businessData)`
|
||||
- **THEN** 业务数据使用 AES-128-ECB 加密
|
||||
- **AND** 请求使用 MD5 签名
|
||||
- **AND** HTTP POST 发送到 `{baseURL}/flow-card/status`
|
||||
- **AND** 响应中的 `data` 字段解密并返回
|
||||
|
||||
#### Scenario: 网络错误
|
||||
|
||||
- **WHEN** HTTP 请求失败(网络中断、DNS 解析失败)
|
||||
- **THEN** 返回 `CodeGatewayError` 错误
|
||||
- **AND** 错误信息包含原始网络错误
|
||||
|
||||
#### Scenario: 请求超时
|
||||
|
||||
- **WHEN** HTTP 请求超过配置的超时时间
|
||||
- **THEN** 返回 `CodeGatewayTimeout` 错误
|
||||
- **AND** Context 超时错误被正确识别
|
||||
|
||||
#### Scenario: 响应格式错误
|
||||
|
||||
- **WHEN** Gateway 响应无法解析为 JSON
|
||||
- **THEN** 返回 `CodeGatewayInvalidResp` 错误
|
||||
- **AND** 错误信息包含原始响应内容(限制 200 字符)
|
||||
|
||||
#### Scenario: Gateway 业务错误
|
||||
|
||||
- **WHEN** Gateway 响应中 `code != 200`
|
||||
- **THEN** 返回 `CodeGatewayError` 错误
|
||||
- **AND** 错误信息包含 Gateway 的 code 和 msg
|
||||
|
||||
### Requirement: 流量卡 API 封装
|
||||
|
||||
系统 SHALL 提供 7 个流量卡相关的 API 方法。
|
||||
|
||||
#### Scenario: 查询流量卡状态
|
||||
|
||||
- **WHEN** 调用 `client.QueryCardStatus(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回 `CardStatusResp` 包含 ICCID 和卡状态
|
||||
- **AND** 卡状态为:"准备"、"正常" 或 "停机" 之一
|
||||
|
||||
#### Scenario: 查询流量使用
|
||||
|
||||
- **WHEN** 调用 `client.QueryFlow(ctx, &FlowQueryReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回 `FlowUsageResp` 包含已用流量和单位
|
||||
- **AND** 流量单位为 "MB"
|
||||
|
||||
#### Scenario: 查询实名认证状态
|
||||
|
||||
- **WHEN** 调用 `client.QueryRealnameStatus(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回实名认证状态信息
|
||||
|
||||
#### Scenario: 流量卡停机
|
||||
|
||||
- **WHEN** 调用 `client.StopCard(ctx, &CardOperationReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** Gateway 执行停机操作
|
||||
- **AND** 方法返回 nil(成功)或错误
|
||||
|
||||
#### Scenario: 流量卡复机
|
||||
|
||||
- **WHEN** 调用 `client.StartCard(ctx, &CardOperationReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** Gateway 执行复机操作
|
||||
- **AND** 方法返回 nil(成功)或错误
|
||||
|
||||
#### Scenario: 获取实名认证链接
|
||||
|
||||
- **WHEN** 调用 `client.GetRealnameLink(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回实名认证跳转链接
|
||||
- **AND** 链接格式为有效的 HTTPS URL
|
||||
|
||||
#### Scenario: 广电国网扩展参数
|
||||
|
||||
- **WHEN** 停机/复机请求中 `Extend` 字段不为空
|
||||
- **THEN** 请求包含 `extend` 参数
|
||||
- **AND** Gateway 正确处理广电国网特殊逻辑
|
||||
|
||||
### Requirement: 设备 API 封装
|
||||
|
||||
系统 SHALL 提供 7 个设备相关的 API 方法。
|
||||
|
||||
#### Scenario: 查询设备信息
|
||||
|
||||
- **WHEN** 调用 `client.GetDeviceInfo(ctx, &DeviceInfoReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回 `DeviceInfoResp` 包含设备详细信息
|
||||
- **AND** 信息包括:IMEI、在线状态、信号强度、WiFi 配置、速率等
|
||||
|
||||
#### Scenario: 通过设备 ID 查询
|
||||
|
||||
- **WHEN** 调用 `client.GetDeviceInfo(ctx, &DeviceInfoReq{DeviceID: "868123456789012"})`
|
||||
- **THEN** 通过设备 IMEI 查询设备信息
|
||||
- **AND** 返回结果与通过卡号查询一致
|
||||
|
||||
#### Scenario: 查询设备卡槽信息
|
||||
|
||||
- **WHEN** 调用 `client.GetSlotInfo(ctx, &DeviceInfoReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回设备中已安装的物联网卡信息
|
||||
|
||||
#### Scenario: 设置设备限速
|
||||
|
||||
- **WHEN** 调用 `client.SetSpeedLimit(ctx, &SpeedLimitReq{DeviceID: "868123456789012", UploadSpeed: 1024, DownloadSpeed: 2048})`
|
||||
- **THEN** 设备上下行速率设置为指定值(KB/s)
|
||||
|
||||
#### Scenario: 设置设备 WiFi
|
||||
|
||||
- **WHEN** 调用 `client.SetWiFi(ctx, &WiFiReq{DeviceID: "868123456789012", SSID: "MyWiFi", Password: "12345678", Enabled: true})`
|
||||
- **THEN** 设备 WiFi 配置更新
|
||||
- **AND** WiFi 名称、密码和启用状态正确设置
|
||||
|
||||
#### Scenario: 设备切换卡
|
||||
|
||||
- **WHEN** 调用 `client.SwitchCard(ctx, &SwitchCardReq{DeviceID: "868123456789012", TargetICCID: "898608070422D0010270"})`
|
||||
- **THEN** 多卡设备切换到目标 ICCID
|
||||
|
||||
#### Scenario: 设备恢复出厂设置
|
||||
|
||||
- **WHEN** 调用 `client.ResetDevice(ctx, &DeviceOperationReq{DeviceID: "868123456789012"})`
|
||||
- **THEN** 设备恢复为出厂状态
|
||||
|
||||
#### Scenario: 设备重启
|
||||
|
||||
- **WHEN** 调用 `client.RebootDevice(ctx, &DeviceOperationReq{DeviceID: "868123456789012"})`
|
||||
- **THEN** 设备执行重启操作
|
||||
|
||||
### Requirement: 类型安全的 DTO
|
||||
|
||||
系统 SHALL 为所有请求和响应定义类型安全的结构体。
|
||||
|
||||
#### Scenario: 请求 DTO 包含验证标签
|
||||
|
||||
- **WHEN** 定义 `CardStatusReq` 结构体
|
||||
- **THEN** `CardNo` 字段包含 `validate:"required"` 标签
|
||||
- **AND** 可以使用 Validator 库进行验证
|
||||
|
||||
#### Scenario: 响应 DTO 正确解析
|
||||
|
||||
- **WHEN** Gateway 返回 JSON 响应
|
||||
- **THEN** `CardStatusResp` 结构体正确解析 `iccid`、`cardStatus`、`extend` 字段
|
||||
- **AND** 字段类型与 Gateway 文档一致
|
||||
|
||||
### Requirement: 并发安全
|
||||
|
||||
系统 SHALL 确保 `Client` 结构体可以安全地并发调用。
|
||||
|
||||
#### Scenario: 多个 Goroutine 并发调用
|
||||
|
||||
- **WHEN** 10 个 Goroutine 同时调用 `client.QueryCardStatus`
|
||||
- **THEN** 所有请求都正确执行
|
||||
- **AND** 不发生 race condition
|
||||
|
||||
#### Scenario: HTTP 连接复用
|
||||
|
||||
- **WHEN** 多次调用相同的 Gateway API
|
||||
- **THEN** HTTP 客户端复用 TCP 连接
|
||||
- **AND** 减少连接建立开销
|
||||
|
||||
### Requirement: 错误处理一致性
|
||||
|
||||
系统 SHALL 使用项目统一的错误码系统。
|
||||
|
||||
#### Scenario: Gateway 错误返回统一错误码
|
||||
|
||||
- **WHEN** Gateway API 调用失败
|
||||
- **THEN** 返回 `errors.AppError` 类型
|
||||
- **AND** 错误码为 `CodeGatewayError`、`CodeGatewayTimeout` 等之一
|
||||
|
||||
#### Scenario: 错误包含上下文信息
|
||||
|
||||
- **WHEN** 加密失败
|
||||
- **THEN** 错误信息为 "数据加密失败"
|
||||
- **AND** 包含底层错误的详细信息
|
||||
|
||||
### Requirement: Context 支持
|
||||
|
||||
系统 SHALL 支持通过 Context 控制请求超时和取消。
|
||||
|
||||
#### Scenario: 使用 Context 控制超时
|
||||
|
||||
- **WHEN** 调用 `client.QueryCardStatus(ctx, req)` 且 ctx 设置了 30 秒超时
|
||||
- **THEN** 请求在 30 秒后自动超时
|
||||
- **AND** 返回 `CodeGatewayTimeout` 错误
|
||||
|
||||
#### Scenario: 取消请求
|
||||
|
||||
- **WHEN** 调用 `client.QueryCardStatus(ctx, req)` 且 ctx 被取消
|
||||
- **THEN** 请求立即停止
|
||||
- **AND** 返回 context canceled 错误
|
||||
@@ -0,0 +1,175 @@
|
||||
# Gateway Config Specification
|
||||
|
||||
Gateway API 的配置集成规范,定义配置结构和加载方式。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Gateway 配置结构
|
||||
|
||||
系统 SHALL 在 `pkg/config/config.go` 中添加 `GatewayConfig` 结构体。
|
||||
|
||||
配置字段:
|
||||
- `BaseURL string` - Gateway API 基础 URL
|
||||
- `AppID string` - 应用 ID
|
||||
- `AppSecret string` - 应用密钥
|
||||
- `Timeout int` - 请求超时时间(秒)
|
||||
|
||||
#### Scenario: 配置结构定义
|
||||
|
||||
- **WHEN** 定义 `GatewayConfig` 结构体
|
||||
- **THEN** 包含 `mapstructure` 标签用于 Viper 解析
|
||||
- **AND** 字段名使用 snake_case(如 `base_url`、`app_id`)
|
||||
|
||||
#### Scenario: 集成到主配置
|
||||
|
||||
- **WHEN** 在 `Config` 结构体中添加 `Gateway GatewayConfig` 字段
|
||||
- **THEN** 使用 `mapstructure:"gateway"` 标签
|
||||
- **AND** 配置可通过 `config.Get().Gateway` 访问
|
||||
|
||||
### Requirement: 默认配置嵌入
|
||||
|
||||
系统 SHALL 在 `pkg/config/defaults/config.yaml` 中添加 Gateway 默认配置。
|
||||
|
||||
#### Scenario: 嵌入默认配置
|
||||
|
||||
- **WHEN** 读取嵌入的默认配置文件
|
||||
- **THEN** 包含 `gateway` 配置节
|
||||
- **AND** 配置包含:
|
||||
```yaml
|
||||
gateway:
|
||||
base_url: "https://lplan.whjhft.com/openapi"
|
||||
app_id: "60bgt1X8i7AvXqkd"
|
||||
app_secret: "BZeQttaZQt0i73moF"
|
||||
timeout: 30
|
||||
```
|
||||
|
||||
### Requirement: 环境变量覆盖
|
||||
|
||||
系统 SHALL 支持通过环境变量覆盖 Gateway 配置。
|
||||
|
||||
环境变量格式:`JUNHONG_GATEWAY_{KEY}`
|
||||
|
||||
#### Scenario: 覆盖 BaseURL
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_BASE_URL=https://test.example.com`
|
||||
- **THEN** `config.Gateway.BaseURL` 的值为 "https://test.example.com"
|
||||
- **AND** 覆盖嵌入配置中的默认值
|
||||
|
||||
#### Scenario: 覆盖 AppID
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_APP_ID=test_app_id`
|
||||
- **THEN** `config.Gateway.AppID` 的值为 "test_app_id"
|
||||
|
||||
#### Scenario: 覆盖 AppSecret
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_APP_SECRET=test_secret`
|
||||
- **THEN** `config.Gateway.AppSecret` 的值为 "test_secret"
|
||||
|
||||
#### Scenario: 覆盖 Timeout
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_TIMEOUT=60`
|
||||
- **THEN** `config.Gateway.Timeout` 的值为 60
|
||||
|
||||
### Requirement: 配置验证
|
||||
|
||||
系统 SHALL 在配置加载后验证 Gateway 配置的有效性。
|
||||
|
||||
#### Scenario: 必填字段验证
|
||||
|
||||
- **WHEN** 配置加载完成
|
||||
- **THEN** 验证 `BaseURL`、`AppID`、`AppSecret` 不为空
|
||||
- **AND** 如果为空,返回明确的错误信息
|
||||
|
||||
#### Scenario: BaseURL 格式验证
|
||||
|
||||
- **WHEN** 验证 `BaseURL` 字段
|
||||
- **THEN** 必须以 `http://` 或 `https://` 开头
|
||||
- **AND** 不能以 `/` 结尾
|
||||
|
||||
#### Scenario: Timeout 范围验证
|
||||
|
||||
- **WHEN** 验证 `Timeout` 字段
|
||||
- **THEN** 值必须在 5 到 300 秒之间
|
||||
- **AND** 如果超出范围,返回验证错误
|
||||
|
||||
#### Scenario: AppID 格式验证
|
||||
|
||||
- **WHEN** 验证 `AppID` 字段
|
||||
- **THEN** 长度必须 > 0
|
||||
- **AND** 不包含特殊字符(仅允许字母、数字、下划线)
|
||||
|
||||
### Requirement: 敏感配置处理
|
||||
|
||||
系统 SHALL 确保 `AppSecret` 不记录到日志中。
|
||||
|
||||
#### Scenario: 配置日志脱敏
|
||||
|
||||
- **WHEN** 记录配置加载成功的日志
|
||||
- **THEN** `AppSecret` 字段显示为 "***"
|
||||
- **AND** 实际值不出现在日志中
|
||||
|
||||
#### Scenario: 错误日志脱敏
|
||||
|
||||
- **WHEN** 配置验证失败并记录错误日志
|
||||
- **THEN** `AppSecret` 字段显示为 "***"
|
||||
|
||||
### Requirement: Gateway 客户端初始化
|
||||
|
||||
系统 SHALL 在 `internal/bootstrap/bootstrap.go` 中初始化 Gateway 客户端。
|
||||
|
||||
#### Scenario: Bootstrap 中初始化
|
||||
|
||||
- **WHEN** 调用 `bootstrap.Bootstrap(deps)`
|
||||
- **THEN** 从 `deps.Config.Gateway` 读取配置
|
||||
- **AND** 调用 `gateway.NewClient(baseURL, appID, appSecret).WithTimeout(...)`
|
||||
- **AND** 将客户端赋值给 `deps.GatewayClient`
|
||||
|
||||
#### Scenario: 配置错误时启动失败
|
||||
|
||||
- **WHEN** Gateway 配置验证失败
|
||||
- **THEN** `bootstrap.Bootstrap` 返回错误
|
||||
- **AND** 应用启动失败
|
||||
|
||||
### Requirement: 多环境配置支持
|
||||
|
||||
系统 SHALL 支持通过环境变量切换不同环境的 Gateway 配置。
|
||||
|
||||
#### Scenario: 开发环境配置
|
||||
|
||||
- **WHEN** 使用默认嵌入配置(未设置环境变量)
|
||||
- **THEN** 使用生产环境的 Gateway URL 和凭证
|
||||
|
||||
#### Scenario: 测试环境配置
|
||||
|
||||
- **WHEN** 设置环境变量指向测试 Gateway
|
||||
- **AND** `JUNHONG_GATEWAY_BASE_URL=https://test-gateway.example.com`
|
||||
- **AND** `JUNHONG_GATEWAY_APP_ID=test_app_id`
|
||||
- **THEN** 客户端连接到测试环境
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Config 结构体扩展
|
||||
|
||||
系统 SHALL 在现有的 `Config` 结构体中添加 `Gateway` 字段。
|
||||
|
||||
#### Scenario: 配置结构兼容性
|
||||
|
||||
- **WHEN** 添加 `Gateway GatewayConfig` 字段
|
||||
- **THEN** 不影响现有配置字段的加载
|
||||
- **AND** 现有配置(Server、Database、Redis 等)继续正常工作
|
||||
|
||||
### Requirement: Dependencies 结构体扩展
|
||||
|
||||
系统 SHALL 在 `internal/bootstrap/bootstrap.go` 的 `Dependencies` 结构体中添加 `GatewayClient` 字段。
|
||||
|
||||
#### Scenario: 依赖注入扩展
|
||||
|
||||
- **WHEN** 在 `Dependencies` 中添加 `GatewayClient *gateway.Client` 字段
|
||||
- **THEN** 不影响现有依赖的注入
|
||||
- **AND** Gateway 客户端可以注入到需要的 Service
|
||||
|
||||
#### Scenario: Service 层使用
|
||||
|
||||
- **WHEN** Service 需要调用 Gateway API
|
||||
- **THEN** 在 Service 构造函数中接收 `gatewayClient *gateway.Client` 参数
|
||||
- **AND** 从 Bootstrap 中传递 `deps.GatewayClient`
|
||||
@@ -0,0 +1,155 @@
|
||||
# Gateway Crypto Specification
|
||||
|
||||
Gateway API 的加密和签名工具函数,实现 AES-128-ECB 加密和 MD5 签名机制。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: AES-128-ECB 加密
|
||||
|
||||
系统 SHALL 提供 `aesEncrypt` 函数,使用 AES-128-ECB 模式加密业务数据。
|
||||
|
||||
加密流程:
|
||||
1. 密钥生成:`MD5(appSecret)` 的原始字节数组(16字节)
|
||||
2. 加密算法:AES-128-ECB
|
||||
3. 填充方式:PKCS5Padding
|
||||
4. 编码输出:Base64
|
||||
|
||||
#### Scenario: 加密业务数据
|
||||
|
||||
- **WHEN** 调用 `aesEncrypt(data, appSecret)`
|
||||
- **AND** `data` 为业务数据的 JSON 字节数组
|
||||
- **THEN** 返回 Base64 编码的加密字符串
|
||||
- **AND** 密钥为 `MD5(appSecret)` 的 16 字节数组
|
||||
|
||||
#### Scenario: PKCS5 填充正确性
|
||||
|
||||
- **WHEN** 业务数据长度不是 AES 块大小(16 字节)的整数倍
|
||||
- **THEN** 使用 PKCS5Padding 进行填充
|
||||
- **AND** 填充字节值等于填充长度
|
||||
|
||||
#### Scenario: 加密输出格式
|
||||
|
||||
- **WHEN** 加密成功
|
||||
- **THEN** 输出为 Base64 字符串
|
||||
- **AND** 字符串不包含换行符
|
||||
|
||||
#### Scenario: 加密失败
|
||||
|
||||
- **WHEN** AES 加密过程失败
|
||||
- **THEN** 返回 `CodeGatewayEncryptError` 错误
|
||||
- **AND** 错误信息包含原始错误
|
||||
|
||||
### Requirement: MD5 签名生成
|
||||
|
||||
系统 SHALL 提供 `generateSign` 函数,生成 MD5 签名。
|
||||
|
||||
签名流程:
|
||||
1. 参数排序:`appId`、`data`、`timestamp` 按字母升序
|
||||
2. 拼接字符串:`appId=xxx&data=xxx×tamp=xxx&key=appSecret`
|
||||
3. MD5 加密
|
||||
4. 转大写十六进制
|
||||
|
||||
#### Scenario: 生成正确的签名
|
||||
|
||||
- **WHEN** 调用 `generateSign(appID, encryptedData, timestamp, appSecret)`
|
||||
- **THEN** 参数按字母序拼接:`appId` → `data` → `timestamp`
|
||||
- **AND** 追加 `&key=appSecret`
|
||||
- **AND** MD5 加密后转大写十六进制
|
||||
|
||||
#### Scenario: 签名输出格式
|
||||
|
||||
- **WHEN** 签名生成成功
|
||||
- **THEN** 输出为 32 位大写十六进制字符串
|
||||
- **AND** 例如:"ABCDEF1234567890ABCDEF1234567890"
|
||||
|
||||
#### Scenario: 签名可重现
|
||||
|
||||
- **WHEN** 使用相同的 `appID`、`encryptedData`、`timestamp`、`appSecret`
|
||||
- **THEN** 多次调用 `generateSign` 生成相同的签名
|
||||
|
||||
#### Scenario: 时间戳格式
|
||||
|
||||
- **WHEN** 签名中使用时间戳
|
||||
- **THEN** 时间戳为 Unix 秒级时间戳(10 位数字)
|
||||
- **AND** 例如:1704067200
|
||||
|
||||
### Requirement: 参数序列化
|
||||
|
||||
系统 SHALL 正确序列化请求参数,确保与 Gateway 期望格式一致。
|
||||
|
||||
#### Scenario: 业务数据序列化
|
||||
|
||||
- **WHEN** 业务数据为 Go 结构体
|
||||
- **THEN** 使用 `sonic.Marshal` 序列化为 JSON 字符串
|
||||
- **AND** JSON 格式与 Gateway 文档一致
|
||||
|
||||
#### Scenario: 空字段处理
|
||||
|
||||
- **WHEN** 请求结构体中某些字段为空(omitempty)
|
||||
- **THEN** 序列化时忽略空字段
|
||||
- **AND** 减少请求体大小
|
||||
|
||||
### Requirement: 加密/签名测试验证
|
||||
|
||||
系统 SHALL 提供加密和签名的单元测试,验证与 Gateway 文档一致性。
|
||||
|
||||
#### Scenario: 加密测试用例
|
||||
|
||||
- **WHEN** 使用已知的业务数据和 appSecret
|
||||
- **THEN** 加密输出与 Gateway 文档示例一致
|
||||
- **AND** 可以被 Gateway 正确解密
|
||||
|
||||
#### Scenario: 签名测试用例
|
||||
|
||||
- **WHEN** 使用已知的参数和 appSecret
|
||||
- **THEN** 签名输出与 Gateway 文档示例一致
|
||||
- **AND** Gateway 验证签名成功
|
||||
|
||||
#### Scenario: 端到端验证
|
||||
|
||||
- **WHEN** 运行集成测试,实际调用 Gateway API
|
||||
- **THEN** 加密和签名被 Gateway 接受
|
||||
- **AND** 响应状态码为 200
|
||||
|
||||
### Requirement: 性能要求
|
||||
|
||||
系统 SHALL 确保加密和签名操作的性能满足要求。
|
||||
|
||||
#### Scenario: 加密性能
|
||||
|
||||
- **WHEN** 加密 1KB 的业务数据
|
||||
- **THEN** 加密时间 < 1ms
|
||||
- **AND** 内存分配最小化
|
||||
|
||||
#### Scenario: 签名性能
|
||||
|
||||
- **WHEN** 生成签名
|
||||
- **THEN** 签名时间 < 0.5ms
|
||||
- **AND** 无不必要的内存分配
|
||||
|
||||
### Requirement: 安全性说明
|
||||
|
||||
系统 SHALL 在文档中说明 AES-ECB 模式的安全性限制。
|
||||
|
||||
#### Scenario: 安全性文档
|
||||
|
||||
- **WHEN** 查看加密函数的文档注释
|
||||
- **THEN** 注释中说明 ECB 模式不推荐用于生产环境
|
||||
- **AND** 说明这是 Gateway 强制要求,无法改变
|
||||
- **AND** 建议使用 HTTPS 加密传输层
|
||||
|
||||
### Requirement: 字符编码一致性
|
||||
|
||||
系统 SHALL 确保所有字符串操作使用 UTF-8 编码。
|
||||
|
||||
#### Scenario: 字符串编码
|
||||
|
||||
- **WHEN** 序列化业务数据
|
||||
- **THEN** 使用 UTF-8 编码
|
||||
- **AND** 中文字符正确处理
|
||||
|
||||
#### Scenario: 签名字符串编码
|
||||
|
||||
- **WHEN** 生成签名的拼接字符串
|
||||
- **THEN** 使用 UTF-8 编码
|
||||
- **AND** 与 Gateway 期望的编码一致
|
||||
173
openspec/changes/gateway-integration/tasks.md
Normal file
173
openspec/changes/gateway-integration/tasks.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# 任务清单:Gateway API 统一封装
|
||||
|
||||
## Phase 1: 基础结构搭建(30min)
|
||||
|
||||
### Task 1.1: 创建 Gateway 包目录结构
|
||||
- [ ] 创建 `internal/gateway/` 目录
|
||||
- [ ] 创建占位文件:`client.go`、`crypto.go`、`models.go`
|
||||
- **验证**:目录结构创建成功
|
||||
|
||||
### Task 1.2: 实现加密/签名工具函数
|
||||
- [ ] 在 `crypto.go` 中实现 `aesEncrypt` 函数(AES-128-ECB + PKCS5Padding + Base64)
|
||||
- [ ] 在 `crypto.go` 中实现 `generateSign` 函数(MD5 签名,大写输出)
|
||||
- [ ] 添加单元测试验证加密/签名正确性
|
||||
- **验证**:
|
||||
```bash
|
||||
go test -v ./internal/gateway -run TestAESEncrypt
|
||||
go test -v ./internal/gateway -run TestGenerateSign
|
||||
```
|
||||
|
||||
### Task 1.3: 实现 Gateway 客户端基础结构
|
||||
- [ ] 在 `client.go` 中定义 `Client` 结构体
|
||||
- [ ] 实现 `NewClient` 构造函数
|
||||
- [ ] 实现 `WithTimeout` 配置方法
|
||||
- [ ] 实现 `doRequest` 统一请求方法(加密、签名、HTTP 请求、响应解析)
|
||||
- **验证**:编译通过,无 LSP 错误
|
||||
|
||||
### Task 1.4: 定义请求/响应 DTO
|
||||
- [ ] 在 `models.go` 中定义 `GatewayResponse` 通用响应结构
|
||||
- [ ] 定义流量卡相关 DTO(`CardStatusReq`、`CardStatusResp`、`FlowQueryReq`、`FlowUsageResp` 等)
|
||||
- [ ] 定义设备相关 DTO(`DeviceInfoReq`、`DeviceInfoResp` 等)
|
||||
- [ ] 添加 JSON 标签和验证标签
|
||||
- **验证**:编译通过,结构体定义完整
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: API 接口封装(40min)
|
||||
|
||||
### Task 2.1: 实现流量卡 API(7个接口)
|
||||
- [ ] 在 `flow_card.go` 中实现 `QueryCardStatus`(流量卡状态查询)
|
||||
- [ ] 实现 `QueryFlow`(流量使用查询)
|
||||
- [ ] 实现 `QueryRealnameStatus`(实名认证状态查询)
|
||||
- [ ] 实现 `StopCard`(流量卡停机)
|
||||
- [ ] 实现 `StartCard`(流量卡复机)
|
||||
- [ ] 实现 `GetRealnameLink`(获取实名认证跳转链接)
|
||||
- [ ] 预留 `BatchQuery`(批量查询,未来扩展)
|
||||
- **验证**:编译通过,方法签名正确
|
||||
|
||||
### Task 2.2: 实现设备 API(7个接口)
|
||||
- [ ] 在 `device.go` 中实现 `GetDeviceInfo`(获取设备信息)
|
||||
- [ ] 实现 `GetSlotInfo`(获取设备卡槽信息)
|
||||
- [ ] 实现 `SetSpeedLimit`(设置设备限速)
|
||||
- [ ] 实现 `SetWiFi`(设置设备 WiFi)
|
||||
- [ ] 实现 `SwitchCard`(设备切换卡)
|
||||
- [ ] 实现 `ResetDevice`(设备恢复出厂设置)
|
||||
- [ ] 实现 `RebootDevice`(设备重启)
|
||||
- **验证**:编译通过,方法签名正确
|
||||
|
||||
### Task 2.3: 添加单元测试
|
||||
- [ ] 在 `client_test.go` 中添加加密/签名单元测试
|
||||
- [ ] 添加 `doRequest` 的 mock 测试
|
||||
- [ ] 验证错误处理逻辑(超时、网络错误、响应格式错误)
|
||||
- **验证**:
|
||||
```bash
|
||||
go test -v ./internal/gateway -cover
|
||||
```
|
||||
覆盖率 ≥ 90%
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 配置和错误码集成(20min)
|
||||
|
||||
### Task 3.1: 添加 Gateway 配置
|
||||
- [ ] 在 `pkg/config/config.go` 中添加 `GatewayConfig` 结构体
|
||||
- [ ] 在 `Config` 中添加 `Gateway GatewayConfig` 字段
|
||||
- [ ] 在 `pkg/config/defaults/config.yaml` 中添加 gateway 配置项
|
||||
- [ ] 添加配置验证逻辑(必填项检查)
|
||||
- **验证**:
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
|
||||
export JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
||||
export JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
||||
|
||||
# 启动应用验证配置加载
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
### Task 3.2: 添加 Gateway 错误码
|
||||
- [ ] 在 `pkg/errors/codes.go` 中添加 Gateway 错误码常量(1110-1119)
|
||||
- [ ] 在 `allErrorCodes` 数组中注册新错误码
|
||||
- [ ] 在 `errorMessages` 映射表中添加中文错误消息
|
||||
- [ ] 运行错误码验证测试
|
||||
- **验证**:
|
||||
```bash
|
||||
go test -v ./pkg/errors -run TestErrorCodes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 依赖注入和集成(20min)
|
||||
|
||||
### Task 4.1: Bootstrap 初始化 Gateway 客户端
|
||||
- [ ] 在 `internal/bootstrap/bootstrap.go` 的 `Dependencies` 中添加 `GatewayClient *gateway.Client` 字段
|
||||
- [ ] 在 `Bootstrap` 函数中初始化 Gateway 客户端
|
||||
- [ ] 将 Gateway 客户端注入到需要的 Service
|
||||
- **验证**:编译通过,依赖注入正确
|
||||
|
||||
### Task 4.2: Service 层集成示例
|
||||
- [ ] 选择一个 Service(如 `iot_card`)集成 Gateway 客户端
|
||||
- [ ] 添加 `SyncCardStatus` 方法示例
|
||||
- [ ] 添加错误处理和日志记录
|
||||
- **验证**:编译通过,方法签名正确
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 集成测试和文档(10min)
|
||||
|
||||
### Task 5.1: 编写集成测试
|
||||
- [ ] 在 `client_test.go` 中添加集成测试(需要真实 Gateway 环境)
|
||||
- [ ] 测试至少 2 个接口(如 `QueryCardStatus`、`StopCard`)
|
||||
- [ ] 验证加密/签名与 Gateway 文档一致
|
||||
- **验证**:
|
||||
```bash
|
||||
# 设置测试环境变量
|
||||
source .env.local
|
||||
|
||||
# 运行集成测试
|
||||
go test -v ./internal/gateway -run TestIntegration
|
||||
```
|
||||
|
||||
### Task 5.2: 更新文档
|
||||
- [ ] 在 `docs/` 目录下创建 `gateway-client-usage.md`
|
||||
- [ ] 添加 Gateway 客户端使用示例
|
||||
- [ ] 添加错误码说明
|
||||
- [ ] 更新 `README.md` 添加 Gateway 模块说明
|
||||
- **验证**:文档完整,示例代码可运行
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 所有 14 个 Gateway API 接口成功封装
|
||||
- [ ] 加密/签名验证通过(与 Gateway 文档一致)
|
||||
- [ ] 错误处理覆盖所有异常场景
|
||||
- [ ] 单元测试覆盖率 ≥ 90%
|
||||
- [ ] 集成测试验证真实 Gateway API 调用
|
||||
- [ ] 配置通过环境变量成功加载
|
||||
- [ ] 依赖注入到 Service 层成功
|
||||
- [ ] 文档完整(使用示例、错误码说明)
|
||||
- [ ] 无 LSP 错误,编译通过
|
||||
- [ ] 符合项目代码规范(中文注释、Go 命名规范)
|
||||
|
||||
---
|
||||
|
||||
## 任务执行规范
|
||||
|
||||
**⚠️ 重要提醒**:
|
||||
- ❌ 禁止跳过任务
|
||||
- ❌ 禁止合并任务或简化执行
|
||||
- ❌ 禁止自作主张优化流程
|
||||
- ✅ 必须按顺序逐项完成
|
||||
- ✅ 每个任务完成后标记 `[x]`
|
||||
- ✅ 如需调整任务,先询问用户确认
|
||||
|
||||
**任务依赖关系**:
|
||||
- Phase 1 → Phase 2:基础结构完成后再实现 API
|
||||
- Phase 3 → Phase 4:配置和错误码完成后再集成
|
||||
- Phase 4 → Phase 5:依赖注入完成后再测试
|
||||
|
||||
**并行执行机会**:
|
||||
- Task 1.2(加密函数)和 Task 1.4(DTO 定义)可并行
|
||||
- Task 2.1(流量卡 API)和 Task 2.2(设备 API)可并行
|
||||
- Task 3.1(配置)和 Task 3.2(错误码)可并行
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-30
|
||||
@@ -0,0 +1,368 @@
|
||||
# 微信公众号与微信支付集成 - 技术设计
|
||||
|
||||
## 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` 为沙盒地址)
|
||||
- 需要微信商户平台申请沙盒权限
|
||||
@@ -0,0 +1,62 @@
|
||||
# 微信公众号与微信支付集成提案
|
||||
|
||||
## Why
|
||||
|
||||
当前系统的个人客户(C 端用户)无法通过微信公众号登录和使用微信支付功能,导致用户体验不完整。系统已经预留了微信相关的数据模型(`wx_open_id`, `wx_union_id`)和支付回调接口,但缺少真实的微信 SDK 集成。为了满足个人客户的核心使用场景(微信扫码登录、钱包充值、购买套餐),需要立即对接微信公众号和微信支付能力。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增微信公众号 OAuth 认证**:实现用户通过微信授权码获取 OpenID/UnionID 和基本信息(昵称、头像)
|
||||
- **新增微信支付发起功能**:
|
||||
- H5 支付:支持移动端浏览器外唤起微信支付
|
||||
- JSAPI 支付:支持微信内网页支付
|
||||
- **完善微信支付回调处理**:在现有回调接口基础上补充签名验证和完整的支付状态同步
|
||||
- **新增微信配置管理**:在配置文件中增加微信公众号和支付的必要参数(AppID、Secret、商户号、证书等)
|
||||
- **集成 PowerWeChat SDK**:使用 `github.com/ArtisanCloud/PowerWeChat/v3` 实现微信 API 调用
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `wechat-official-account`: 微信公众号能力(OAuth 认证、获取用户信息)
|
||||
- `wechat-payment`: 微信支付能力(H5 支付、JSAPI 支付、支付回调)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。现有功能不涉及需求级别的变更。
|
||||
|
||||
## Impact
|
||||
|
||||
**影响的代码模块**:
|
||||
- `pkg/wechat/`: 微信服务接口实现(替换 Mock 为真实实现)
|
||||
- `pkg/config/`: 新增微信配置项
|
||||
- `internal/service/personal_customer/`: 补充微信 OAuth 登录逻辑
|
||||
- `internal/service/order/`: 新增微信支付发起和回调验证
|
||||
- `internal/handler/app/`: 微信 OAuth 相关 API 端点
|
||||
- `internal/handler/h5/`: 微信支付发起 API 端点
|
||||
- `internal/handler/callback/`: 补充支付回调签名验证
|
||||
|
||||
**依赖变更**:
|
||||
- 新增依赖:`github.com/ArtisanCloud/PowerWeChat/v3`
|
||||
|
||||
**配置变更**:
|
||||
- 新增环境变量:
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID`
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET`
|
||||
- `JUNHONG_WECHAT_PAYMENT_MCH_ID`
|
||||
- `JUNHONG_WECHAT_PAYMENT_API_V3_KEY`
|
||||
- 等(详见配置设计)
|
||||
|
||||
**API 变更**:
|
||||
- 修改端点:`POST /api/c/v1/bind-wechat`(从"not implemented"变为可用)
|
||||
- 新增端点:
|
||||
- `POST /api/h5/orders/:id/wechat-pay/jsapi`(JSAPI 支付)
|
||||
- `POST /api/h5/orders/:id/wechat-pay/h5`(H5 支付)
|
||||
- 增强端点:`POST /api/callback/wechat-pay`(补充签名验证)
|
||||
|
||||
**数据库变更**:
|
||||
- 无。现有表结构已满足需求。
|
||||
|
||||
**部署影响**:
|
||||
- 需要提供微信商户证书文件(`apiclient_cert.pem`、`apiclient_key.pem`)
|
||||
- 需要配置微信回调域名白名单
|
||||
@@ -0,0 +1,147 @@
|
||||
# 微信公众号能力规格说明
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 系统必须支持微信 OAuth 2.0 授权登录
|
||||
|
||||
系统 SHALL 实现微信公众号 OAuth 2.0 授权流程,允许个人客户通过微信授权获取用户身份信息。
|
||||
|
||||
#### Scenario: 用户首次通过微信授权码登录成功
|
||||
- **WHEN** 用户在前端完成微信授权,后端接收到有效的授权码(code)
|
||||
- **THEN** 系统调用微信 API 获取用户 OpenID、UnionID 和基本信息(昵称、头像)
|
||||
- **THEN** 系统在数据库中创建新的个人客户记录,保存微信 OpenID 和 UnionID
|
||||
- **THEN** 系统生成 JWT Token 并返回给客户端
|
||||
|
||||
#### Scenario: 已存在的微信用户再次登录
|
||||
- **WHEN** 用户通过微信授权码登录,且该 OpenID 已存在于数据库
|
||||
- **THEN** 系统查询到现有客户记录
|
||||
- **THEN** 系统更新客户的昵称和头像信息(保持最新)
|
||||
- **THEN** 系统生成 JWT Token 并返回给客户端
|
||||
|
||||
#### Scenario: 微信授权码无效或过期
|
||||
- **WHEN** 用户提交的授权码无效、过期或已被使用
|
||||
- **THEN** 系统调用微信 API 失败
|
||||
- **THEN** 系统返回错误码 1040(微信 OAuth 授权失败)和中文错误消息"微信授权失败,请重试"
|
||||
|
||||
#### Scenario: 微信 API 服务不可用
|
||||
- **WHEN** 调用微信 API 时发生网络超时或微信服务异常
|
||||
- **THEN** 系统记录详细的错误日志(包含 Request ID)
|
||||
- **THEN** 系统返回错误码 1040(微信 OAuth 授权失败)和用户友好的中文错误消息
|
||||
|
||||
### Requirement: 系统必须支持已有账号绑定微信
|
||||
|
||||
系统 SHALL 允许已注册的个人客户(通过手机号登录)绑定微信账号。
|
||||
|
||||
#### Scenario: 用户成功绑定微信账号
|
||||
- **WHEN** 已登录用户提交有效的微信授权码,且该用户尚未绑定微信
|
||||
- **THEN** 系统调用微信 API 获取 OpenID 和 UnionID
|
||||
- **THEN** 系统验证该 OpenID 未被其他用户绑定
|
||||
- **THEN** 系统更新该用户的 wx_open_id 和 wx_union_id 字段
|
||||
- **THEN** 系统返回成功响应和更新后的用户信息
|
||||
|
||||
#### Scenario: 尝试绑定已被使用的微信账号
|
||||
- **WHEN** 用户提交的微信授权码对应的 OpenID 已被其他用户绑定
|
||||
- **THEN** 系统返回错误码 1036(微信账号已被绑定)和中文错误消息"该微信账号已绑定其他用户"
|
||||
|
||||
#### Scenario: 用户已绑定微信后再次绑定
|
||||
- **WHEN** 已绑定微信的用户再次提交微信授权码
|
||||
- **THEN** 系统更新用户的昵称和头像信息
|
||||
- **THEN** 系统返回成功响应(允许更新信息,不报错)
|
||||
|
||||
### Requirement: 系统必须支持通过 OpenID/UnionID 查询用户
|
||||
|
||||
系统 MUST 提供通过微信 OpenID 或 UnionID 查询个人客户的能力。
|
||||
|
||||
#### Scenario: 通过 OpenID 查询到用户
|
||||
- **WHEN** 调用 Store 层的 GetByWxOpenID 方法,传入有效的 OpenID
|
||||
- **THEN** 系统返回对应的个人客户记录
|
||||
|
||||
#### Scenario: 通过 OpenID 查询不到用户
|
||||
- **WHEN** 调用 Store 层的 GetByWxOpenID 方法,传入不存在的 OpenID
|
||||
- **THEN** 系统返回 nil(无错误,表示用户不存在)
|
||||
|
||||
#### Scenario: 通过 UnionID 查询到用户
|
||||
- **WHEN** 调用 Store 层的 GetByWxUnionID 方法,传入有效的 UnionID
|
||||
- **THEN** 系统返回对应的个人客户记录
|
||||
|
||||
### Requirement: 系统必须实现 Access Token 中控
|
||||
|
||||
系统 MUST 使用 Redis 缓存微信 Access Token,支持多实例共享,避免重复获取导致超出每日限额。
|
||||
|
||||
#### Scenario: 首次获取 Access Token
|
||||
- **WHEN** 系统首次调用微信 API 需要 Access Token
|
||||
- **THEN** 系统调用微信 API 获取 Access Token
|
||||
- **THEN** 系统将 Token 存储到 Redis(Key: `powerwechat.access_token.{MD5(appid+secret)}`,TTL: 7200秒)
|
||||
- **THEN** 系统使用该 Token 完成 API 调用
|
||||
|
||||
#### Scenario: 从 Redis 缓存获取 Token
|
||||
- **WHEN** 系统调用微信 API,Redis 中存在有效的 Access Token
|
||||
- **THEN** 系统直接使用缓存的 Token,不调用微信 API 获取新 Token
|
||||
|
||||
#### Scenario: Access Token 过期后自动刷新
|
||||
- **WHEN** 系统使用缓存的 Token 调用微信 API 返回 Token 过期错误
|
||||
- **THEN** 系统自动重新获取 Access Token
|
||||
- **THEN** 系统更新 Redis 缓存
|
||||
- **THEN** 系统重试原 API 调用
|
||||
|
||||
### Requirement: API 必须遵循统一响应格式
|
||||
|
||||
所有微信相关 API MUST 返回统一的 JSON 响应格式。
|
||||
|
||||
#### Scenario: 成功响应格式
|
||||
- **WHEN** API 调用成功
|
||||
- **THEN** 系统返回 HTTP 200 和以下 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": { /* 业务数据 */ },
|
||||
"timestamp": 1706789012345
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 失败响应格式
|
||||
- **WHEN** API 调用失败(参数错误、业务逻辑错误、微信 API 错误)
|
||||
- **THEN** 系统返回对应的 HTTP 状态码(400/401/500)和以下 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"code": 1040,
|
||||
"message": "微信授权失败,请重试",
|
||||
"data": null,
|
||||
"timestamp": 1706789012345
|
||||
}
|
||||
```
|
||||
|
||||
### Requirement: 系统必须记录完整的日志
|
||||
|
||||
所有微信 API 调用 MUST 记录完整的日志,便于排查问题。
|
||||
|
||||
#### Scenario: 记录微信 API 请求日志
|
||||
- **WHEN** 系统调用微信 API
|
||||
- **THEN** 系统记录 INFO 级别日志,包含:Request ID、API 端点、请求参数(脱敏)
|
||||
|
||||
#### Scenario: 记录微信 API 响应日志
|
||||
- **WHEN** 系统收到微信 API 响应
|
||||
- **THEN** 系统记录 INFO 级别日志,包含:Request ID、响应状态、响应时间、关键字段
|
||||
|
||||
#### Scenario: 记录微信 API 错误日志
|
||||
- **WHEN** 微信 API 调用失败
|
||||
- **THEN** 系统记录 ERROR 级别日志,包含:Request ID、错误码、错误消息、完整的错误详情
|
||||
|
||||
### Requirement: 系统必须支持配置管理
|
||||
|
||||
微信公众号相关配置 MUST 通过 Viper + 环境变量管理。
|
||||
|
||||
#### Scenario: 从环境变量读取配置
|
||||
- **WHEN** 系统启动时
|
||||
- **THEN** 系统从环境变量读取以下配置:
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID`(公众号 AppID)
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET`(公众号 AppSecret)
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN`(回调 Token)
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY`(回调加密密钥)
|
||||
- `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL`(OAuth 回调地址)
|
||||
|
||||
#### Scenario: 配置缺失时启动失败
|
||||
- **WHEN** 必填配置项(AppID、AppSecret)缺失
|
||||
- **THEN** 系统记录 FATAL 级别日志
|
||||
- **THEN** 系统启动失败并退出
|
||||
@@ -0,0 +1,229 @@
|
||||
#微信支付能力规格说明
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 系统必须支持 JSAPI 支付
|
||||
|
||||
系统 MUST 支持在微信内网页发起 JSAPI 支付,用户在微信客户端内完成支付。
|
||||
|
||||
#### Scenario: 用户在微信内成功发起支付
|
||||
- **WHEN** 用户在微信内选择订单并点击"微信支付",前端调用 `/api/h5/orders/:id/wechat-pay/jsapi` 端点,传入用户 OpenID
|
||||
- **THEN** 系统验证订单状态为 `pending`(待支付)
|
||||
- **THEN** 系统调用 PowerWeChat SDK 的 `Order.JSAPITransaction()` 创建支付订单
|
||||
- **THEN** 系统生成 JSSDK 支付配置(包含 prepay_id、timestamp、nonceStr、paySign)
|
||||
- **THEN** 系统返回支付配置给前端
|
||||
- **THEN** 前端调用 `wx.requestPayment()` 唤起微信支付
|
||||
|
||||
#### Scenario: 订单不存在或状态不正确
|
||||
- **WHEN** 用户提交的订单 ID 不存在,或订单状态不是 `pending`
|
||||
- **THEN** 系统返回错误码 1000(参数错误)和中文错误消息"订单不存在或不可支付"
|
||||
|
||||
#### Scenario: 订单金额为 0
|
||||
- **WHEN** 订单金额为 0 元
|
||||
- **THEN** 系统跳过微信支付,直接更新订单状态为 `paid`
|
||||
- **THEN** 系统触发套餐激活和分佣计算
|
||||
|
||||
#### Scenario: 微信支付 API 调用失败
|
||||
- **WHEN** 调用 PowerWeChat SDK 创建支付订单时失败(网络超时、参数错误等)
|
||||
- **THEN** 系统记录详细的错误日志(Request ID、错误码、错误消息)
|
||||
- **THEN** 系统返回错误码 1042(微信支付发起失败)和中文错误消息"支付发起失败,请重试"
|
||||
|
||||
### Requirement: 系统必须支持 H5 支付
|
||||
|
||||
系统 MUST 支持在移动端浏览器外发起 H5 支付,用户可唤起微信 APP 完成支付。
|
||||
|
||||
#### Scenario: 用户在浏览器中成功发起 H5 支付
|
||||
- **WHEN** 用户在移动端浏览器选择订单并点击"微信支付",前端调用 `/api/h5/orders/:id/wechat-pay/h5` 端点,传入用户终端 IP 和场景信息
|
||||
- **THEN** 系统验证订单状态为 `pending`
|
||||
- **THEN** 系统调用 PowerWeChat SDK 的 `Order.TransactionH5()` 创建 H5 支付订单
|
||||
- **THEN** 系统返回微信支付跳转 URL(h5_url)
|
||||
- **THEN** 前端跳转到该 URL,用户在微信 H5 页面完成支付
|
||||
|
||||
#### Scenario: 缺少必填参数
|
||||
- **WHEN** 请求缺少 `payer_client_ip` 或 `scene_info` 参数
|
||||
- **THEN** 系统返回错误码 1000(参数错误)和中文错误消息"缺少必填参数"
|
||||
|
||||
#### Scenario: 订单已支付
|
||||
- **WHEN** 用户提交的订单状态已是 `paid`
|
||||
- **THEN** 系统返回错误码 1000(参数错误)和中文错误消息"订单已支付"
|
||||
|
||||
### Requirement: 系统必须支持微信支付回调
|
||||
|
||||
系统 SHALL 接收并处理微信支付成功通知,更新订单状态并触发后续业务逻辑。
|
||||
|
||||
#### Scenario: 接收到合法的支付成功通知
|
||||
- **WHEN** 微信回调 `/api/callback/wechat-pay` 端点,传入支付成功通知
|
||||
- **THEN** PowerWeChat SDK 自动验证回调签名
|
||||
- **THEN** 系统解析通知内容,提取商户订单号(out_trade_no)
|
||||
- **THEN** 系统调用 `orderService.HandlePaymentCallback()` 更新订单状态为 `paid`(幂等处理)
|
||||
- **THEN** 系统触发套餐激活和分佣计算
|
||||
- **THEN** 系统返回 HTTP 200 和 `{"return_code": "SUCCESS"}` 给微信
|
||||
|
||||
#### Scenario: 接收到重复的支付通知
|
||||
- **WHEN** 微信多次发送同一订单的支付成功通知
|
||||
- **THEN** 系统通过幂等检查识别订单已支付
|
||||
- **THEN** 系统直接返回成功响应,不重复处理业务逻辑
|
||||
|
||||
#### Scenario: 回调签名验证失败
|
||||
- **WHEN** 微信回调的签名无效或被篡改
|
||||
- **THEN** PowerWeChat SDK 自动拒绝该请求
|
||||
- **THEN** 系统记录 ERROR 级别日志(Request ID、签名验证失败详情)
|
||||
- **THEN** 系统返回 HTTP 400 错误
|
||||
|
||||
#### Scenario: 订单号不存在
|
||||
- **WHEN** 微信回调中的商户订单号在系统中不存在
|
||||
- **THEN** 系统记录 ERROR 级别日志
|
||||
- **THEN** 系统返回失败响应给微信(让微信稍后重试)
|
||||
|
||||
#### Scenario: 支付回调处理失败
|
||||
- **WHEN** 系统在处理支付回调时发生数据库错误或其他异常
|
||||
- **THEN** 系统记录 ERROR 级别日志(Request ID、错误详情)
|
||||
- **THEN** 系统返回失败响应给微信(让微信稍后重试)
|
||||
|
||||
### Requirement: 支付回调处理必须幂等
|
||||
|
||||
系统 MUST 确保多次接收到同一支付通知时,业务逻辑只执行一次。
|
||||
|
||||
#### Scenario: 订单状态条件更新
|
||||
- **WHEN** 系统更新订单状态为 `paid`
|
||||
- **THEN** 系统使用条件更新:`UPDATE ... WHERE id = ? AND payment_status = ?`(只更新状态为 pending 的订单)
|
||||
- **THEN** 如果更新影响行数为 0,系统检查当前订单状态:
|
||||
- 如果已支付,返回成功(幂等)
|
||||
- 如果已取消/已退款,返回错误
|
||||
|
||||
#### Scenario: 套餐激活幂等性
|
||||
- **WHEN** 订单支付成功后触发套餐激活
|
||||
- **THEN** 系统检查 `tb_package_usage` 表是否已存在该订单的激活记录
|
||||
- **THEN** 如果已存在,跳过激活逻辑(幂等)
|
||||
|
||||
### Requirement: 系统必须支持查询微信支付订单
|
||||
|
||||
系统 SHALL 支持根据商户订单号查询微信支付订单状态。
|
||||
|
||||
#### Scenario: 查询到支付成功的订单
|
||||
- **WHEN** 调用 `PaymentService.Order.QueryByOutTradeNumber()` 查询订单
|
||||
- **THEN** 系统返回订单详情,包含:
|
||||
- 订单号(out_trade_no)
|
||||
- 微信支付单号(transaction_id)
|
||||
- 支付状态(trade_state: SUCCESS)
|
||||
- 支付时间(success_time)
|
||||
- 支付金额(total)
|
||||
|
||||
#### Scenario: 查询到待支付的订单
|
||||
- **WHEN** 查询的订单尚未支付
|
||||
- **THEN** 系统返回订单详情,支付状态为 `NOTPAY`
|
||||
|
||||
#### Scenario: 查询不存在的订单
|
||||
- **WHEN** 查询的商户订单号在微信侧不存在
|
||||
- **THEN** PowerWeChat SDK 返回错误
|
||||
- **THEN** 系统记录日志并返回错误码 1042
|
||||
|
||||
### Requirement: 系统必须支持关闭未支付订单
|
||||
|
||||
系统 SHALL 支持关闭超时未支付的微信订单。
|
||||
|
||||
#### Scenario: 成功关闭未支付订单
|
||||
- **WHEN** 调用 `PaymentService.Order.Close()` 关闭订单,传入商户订单号
|
||||
- **THEN** 系统调用微信 API 关闭订单
|
||||
- **THEN** 系统返回成功响应
|
||||
|
||||
#### Scenario: 尝试关闭已支付订单
|
||||
- **WHEN** 调用关闭接口,但订单已支付
|
||||
- **THEN** 微信 API 返回错误(订单已支付,无法关闭)
|
||||
- **THEN** 系统记录日志并返回错误
|
||||
|
||||
#### Scenario: 订单创建后 5 分钟内关闭
|
||||
- **WHEN** 订单创建后不足 5 分钟就调用关闭接口
|
||||
- **THEN** 系统可能因订单状态同步不及时而关闭失败
|
||||
- **THEN** 系统建议在创建 5 分钟后再关闭
|
||||
|
||||
### Requirement: 系统必须支持配置管理
|
||||
|
||||
微信支付相关配置 MUST 通过 Viper + 环境变量管理。
|
||||
|
||||
#### Scenario: 从环境变量读取配置
|
||||
- **WHEN** 系统启动时
|
||||
- **THEN** 系统从环境变量读取以下配置:
|
||||
- `JUNHONG_WECHAT_PAYMENT_APP_ID`(支付 AppID)
|
||||
- `JUNHONG_WECHAT_PAYMENT_MCH_ID`(商户号)
|
||||
- `JUNHONG_WECHAT_PAYMENT_API_V3_KEY`(API V3 密钥)
|
||||
- `JUNHONG_WECHAT_PAYMENT_API_V2_KEY`(API V2 密钥)
|
||||
- `JUNHONG_WECHAT_PAYMENT_CERT_PATH`(商户证书路径)
|
||||
- `JUNHONG_WECHAT_PAYMENT_KEY_PATH`(商户私钥路径)
|
||||
- `JUNHONG_WECHAT_PAYMENT_SERIAL_NO`(证书序列号)
|
||||
- `JUNHONG_WECHAT_PAYMENT_NOTIFY_URL`(支付回调地址)
|
||||
|
||||
#### Scenario: 证书文件不存在时启动失败
|
||||
- **WHEN** 配置的证书路径指向的文件不存在或无读取权限
|
||||
- **THEN** 系统记录 FATAL 级别日志
|
||||
- **THEN** 系统启动失败并退出
|
||||
|
||||
#### Scenario: 必填配置缺失时启动失败
|
||||
- **WHEN** 必填配置项(AppID、商户号、API 密钥)缺失
|
||||
- **THEN** 系统记录 FATAL 级别日志
|
||||
- **THEN** 系统启动失败并退出
|
||||
|
||||
### Requirement: API 必须遵循统一响应格式
|
||||
|
||||
所有微信支付相关 API MUST 返回统一的 JSON 响应格式(同微信公众号规范)。
|
||||
|
||||
#### Scenario: 支付发起成功响应
|
||||
- **WHEN** JSAPI 支付发起成功
|
||||
- **THEN** 系统返回 HTTP 200 和以下格式:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"prepay_id": "wx...",
|
||||
"pay_config": {
|
||||
"appId": "...",
|
||||
"timeStamp": "...",
|
||||
"nonceStr": "...",
|
||||
"package": "prepay_id=...",
|
||||
"signType": "RSA",
|
||||
"paySign": "..."
|
||||
}
|
||||
},
|
||||
"timestamp": 1706789012345
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: H5 支付发起成功响应
|
||||
- **WHEN** H5 支付发起成功
|
||||
- **THEN** 系统返回 HTTP 200 和以下格式:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?..."
|
||||
},
|
||||
"timestamp": 1706789012345
|
||||
}
|
||||
```
|
||||
|
||||
### Requirement: 系统必须记录完整的日志
|
||||
|
||||
所有微信支付 API 调用 MUST 记录完整的日志。
|
||||
|
||||
#### Scenario: 记录支付发起日志
|
||||
- **WHEN** 系统调用微信支付 API 创建订单
|
||||
- **THEN** 系统记录 INFO 级别日志,包含:Request ID、订单号、支付类型(JSAPI/H5)、订单金额
|
||||
|
||||
#### Scenario: 记录支付回调日志
|
||||
- **WHEN** 系统收到微信支付回调
|
||||
- **THEN** 系统记录 INFO 级别日志,包含:Request ID、订单号、微信支付单号、支付时间
|
||||
|
||||
#### Scenario: 记录支付错误日志
|
||||
- **WHEN** 微信支付 API 调用失败
|
||||
- **THEN** 系统记录 ERROR 级别日志,包含:Request ID、订单号、错误码、错误消息、完整的错误详情
|
||||
|
||||
### Requirement: 系统必须支持 Redis 缓存
|
||||
|
||||
微信支付的 Access Token MUST 使用 Redis 缓存(与微信公众号共享同一缓存机制)。
|
||||
|
||||
#### Scenario: Token 缓存与公众号共享
|
||||
- **WHEN** 微信支付和公众号使用相同的 AppID
|
||||
- **THEN** 系统复用同一个 Redis Cache 实例
|
||||
- **THEN** Token 缓存 Key 相同,避免重复获取
|
||||
@@ -0,0 +1,257 @@
|
||||
# 微信公众号与微信支付集成 - 任务清单
|
||||
|
||||
## 1. 依赖安装和配置准备
|
||||
|
||||
- [ ] 1.1 安装 PowerWeChat v3 SDK:`go get -u github.com/ArtisanCloud/PowerWeChat/v3`
|
||||
- [ ] 1.2 在 `pkg/config/defaults/config.yaml` 中新增微信配置结构(wechat.official_account、wechat.payment)
|
||||
- [ ] 1.3 在 `pkg/config/config.go` 中定义微信配置结构体(WechatConfig、OfficialAccountConfig、PaymentConfig)
|
||||
- [ ] 1.4 在 `docs/environment-variables.md` 中添加微信相关环境变量说明
|
||||
|
||||
## 2. 错误码定义
|
||||
|
||||
- [ ] 2.1 在 `pkg/errors/codes.go` 中新增微信相关错误码(1040-1049)
|
||||
- [ ] 2.2 在 `pkg/errors/messages.go` 中添加对应的中英文错误消息
|
||||
|
||||
## 3. 微信服务基础设施(pkg/wechat)
|
||||
|
||||
- [ ] 3.1 实现 `pkg/wechat/config.go` - 创建 PowerWeChat 配置初始化函数
|
||||
- [ ] 3.2 实现 `pkg/wechat/official_account.go` - OfficialAccount 服务实现
|
||||
- [ ] 3.2.1 实现 `NewOfficialAccountService()` 初始化函数(集成 Redis 缓存)
|
||||
- [ ] 3.2.2 实现 `GetUserInfo(ctx, code)` 方法(调用 OAuth.UserFromCode)
|
||||
- [ ] 3.2.3 实现 `GetUserInfoByToken(ctx, accessToken, openID)` 方法
|
||||
- [ ] 3.3 实现 `pkg/wechat/payment.go` - Payment 服务实现
|
||||
- [ ] 3.3.1 实现 `NewPaymentService()` 初始化函数(集成 Redis 缓存)
|
||||
- [ ] 3.3.2 实现 `CreateJSAPIOrder(ctx, params)` 方法(JSAPI 支付下单)
|
||||
- [ ] 3.3.3 实现 `CreateH5Order(ctx, params)` 方法(H5 支付下单)
|
||||
- [ ] 3.3.4 实现 `QueryOrder(ctx, orderNo)` 方法(查询订单状态)
|
||||
- [ ] 3.3.5 实现 `CloseOrder(ctx, orderNo)` 方法(关闭订单)
|
||||
- [ ] 3.3.6 实现 `HandlePaymentNotify(request, callback)` 方法(支付回调处理)
|
||||
- [ ] 3.4 实现 `pkg/wechat/wechat.go` - 更新 Service 接口定义(保持向后兼容)
|
||||
- [ ] 3.5 删除 `pkg/wechat/mock.go`(替换为真实实现)
|
||||
|
||||
## 4. 配置验证和启动检查
|
||||
|
||||
- [ ] 4.1 在 `cmd/api/main.go` 中添加微信配置验证逻辑
|
||||
- [ ] 4.2 验证证书文件存在性和可读性(cert_path、key_path)
|
||||
- [ ] 4.3 验证必填配置项(AppID、AppSecret、商户号、API 密钥)
|
||||
- [ ] 4.4 配置缺失或证书文件不存在时记录 FATAL 日志并退出
|
||||
|
||||
## 5. DTO 定义
|
||||
|
||||
- [ ] 5.1 在 `internal/model/dto/wechat_dto.go` 中定义微信相关 DTO
|
||||
- [ ] 5.1.1 定义 `WechatOAuthRequest`(code)
|
||||
- [ ] 5.1.2 定义 `WechatOAuthResponse`(token、customer)
|
||||
- [ ] 5.1.3 定义 `WechatPayJSAPIRequest`(openid)
|
||||
- [ ] 5.1.4 定义 `WechatPayJSAPIResponse`(prepay_id、pay_config)
|
||||
- [ ] 5.1.5 定义 `WechatPayH5Request`(scene_info)
|
||||
- [ ] 5.1.6 定义 `WechatPayH5Response`(h5_url)
|
||||
- [ ] 5.1.7 添加 `description` 标签和验证标签(validate)
|
||||
|
||||
## 6. Service 层实现 - 个人客户服务
|
||||
|
||||
- [ ] 6.1 修改 `internal/service/personal_customer/service.go`
|
||||
- [ ] 6.1.1 添加 `wechatService wechat.Service` 字段(依赖注入)
|
||||
- [ ] 6.1.2 实现 `WechatOAuthLogin(ctx, code)` 方法
|
||||
- [ ] 6.1.2.1 调用 `wechatService.GetUserInfo()` 获取 OpenID/UnionID
|
||||
- [ ] 6.1.2.2 通过 OpenID 查询客户(Store.GetByWxOpenID)
|
||||
- [ ] 6.1.2.3 如果客户不存在,创建新客户
|
||||
- [ ] 6.1.2.4 如果客户存在,更新昵称和头像
|
||||
- [ ] 6.1.2.5 生成 JWT Token 并返回
|
||||
- [ ] 6.1.3 修改现有 `BindWechat(ctx, customerID, code)` 方法
|
||||
- [ ] 6.1.3.1 调用 `wechatService.GetUserInfo()` 获取 OpenID/UnionID
|
||||
- [ ] 6.1.3.2 验证 OpenID 未被其他用户绑定
|
||||
- [ ] 6.1.3.3 更新客户的 wx_open_id 和 wx_union_id
|
||||
- [ ] 6.1.3.4 更新昵称和头像
|
||||
|
||||
## 7. Service 层实现 - 订单服务
|
||||
|
||||
- [ ] 7.1 修改 `internal/service/order/service.go`
|
||||
- [ ] 7.1.1 添加 `wechatPayment wechat.PaymentService` 字段(依赖注入)
|
||||
- [ ] 7.1.2 实现 `WechatPayJSAPI(ctx, orderID, openID)` 方法
|
||||
- [ ] 7.1.2.1 查询订单并验证状态为 `pending`
|
||||
- [ ] 7.1.2.2 调用 `wechatPayment.CreateJSAPIOrder()` 创建支付订单
|
||||
- [ ] 7.1.2.3 生成 JSSDK 支付配置
|
||||
- [ ] 7.1.2.4 返回 prepay_id 和 pay_config
|
||||
- [ ] 7.1.3 实现 `WechatPayH5(ctx, orderID, sceneInfo)` 方法
|
||||
- [ ] 7.1.3.1 查询订单并验证状态为 `pending`
|
||||
- [ ] 7.1.3.2 调用 `wechatPayment.CreateH5Order()` 创建支付订单
|
||||
- [ ] 7.1.3.3 返回 h5_url
|
||||
- [ ] 7.1.4 修改现有 `HandlePaymentCallback(ctx, orderNo, paymentMethod)` 方法(保持幂等逻辑不变)
|
||||
|
||||
## 8. Handler 层实现 - 个人客户 Handler
|
||||
|
||||
- [ ] 8.1 修改 `internal/handler/app/personal_customer.go`
|
||||
- [ ] 8.1.1 实现 `WechatOAuthLogin(c *fiber.Ctx)` 方法(POST /api/c/v1/wechat/auth)
|
||||
- [ ] 8.1.1.1 解析请求参数(code)
|
||||
- [ ] 8.1.1.2 调用 `service.WechatOAuthLogin()`
|
||||
- [ ] 8.1.1.3 返回 JWT Token 和客户信息
|
||||
- [ ] 8.1.2 修改 `BindWechat(c *fiber.Ctx)` 方法(POST /api/c/v1/bind-wechat)
|
||||
- [ ] 8.1.2.1 从 context 获取 customer_id
|
||||
- [ ] 8.1.2.2 解析请求参数(code)
|
||||
- [ ] 8.1.2.3 调用 `service.BindWechat()`
|
||||
- [ ] 8.1.2.4 返回成功响应
|
||||
|
||||
## 9. Handler 层实现 - H5 订单 Handler
|
||||
|
||||
- [ ] 9.1 修改 `internal/handler/h5/order.go`
|
||||
- [ ] 9.1.1 实现 `WechatPayJSAPI(c *fiber.Ctx)` 方法(POST /api/h5/orders/:id/wechat-pay/jsapi)
|
||||
- [ ] 9.1.1.1 解析路径参数(order_id)
|
||||
- [ ] 9.1.1.2 解析请求参数(openid)
|
||||
- [ ] 9.1.1.3 调用 `orderService.WechatPayJSAPI()`
|
||||
- [ ] 9.1.1.4 返回支付配置
|
||||
- [ ] 9.1.2 实现 `WechatPayH5(c *fiber.Ctx)` 方法(POST /api/h5/orders/:id/wechat-pay/h5)
|
||||
- [ ] 9.1.2.1 解析路径参数(order_id)
|
||||
- [ ] 9.1.2.2 解析请求参数(scene_info)
|
||||
- [ ] 9.1.2.3 调用 `orderService.WechatPayH5()`
|
||||
- [ ] 9.1.2.4 返回 h5_url
|
||||
|
||||
## 10. Handler 层实现 - 支付回调 Handler
|
||||
|
||||
- [ ] 10.1 修改 `internal/handler/callback/payment.go`
|
||||
- [ ] 10.1.1 添加 `wechatPayment wechat.PaymentService` 字段(依赖注入)
|
||||
- [ ] 10.1.2 重构 `WechatPayCallback(c *fiber.Ctx)` 方法
|
||||
- [ ] 10.1.2.1 调用 `wechatPayment.HandlePaymentNotify()` 自动验证签名
|
||||
- [ ] 10.1.2.2 在回调函数中提取订单号
|
||||
- [ ] 10.1.2.3 调用 `orderService.HandlePaymentCallback()` 更新订单状态
|
||||
- [ ] 10.1.2.4 返回 PowerWeChat 格式的响应
|
||||
|
||||
## 11. 路由注册
|
||||
|
||||
- [ ] 11.1 修改 `internal/routes/personal.go`
|
||||
- [ ] 11.1.1 添加公开路由:POST /api/c/v1/wechat/auth(WechatOAuthLogin)
|
||||
- [ ] 11.1.2 保留现有认证路由:POST /api/c/v1/bind-wechat(BindWechat)
|
||||
- [ ] 11.2 修改 `internal/routes/order.go`
|
||||
- [ ] 11.2.1 添加 H5 认证路由:POST /api/h5/orders/:id/wechat-pay/jsapi
|
||||
- [ ] 11.2.2 添加 H5 认证路由:POST /api/h5/orders/:id/wechat-pay/h5
|
||||
- [ ] 11.2.3 保留回调路由(无认证):POST /api/callback/wechat-pay
|
||||
|
||||
## 12. 依赖注入和初始化
|
||||
|
||||
- [ ] 12.1 修改 `internal/bootstrap/services.go`
|
||||
- [ ] 12.1.1 初始化 `wechat.OfficialAccountService`(传入 config、Redis client、logger)
|
||||
- [ ] 12.1.2 初始化 `wechat.PaymentService`(传入 config、Redis client、logger)
|
||||
- [ ] 12.1.3 将微信服务注入到 `PersonalCustomerService`
|
||||
- [ ] 12.1.4 将微信支付服务注入到 `OrderService`
|
||||
- [ ] 12.2 修改 `internal/bootstrap/handlers.go`
|
||||
- [ ] 12.2.1 将微信支付服务注入到 `PaymentHandler`
|
||||
|
||||
## 13. 文档生成器更新
|
||||
|
||||
- [ ] 13.1 修改 `cmd/api/docs.go`
|
||||
- [ ] 13.1.1 在 `handlers` 结构体中添加新 Handler 的占位符(如需要)
|
||||
- [ ] 13.1.2 更新文档路由注册
|
||||
- [ ] 13.2 修改 `cmd/gendocs/main.go`
|
||||
- [ ] 13.2.1 同步更新文档生成器的 Handler 初始化
|
||||
|
||||
## 14. 单元测试
|
||||
|
||||
- [ ] 14.1 测试 `pkg/wechat/official_account.go`
|
||||
- [ ] 14.1.1 测试 `GetUserInfo()` 成功获取用户信息
|
||||
- [ ] 14.1.2 测试授权码无效时的错误处理
|
||||
- [ ] 14.1.3 测试 Access Token 缓存机制
|
||||
- [ ] 14.2 测试 `pkg/wechat/payment.go`
|
||||
- [ ] 14.2.1 测试 `CreateJSAPIOrder()` 成功创建订单
|
||||
- [ ] 14.2.2 测试 `CreateH5Order()` 成功创建订单
|
||||
- [ ] 14.2.3 测试 `HandlePaymentNotify()` 签名验证
|
||||
- [ ] 14.2.4 测试支付回调幂等性
|
||||
- [ ] 14.3 测试 `internal/service/personal_customer/service.go`
|
||||
- [ ] 14.3.1 测试 `WechatOAuthLogin()` 首次登录创建客户
|
||||
- [ ] 14.3.2 测试 `WechatOAuthLogin()` 已有客户更新信息
|
||||
- [ ] 14.3.3 测试 `BindWechat()` 成功绑定
|
||||
- [ ] 14.3.4 测试 `BindWechat()` OpenID 已被绑定
|
||||
- [ ] 14.4 测试 `internal/service/order/service.go`
|
||||
- [ ] 14.4.1 测试 `WechatPayJSAPI()` 成功发起支付
|
||||
- [ ] 14.4.2 测试 `WechatPayH5()` 成功发起支付
|
||||
- [ ] 14.4.3 测试订单状态不正确时的错误处理
|
||||
|
||||
## 15. 集成测试
|
||||
|
||||
- [ ] 15.1 测试个人客户微信登录完整流程
|
||||
- [ ] 15.1.1 测试 `POST /api/c/v1/wechat/auth` 端点(Mock 微信 OAuth)
|
||||
- [ ] 15.1.2 验证返回 JWT Token 和客户信息
|
||||
- [ ] 15.1.3 验证数据库中客户记录正确创建/更新
|
||||
- [ ] 15.2 测试微信绑定流程
|
||||
- [ ] 15.2.1 测试 `POST /api/c/v1/bind-wechat` 端点
|
||||
- [ ] 15.2.2 验证绑定成功后 wx_open_id 更新
|
||||
- [ ] 15.3 测试 JSAPI 支付流程
|
||||
- [ ] 15.3.1 测试 `POST /api/h5/orders/:id/wechat-pay/jsapi` 端点
|
||||
- [ ] 15.3.2 验证返回 prepay_id 和 pay_config
|
||||
- [ ] 15.4 测试 H5 支付流程
|
||||
- [ ] 15.4.1 测试 `POST /api/h5/orders/:id/wechat-pay/h5` 端点
|
||||
- [ ] 15.4.2 验证返回 h5_url
|
||||
- [ ] 15.5 测试微信支付回调流程
|
||||
- [ ] 15.5.1 测试 `POST /api/callback/wechat-pay` 端点(Mock 微信签名)
|
||||
- [ ] 15.5.2 验证订单状态更新为 `paid`
|
||||
- [ ] 15.5.3 验证套餐激活和分佣计算触发
|
||||
- [ ] 15.5.4 测试重复回调的幂等性
|
||||
|
||||
## 16. 代码质量检查
|
||||
|
||||
- [ ] 16.1 运行 `go fmt` 格式化所有新增代码
|
||||
- [ ] 16.2 运行 `go vet` 检查代码问题
|
||||
- [ ] 16.3 运行 `golangci-lint` 检查代码规范
|
||||
- [ ] 16.4 检查所有注释使用中文
|
||||
- [ ] 16.5 检查所有错误处理使用 `pkg/errors`
|
||||
- [ ] 16.6 检查所有常量定义在 `pkg/constants/`
|
||||
|
||||
## 17. 文档更新
|
||||
|
||||
- [ ] 17.1 创建 `docs/wechat-integration/使用指南.md`
|
||||
- [ ] 17.1.1 微信公众号配置说明(AppID、AppSecret、OAuth 回调域名)
|
||||
- [ ] 17.1.2 微信支付配置说明(商户号、证书、回调 URL)
|
||||
- [ ] 17.1.3 证书文件获取和安装流程
|
||||
- [ ] 17.1.4 环境变量配置示例
|
||||
- [ ] 17.2 创建 `docs/wechat-integration/API 文档.md`
|
||||
- [ ] 17.2.1 微信 OAuth 登录 API 说明
|
||||
- [ ] 17.2.2 微信支付 API 说明(JSAPI + H5)
|
||||
- [ ] 17.2.3 请求/响应示例
|
||||
- [ ] 17.3 更新 `README.md`
|
||||
- [ ] 17.3.1 在核心功能章节添加微信集成说明
|
||||
- [ ] 17.3.2 更新技术栈章节(新增 PowerWeChat)
|
||||
- [ ] 17.4 更新 `docs/environment-variables.md`
|
||||
- [ ] 17.4.1 添加所有微信相关环境变量
|
||||
- [ ] 17.5 更新 `openspec/AGENTS.md`(如需要)
|
||||
- [ ] 17.5.1 添加微信集成相关的开发规范
|
||||
|
||||
## 18. 部署准备
|
||||
|
||||
- [ ] 18.1 准备测试环境配置
|
||||
- [ ] 18.1.1 获取微信测试公众号 AppID 和 AppSecret
|
||||
- [ ] 18.1.2 获取微信支付测试商户号和证书
|
||||
- [ ] 18.1.3 配置微信后台白名单(OAuth 回调域名、支付回调 URL)
|
||||
- [ ] 18.2 准备生产环境配置
|
||||
- [ ] 18.2.1 获取正式公众号 AppID 和 AppSecret
|
||||
- [ ] 18.2.2 获取正式商户号和证书
|
||||
- [ ] 18.2.3 配置生产环境微信后台白名单
|
||||
- [ ] 18.3 创建证书管理文档
|
||||
- [ ] 18.3.1 证书过期提醒机制
|
||||
- [ ] 18.3.2 证书更新流程
|
||||
- [ ] 18.3.3 证书存储安全规范
|
||||
|
||||
## 19. 验证和测试
|
||||
|
||||
- [ ] 19.1 本地开发环境验证
|
||||
- [ ] 19.1.1 验证配置加载正确
|
||||
- [ ] 19.1.2 验证证书文件读取正常
|
||||
- [ ] 19.1.3 验证 Redis 缓存工作正常
|
||||
- [ ] 19.2 测试环境集成测试
|
||||
- [ ] 19.2.1 使用真实微信测试账号测试 OAuth 登录
|
||||
- [ ] 19.2.2 使用真实商户号测试 JSAPI 支付(0.01 元测试订单)
|
||||
- [ ] 19.2.3 使用真实商户号测试 H5 支付
|
||||
- [ ] 19.2.4 验证支付回调正常触发和处理
|
||||
- [ ] 19.3 压力测试
|
||||
- [ ] 19.3.1 测试并发支付请求(100 QPS)
|
||||
- [ ] 19.3.2 测试并发回调处理(50 QPS)
|
||||
- [ ] 19.3.3 验证 Redis Token 缓存不会频繁刷新
|
||||
|
||||
## 20. 监控和告警
|
||||
|
||||
- [ ] 20.1 添加监控指标
|
||||
- [ ] 20.1.1 微信 OAuth 成功率/失败率
|
||||
- [ ] 20.1.2 支付发起成功率/失败率
|
||||
- [ ] 20.1.3 支付回调接收数量/验证失败数量
|
||||
- [ ] 20.1.4 Access Token 获取次数
|
||||
- [ ] 20.2 配置告警规则(如有监控系统)
|
||||
- [ ] 20.2.1 微信 OAuth 失败率 > 10% 告警
|
||||
- [ ] 20.2.2 支付发起失败率 > 5% 告警
|
||||
- [ ] 20.2.3 支付回调验证失败数量 > 10/分钟 告警
|
||||
Reference in New Issue
Block a user