Compare commits

...

6 Commits

Author SHA1 Message Date
e461791a0e 解决冲突
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m7s
2026-01-30 18:09:31 +08:00
109ae688d2 解决冲突 2026-01-30 17:37:35 +08:00
65b4127b84 Merge branch 'emdash/wechat-official-account-payment-integration-30g'
# Conflicts:
#	README.md
#	cmd/api/main.go
#	internal/bootstrap/dependencies.go
#	pkg/config/config.go
#	pkg/config/defaults/config.yaml
2026-01-30 17:32:33 +08:00
bf591095a2 微信相关能力 2026-01-30 17:25:30 +08:00
accf7cb293 Merge branch 'emdash/login-prome-47c' 2026-01-30 17:23:33 +08:00
ffeb0417c0 登录权限返回修改 2026-01-30 17:22:38 +08:00
58 changed files with 6348 additions and 396 deletions

View File

@@ -194,13 +194,14 @@ default:
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
- **代理商体系**:层级管理和分佣结算
- **批量同步**:卡状态、实名状态、流量使用情况
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
- **对象存储**S3 兼容的对象存储服务集成(联通云 OSS支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
- **Gateway 客户端**:第三方 Gateway API 的 Go 封装,提供流量卡和设备管理的统一接口;内置 AES-128-ECB 加密、MD5 签名验证、HTTP 连接池管理;支持流量卡状态查询、停复机、实名认证、流量查询等 7 个流量卡接口和设备信息查询、卡槽管理、限速设置、WiFi 配置、切卡、重启、恢复出厂等 7 个设备管理接口;测试覆盖率 88.8%;详见 [使用指南](docs/gateway-client-usage.md) 和 [API 参考](docs/gateway-api-reference.md)
- **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能JSAPI + H5使用 PowerWeChat v3 SDK支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 [使用指南](docs/wechat-integration/使用指南.md) 和 [API 文档](docs/wechat-integration/API文档.md)
## 用户体系设计
@@ -870,6 +871,7 @@ rdb.Set(ctx, key, status, time.Hour)
- **sonic**:(高性能 JSON
- **Asynq**:(异步任务队列)
- **Validator**:(参数验证)
- **PowerWeChat**v3.4.38微信SDK - 公众号 & 支付)
## 开发流程Speckit

View File

@@ -41,26 +41,29 @@ func main() {
// 3. 初始化日志
appLogger := initLogger(cfg)
// 4. 验证微信配置
validateWechatConfig(cfg, appLogger)
defer func() {
_ = logger.Sync()
}()
// 4. 初始化数据库
// 5. 初始化数据库
db := initDatabase(cfg, appLogger)
defer closeDatabase(db, appLogger)
// 5. 初始化 Redis
// 6. 初始化 Redis
redisClient := initRedis(cfg, appLogger)
defer closeRedis(redisClient, appLogger)
// 6. 初始化队列客户端
// 7. 初始化队列客户端
queueClient := initQueue(redisClient, appLogger)
defer closeQueue(queueClient, appLogger)
// 7. 初始化认证管理器
// 8. 初始化认证管理器
jwtManager, tokenManager, verificationSvc := initAuthComponents(cfg, redisClient, appLogger)
// 8. 初始化对象存储服务(可选)
// 9. 初始化对象存储服务(可选)
storageSvc := initStorage(cfg, appLogger)
// 9. 初始化 Gateway 客户端(可选)
@@ -351,3 +354,64 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
return client
}
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
wechatCfg := cfg.Wechat
if wechatCfg.OfficialAccount.AppID == "" && wechatCfg.Payment.AppID == "" {
appLogger.Warn("微信配置未设置,微信相关功能将不可用")
return
}
if wechatCfg.OfficialAccount.AppID != "" {
if wechatCfg.OfficialAccount.AppSecret == "" {
appLogger.Fatal("微信公众号配置不完整",
zap.String("missing", "app_secret"),
zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET"))
}
appLogger.Info("微信公众号配置已验证",
zap.String("app_id", wechatCfg.OfficialAccount.AppID))
}
if wechatCfg.Payment.AppID != "" {
missingFields := []string{}
if wechatCfg.Payment.MchID == "" {
missingFields = append(missingFields, "mch_id (JUNHONG_WECHAT_PAYMENT_MCH_ID)")
}
if wechatCfg.Payment.APIV3Key == "" {
missingFields = append(missingFields, "api_v3_key (JUNHONG_WECHAT_PAYMENT_API_V3_KEY)")
}
if wechatCfg.Payment.CertPath == "" {
missingFields = append(missingFields, "cert_path (JUNHONG_WECHAT_PAYMENT_CERT_PATH)")
}
if wechatCfg.Payment.KeyPath == "" {
missingFields = append(missingFields, "key_path (JUNHONG_WECHAT_PAYMENT_KEY_PATH)")
}
if wechatCfg.Payment.SerialNo == "" {
missingFields = append(missingFields, "serial_no (JUNHONG_WECHAT_PAYMENT_SERIAL_NO)")
}
if wechatCfg.Payment.NotifyURL == "" {
missingFields = append(missingFields, "notify_url (JUNHONG_WECHAT_PAYMENT_NOTIFY_URL)")
}
if len(missingFields) > 0 {
appLogger.Fatal("微信支付配置不完整",
zap.Strings("missing_fields", missingFields))
}
if _, err := os.Stat(wechatCfg.Payment.CertPath); os.IsNotExist(err) {
appLogger.Fatal("微信支付证书文件不存在",
zap.String("cert_path", wechatCfg.Payment.CertPath))
}
if _, err := os.Stat(wechatCfg.Payment.KeyPath); os.IsNotExist(err) {
appLogger.Fatal("微信支付私钥文件不存在",
zap.String("key_path", wechatCfg.Payment.KeyPath))
}
appLogger.Info("微信支付配置已验证",
zap.String("app_id", wechatCfg.Payment.AppID),
zap.String("mch_id", wechatCfg.Payment.MchID))
}
}

View File

@@ -13,12 +13,18 @@ version: '3.8'
#
# 必填配置(缺失时服务无法启动):
# - JUNHONG_DATABASE_HOST
# - JUNHONG_DATABASE_PORT
# - JUNHONG_DATABASE_PORT
# - JUNHONG_DATABASE_USER
# - JUNHONG_DATABASE_PASSWORD
# - JUNHONG_DATABASE_DBNAME
# - JUNHONG_REDIS_ADDRESS
# - JUNHONG_JWT_SECRET_KEY
#
# 可选配置(根据需要启用):
# - Gateway 服务配置JUNHONG_GATEWAY_*
# - 微信公众号配置JUNHONG_WECHAT_OFFICIAL_ACCOUNT_*
# - 微信支付配置JUNHONG_WECHAT_PAYMENT_*
# - 对象存储配置JUNHONG_STORAGE_*
services:
api:
@@ -54,9 +60,33 @@ services:
- JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY=8393425DCB2F48F1914FF39DCBC6C7B17325
- JUNHONG_STORAGE_S3_USE_SSL=false
- JUNHONG_STORAGE_S3_PATH_STYLE=true
# Gateway 配置(可选)
# - JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
# - JUNHONG_GATEWAY_APP_ID=your_app_id
# - JUNHONG_GATEWAY_APP_SECRET=your_app_secret
# - JUNHONG_GATEWAY_TIMEOUT=30
# 微信公众号配置(可选)
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
# 微信支付配置(可选)
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
volumes:
# 仅挂载日志目录(配置已嵌入二进制文件)
- ./logs:/app/logs
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
# - ./certs:/app/certs:ro
networks:
- junhong-network
healthcheck:
@@ -102,8 +132,32 @@ services:
- JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY=8393425DCB2F48F1914FF39DCBC6C7B17325
- JUNHONG_STORAGE_S3_USE_SSL=false
- JUNHONG_STORAGE_S3_PATH_STYLE=true
# Gateway 配置(可选)
# - JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
# - JUNHONG_GATEWAY_APP_ID=your_app_id
# - JUNHONG_GATEWAY_APP_SECRET=your_app_secret
# - JUNHONG_GATEWAY_TIMEOUT=30
# 微信公众号配置(可选)
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
# 微信支付配置(可选)
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
volumes:
- ./logs:/app/logs
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
# - ./certs:/app/certs:ro
networks:
- junhong-network
depends_on:

View File

@@ -33,6 +33,40 @@
|---------|------|------|
| `JUNHONG_JWT_SECRET_KEY` | JWT 签名密钥(生产环境必须修改) | `your-secret-key` |
### 微信配置
#### 微信公众号
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID` | 公众号 AppID必填 | `wxabcdef1234567890` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET` | 公众号 AppSecret必填 | `abcdef1234567890` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN` | 服务器配置Token可选 | `your_token` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY` | 消息加解密Key可选 | `` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL` | OAuth回调URL可选 | `https://your-domain.com/callback` |
#### 微信支付
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_WECHAT_PAYMENT_APP_ID` | 支付 AppID必填通常与公众号相同 | `wxabcdef1234567890` |
| `JUNHONG_WECHAT_PAYMENT_MCH_ID` | 商户号(必填) | `1234567890` |
| `JUNHONG_WECHAT_PAYMENT_API_V3_KEY` | APIv3 密钥必填32位字符串 | `your_apiv3_key_32_chars_here` |
| `JUNHONG_WECHAT_PAYMENT_API_V2_KEY` | APIv2 密钥(可选,部分接口需要) | `` |
| `JUNHONG_WECHAT_PAYMENT_CERT_PATH` | 商户证书路径(必填) | `/app/certs/apiclient_cert.pem` |
| `JUNHONG_WECHAT_PAYMENT_KEY_PATH` | 商户私钥路径(必填) | `/app/certs/apiclient_key.pem` |
| `JUNHONG_WECHAT_PAYMENT_SERIAL_NO` | 证书序列号(必填) | `1234567890ABCDEF` |
| `JUNHONG_WECHAT_PAYMENT_NOTIFY_URL` | 支付回调URL必填 | `https://api.your-domain.com/api/callback/wechat-pay` |
| `JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG` | HTTP调试日志可选 | `false` |
| `JUNHONG_WECHAT_PAYMENT_TIMEOUT` | HTTP请求超时可选 | `30s` |
**配置说明**
- 微信公众号和支付配置缺失时服务启动会失败FATAL 错误)
- 证书文件必须可读(权限 600 或 644
- APIv3 密钥必须是 32 位字符串
- 证书序列号可通过 `openssl x509 -in apiclient_cert.pem -noout -serial` 获取
- 详细配置指南参见 [微信集成使用指南](wechat-integration/使用指南.md)
## 可选配置
以下配置有合理的默认值,可按需覆盖:

View File

@@ -0,0 +1,252 @@
# 登录接口返回菜单树和按钮权限 - 使用指南
## 概述
从本版本开始,登录接口(`POST /api/admin/login``POST /api/h5/login`)响应中新增了 `menus``buttons` 两个字段,用于直接返回结构化的菜单树和按钮权限列表,简化前端实现。
## 响应结构
### LoginResponse 字段说明
```json
{
"code": 0,
"msg": "success",
"data": {
"access_token": "xxx",
"refresh_token": "xxx",
"expires_in": 86400,
"user": { ... },
"permissions": ["user:menu", "user:create", "user:delete"],
"menus": [
{
"id": 1,
"perm_code": "user:menu",
"name": "用户管理",
"url": "/users",
"sort": 1,
"children": [
{
"id": 2,
"perm_code": "user:list:menu",
"name": "用户列表",
"url": "/users/list",
"sort": 10,
"children": []
}
]
}
],
"buttons": ["user:create", "user:delete", "user:update"]
},
"timestamp": 1638360000
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `permissions` | `[]string` | 所有权限码(向后兼容,包含菜单和按钮) |
| `menus` | `[]MenuNode` | 菜单树(树形结构) |
| `buttons` | `[]string` | 按钮权限码列表(扁平数组) |
### MenuNode 结构说明
```typescript
interface MenuNode {
id: number; // 权限 ID
perm_code: string; // 权限码(如 "user:menu"
name: string; // 菜单名称(如 "用户管理"
url: string; // 路由路径(如 "/users"
sort: number; // 排序值(升序)
children: MenuNode[]; // 子菜单(递归结构)
}
```
## 前端使用示例
### 1. 登录并缓存菜单数据
```javascript
// 登录
const response = await api.post('/api/admin/login', {
username: 'admin',
password: 'password',
device: 'web'
});
const { menus, buttons, permissions } = response.data;
// 缓存到 localStorage推荐
localStorage.setItem('menus', JSON.stringify(menus));
localStorage.setItem('buttons', JSON.stringify(buttons));
localStorage.setItem('permissions', JSON.stringify(permissions));
```
### 2. 渲染侧边栏菜单
```vue
<template>
<aside class="sidebar">
<menu-tree :items="menus" />
</aside>
</template>
<script>
export default {
data() {
return {
menus: []
};
},
mounted() {
// 从 localStorage 读取
const cached = localStorage.getItem('menus');
this.menus = cached ? JSON.parse(cached) : [];
}
};
</script>
```
### 3. 控制按钮显示
```vue
<template>
<div>
<button v-if="hasPermission('user:create')">创建用户</button>
<button v-if="hasPermission('user:delete')">删除用户</button>
</div>
</template>
<script>
export default {
data() {
return {
buttons: []
};
},
mounted() {
// 从 localStorage 读取
const cached = localStorage.getItem('buttons');
this.buttons = cached ? JSON.parse(cached) : [];
},
methods: {
hasPermission(code) {
return this.buttons.includes(code);
}
}
};
</script>
```
### 4. 页面刷新时恢复菜单
```javascript
// App.vue 或 main.js
const menus = localStorage.getItem('menus');
if (menus) {
store.commit('setMenus', JSON.parse(menus));
} else {
// 未登录,跳转到登录页
router.push('/login');
}
```
## 核心特性
### 1. 平台过滤
登录时传递 `device` 参数(`web``h5`),系统会自动过滤对应平台的权限:
```javascript
// Web 后台登录
await api.post('/api/admin/login', {
username: 'admin',
password: 'password',
device: 'web' // 只返回 platform="web" 或 "all" 的菜单
});
// H5 端登录
await api.post('/api/h5/login', {
username: 'user',
password: 'password',
device: 'h5' // 只返回 platform="h5" 或 "all" 的菜单
});
```
### 2. 菜单自动排序
菜单树已按 `sort` 字段升序排序(包含所有层级),前端无需再次排序,直接渲染即可。
### 3. 超级管理员
超级管理员(`user_type = 1`)登录时,返回所有启用的菜单和按钮(仍然应用平台过滤)。
### 4. 孤儿节点处理
如果用户有子菜单权限但没有父菜单权限(如只有 "用户列表" 权限但没有 "用户管理" 权限),子菜单会被提升为根节点显示,避免菜单丢失。
## GetMe 接口行为
`GET /api/admin/me``GET /api/h5/me` 接口**不返回** `menus``buttons` 字段,只返回 `user``permissions`
原因:
- GetMe 是高频接口(如每次路由切换都调用)
- 菜单树构建有计算成本
- 前端应将菜单数据缓存到 localStorage
```json
// GetMe 响应示例
{
"code": 0,
"data": {
"user": { ... },
"permissions": ["user:menu", "user:create"]
}
}
```
## 向后兼容性
- 旧版前端仍可使用 `permissions` 字段正常工作
- 新版前端可以选择使用 `menus``buttons` 字段
- `permissions` 字段包含所有权限码(菜单 + 按钮)
## 最佳实践
1. **登录后立即缓存**:将 `menus``buttons` 存储到 localStorage避免重复构建
2. **页面刷新时恢复**:从 localStorage 读取菜单数据,无需重新登录
3. **权限变更后刷新**:管理员修改权限后,提示用户重新登录或提供"刷新权限"按钮
4. **使用 buttons 控制按钮**:不要使用 `permissions` 字段判断按钮显示,使用 `buttons` 更清晰
5. **GetMe 不依赖菜单**GetMe 接口用于验证 Token 有效性和获取用户信息,不要期望它返回菜单
## 常见问题
### 1. 权限变更后菜单未更新?
**原因**:前端使用了缓存的菜单数据。
**解决方案**
- 短期:提示用户重新登录
- 长期:提供"刷新权限"按钮,调用 `POST /api/admin/login` 重新获取菜单
### 2. 菜单层级不正确?
**原因**:权限配置不当(子菜单的 `parent_id` 指向不存在的父菜单)。
**解决方案**:检查权限配置,确保父子关系正确。孤儿节点会被提升为根节点,同时后端会记录警告日志。
### 3. 性能影响?
**影响**:登录响应时间增加 < 50ms权限数量 < 100 的场景)
**缓解**
- 前端缓存菜单数据到 localStorage
- GetMe 接口未修改,性能无影响
### 4. 响应体过大?
**影响**:响应体增加约 5-10KB取决于权限数量
**缓解**
- 使用 Gzip 压缩(压缩率约 60-70%
- 前端缓存,登录后只传输一次

View File

@@ -0,0 +1,564 @@
# 微信集成 API 文档
本文档详细说明微信 OAuth 登录和微信支付相关的 API 接口。
## 目录
- [认证说明](#认证说明)
- [错误码](#错误码)
- [API 接口](#api-接口)
- [1. 微信 OAuth 登录](#1-微信-oauth-登录)
- [2. 绑定微信账号](#2-绑定微信账号)
- [3. 微信 JSAPI 支付](#3-微信-jsapi-支付)
- [4. 微信 H5 支付](#4-微信-h5-支付)
- [5. 微信支付回调](#5-微信支付回调)
---
## 认证说明
### 公开接口
以下接口无需认证,可直接调用:
- `POST /api/c/v1/wechat/auth` - 微信 OAuth 登录
- `POST /api/callback/wechat-pay` - 微信支付回调
### 需要认证的接口
以下接口需要在请求头中携带 JWT Token
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
- `POST /api/c/v1/bind-wechat` - 绑定微信账号(个人客户)
- `POST /api/h5/orders/:id/wechat-pay/jsapi` - 微信 JSAPI 支付H5认证
- `POST /api/h5/orders/:id/wechat-pay/h5` - 微信 H5 支付H5认证
---
## 错误码
微信集成相关的错误码:
| 错误码 | 说明 | HTTP 状态码 |
|--------|------|-------------|
| 1044 | 微信 OAuth 授权失败 | 400 |
| 1045 | 获取微信用户信息失败 | 400 |
| 1046 | 微信支付失败 | 400 |
| 1047 | 微信支付回调数据无效 | 400 |
| 1003 | 参数无效 | 400 |
| 1020 | 手机号已被使用 | 400 |
| 1021 | 个人客户不存在 | 404 |
| 1035 | 订单不存在 | 404 |
---
## API 接口
### 1. 微信 OAuth 登录
通过微信授权码登录或创建账号。如果用户首次登录,系统会自动创建账号。
**接口地址**
```
POST /api/c/v1/wechat/auth
```
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| code | string | ✅ | 微信授权码5分钟有效期一次性使用 |
**请求示例**
```json
{
"code": "071abc123456789def"
}
```
**响应参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| code | integer | 响应码0表示成功 |
| msg | string | 响应消息 |
| data | object | 响应数据 |
| data.token | string | JWT Token用于后续请求认证 |
| data.customer_id | integer | 个人客户ID |
| data.phone | string | 手机号(未绑定时为空) |
| data.nickname | string | 昵称(微信昵称) |
| data.is_new_user | boolean | 是否新用户 |
| timestamp | string | 响应时间戳RFC3339格式 |
**响应示例**
```json
{
"code": 0,
"msg": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b21lcl9pZCI6MTIzLCJleHAiOjE3MDY2OTI4MDB9.abc123def456",
"customer_id": 123,
"phone": "138****8888",
"nickname": "微信用户",
"is_new_user": false
},
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**错误响应**
```json
{
"code": 1044,
"msg": "微信 OAuth 授权失败",
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**业务逻辑**
1. 验证授权码是否有效
2. 调用微信API获取用户 OpenID 和 UnionID
3. 查找数据库是否存在该微信用户:
- **存在**:返回已有账号的 Token
- **不存在**:创建新账号,返回新账号的 Token
4. 新用户状态为"未绑定手机号",后续需要绑定手机号才能使用完整功能
**注意事项**
- 授权码code只能使用一次重复使用会失败
- 授权码有效期为5分钟
- Token 有效期为7天
- 新用户首次登录时 `phone` 字段为空,需要引导绑定手机号
---
### 2. 绑定微信账号
将当前登录的个人客户账号绑定到微信。
**接口地址**
```
POST /api/c/v1/bind-wechat
```
**认证方式**
需要携带 JWT Token个人客户
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| code | string | ✅ | 微信授权码 |
**请求示例**
```json
{
"code": "071abc123456789def"
}
```
**响应参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| code | integer | 响应码0表示成功 |
| msg | string | 响应消息 |
| timestamp | string | 响应时间戳 |
**响应示例**
```json
{
"code": 0,
"msg": "绑定成功",
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**错误响应**
```json
{
"code": 1020,
"msg": "该微信号已被其他账号绑定",
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**业务逻辑**
1. 验证授权码是否有效
2. 获取微信 OpenID 和 UnionID
3. 检查该微信号是否已被其他账号绑定
4. 更新当前账号的微信绑定信息
**注意事项**
- 一个微信号只能绑定一个账号
- 绑定后无法解绑(需联系管理员)
- 绑定成功后,可以使用微信登录
---
### 3. 微信 JSAPI 支付
创建微信 JSAPI 支付订单(微信内网页支付)。
**接口地址**
```
POST /api/h5/orders/:id/wechat-pay/jsapi
```
**认证方式**
需要携带 H5 Token。
**路径参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| id | integer | 订单ID |
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| open_id | string | ✅ | 用户的微信 OpenID在公众号内获取 |
**请求示例**
```json
{
"open_id": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M"
}
```
**响应参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| code | integer | 响应码0表示成功 |
| msg | string | 响应消息 |
| data | object | 响应数据 |
| data.prepay_id | string | 预支付交易会话标识 |
| data.pay_config | object | 支付配置直接传给微信JSAPI |
| data.pay_config.appId | string | 公众号AppID |
| data.pay_config.timeStamp | string | 时间戳 |
| data.pay_config.nonceStr | string | 随机字符串 |
| data.pay_config.package | string | 订单详情扩展字符串 |
| data.pay_config.signType | string | 签名方式RSA |
| data.pay_config.paySign | string | 签名 |
| timestamp | string | 响应时间戳 |
**响应示例**
```json
{
"code": 0,
"msg": "支付订单创建成功",
"data": {
"prepay_id": "wx30123456789012345678901234567890",
"pay_config": {
"appId": "wxabcdef1234567890",
"timeStamp": "1706606400",
"nonceStr": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS",
"package": "prepay_id=wx30123456789012345678901234567890",
"signType": "RSA",
"paySign": "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7JS..."
}
},
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**错误响应**
```json
{
"code": 1035,
"msg": "订单不存在",
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**业务逻辑**
1. 验证订单是否存在且状态为"待支付"
2. 验证订单归属(只能支付自己的订单)
3. 调用微信支付API创建预支付订单
4. 生成支付配置(包含签名)
5. 返回支付配置给前端
**前端调用示例**
```javascript
// 获取支付配置
const res = await fetch(`/api/h5/orders/${orderId}/wechat-pay/jsapi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${h5Token}`
},
body: JSON.stringify({ open_id: openId })
});
const result = await res.json();
// 调用微信JSAPI支付
wx.chooseWXPay({
...result.data.pay_config,
success: function(res) {
console.log('支付成功', res);
},
fail: function(res) {
console.log('支付失败', res);
}
});
```
**注意事项**
- 只能在微信内网页中使用
- OpenID 需要通过公众号 OAuth 获取
- 支付有效期为2小时
- 订单只能支付一次
---
### 4. 微信 H5 支付
创建微信 H5 支付订单(微信外浏览器支付)。
**接口地址**
```
POST /api/h5/orders/:id/wechat-pay/h5
```
**认证方式**
需要携带 H5 Token。
**路径参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| id | integer | 订单ID |
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| scene_info | object | ❌ | 场景信息 |
| scene_info.payer_client_ip | string | ❌ | 用户客户端IP |
| scene_info.h5_type | string | ❌ | H5类型Wap/IOS/Android |
**请求示例**
```json
{
"scene_info": {
"payer_client_ip": "123.12.12.123",
"h5_type": "Wap"
}
}
```
**响应参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| code | integer | 响应码0表示成功 |
| msg | string | 响应消息 |
| data | object | 响应数据 |
| data.h5_url | string | H5 支付跳转链接 |
| timestamp | string | 响应时间戳 |
**响应示例**
```json
{
"code": 0,
"msg": "H5 支付订单创建成功",
"data": {
"h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx30123456789012345678901234567890&package=3583359058"
},
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**前端调用示例**
```javascript
// 创建 H5 支付订单
const res = await fetch(`/api/h5/orders/${orderId}/wechat-pay/h5`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${h5Token}`
},
body: JSON.stringify({
scene_info: {
payer_client_ip: clientIp,
h5_type: 'Wap'
}
})
});
const result = await res.json();
// 跳转到微信 H5 支付页面
if (result.code === 0) {
const returnUrl = encodeURIComponent(`https://your-domain.com/orders/${orderId}`);
window.location.href = `${result.data.h5_url}&redirect_url=${returnUrl}`;
}
```
**注意事项**
- 适用于微信外浏览器
- 支付完成后会跳转到 `redirect_url`需URL编码
- 支付有效期为5分钟
- 需要在微信商户平台配置 H5 支付域名
---
### 5. 微信支付回调
接收微信支付的异步通知。
**接口地址**
```
POST /api/callback/wechat-pay
```
**认证方式**
无需认证(由微信签名验证)。
**请求说明**
该接口由微信支付系统调用,开发者无需主动调用。
**请求头**
| 参数 | 说明 |
|------|------|
| Wechatpay-Serial | 微信支付平台证书序列号 |
| Wechatpay-Signature | 微信签名 |
| Wechatpay-Timestamp | 微信时间戳 |
| Wechatpay-Nonce | 微信随机串 |
**请求体**
微信发送的加密数据JSON格式
**响应**
成功处理返回 HTTP 200
```json
{
"code": "SUCCESS",
"message": "成功"
}
```
失败返回 HTTP 500
```json
{
"code": "FAIL",
"message": "失败原因"
}
```
**处理流程**
1. 验证微信签名PowerWeChat 自动处理)
2. 解密通知数据
3. 提取支付结果(交易状态、金额、订单号等)
4. 更新订单状态为"已支付"
5. 触发异步任务:
- 分佣计算
- 套餐分配
- 钱包充值
6. 返回成功响应给微信
**幂等性保证**
系统会检查订单状态,避免重复处理:
- 如果订单已支付,直接返回成功
- 如果订单不存在,返回失败
- 使用数据库事务确保原子性
**重试机制**
微信会在以下情况重试:
- 商户系统未返回响应
- 返回 HTTP 状态码不是 200
- 返回结果为 FAIL
重试规则:
- 15秒后第1次重试
- 30秒后第2次重试
- 3分钟后第3次重试
- 最多重试3次
**注意事项**
- 接口必须在 **10秒内** 返回响应
- 必须返回正确的 JSON 格式
- 签名验证失败会记录日志但不影响服务
- 处理失败会自动重试,无需手动干预
---
## 测试建议
### 开发环境测试
1. **OAuth 登录测试**
- 使用微信测试号(公众号测试账号)
- 在本地配置内网穿透ngrok、frp等
- 测试授权流程和账号创建
2. **支付功能测试**
- 使用 0.01 元小额订单测试
- 验证支付流程和回调处理
- 测试完成后可通过退款功能退回
3. **回调测试**
- 使用微信支付沙箱环境(需申请)
- 或者使用 Postman 模拟回调请求
- 验证幂等性和重试机制
### 生产环境测试
1. 使用真实商户号和公众号
2. 配置正确的 HTTPS 域名
3. 小额订单测试(建议 0.01 元)
4. 监控日志确认回调正常
---
## 相关文档
- [使用指南](./使用指南.md) - 详细的配置和部署说明
- [环境变量配置](../environment-variables.md) - 所有环境变量说明
- [README](../../README.md) - 项目整体说明

View File

@@ -0,0 +1,562 @@
# 微信公众号与微信支付集成使用指南
本文档说明如何配置和使用系统的微信公众号 OAuth 认证和微信支付功能。
## 目录
- [概述](#概述)
- [前置条件](#前置条件)
- [配置步骤](#配置步骤)
- [1. 微信公众号配置](#1-微信公众号配置)
- [2. 微信支付配置](#2-微信支付配置)
- [3. 证书文件配置](#3-证书文件配置)
- [环境变量配置](#环境变量配置)
- [功能说明](#功能说明)
- [微信 OAuth 登录](#微信-oauth-登录)
- [微信 JSAPI 支付](#微信-jsapi-支付)
- [微信 H5 支付](#微信-h5-支付)
- [支付回调处理](#支付回调处理)
- [常见问题](#常见问题)
---
## 概述
系统集成了以下微信功能:
1. **微信公众号 OAuth 认证**:个人客户可以通过微信授权码登录/绑定账号
2. **微信 JSAPI 支付**:支持微信内网页支付
3. **微信 H5 支付**:支持微信外浏览器 H5 支付
4. **支付回调处理**:自动验证微信支付签名并处理回调
技术实现使用 [PowerWeChat v3 SDK](https://github.com/ArtisanCloud/PowerWeChat)。
---
## 前置条件
在开始配置之前,您需要:
1. **微信公众号**(已认证)
- 公众号 AppID
- 公众号 AppSecret
- OAuth 回调域名(需在公众号后台配置)
2. **微信商户号**(已开通)
- 商户号 MchID
- APIv3 密钥32位字符串
- APIv2 密钥(可选,部分接口需要)
- 商户证书apiclient_cert.pem
- 商户私钥apiclient_key.pem
- 证书序列号
3. **服务器环境**
- 可访问的 HTTPS 域名(用于接收微信回调)
- Redis用于缓存 AccessToken
---
## 配置步骤
### 1. 微信公众号配置
#### 1.1 获取 AppID 和 AppSecret
登录 [微信公众平台](https://mp.weixin.qq.com/),在"开发" → "基本配置"中获取:
- AppID应用ID
- AppSecret应用密钥
#### 1.2 配置 OAuth 回调域名
在"设置与开发" → "公众号设置" → "功能设置" → "网页授权域名"中配置:
```
your-domain.com
```
**注意**
- 不要带 `http://``https://`
- 不要带端口号
- 需要验证域名所有权(下载验证文件到网站根目录)
### 2. 微信支付配置
#### 2.1 获取商户信息
登录 [微信支付商户平台](https://pay.weixin.qq.com/)
1. **商户号MchID**:在"账户中心" → "商户信息"中查看
2. **APIv3 密钥**:在"账户中心" → "API安全" → "设置APIv3密钥"中设置32位字符串
3. **APIv2 密钥**可选同上设置API密钥32位字符串
#### 2.2 下载商户证书
在"账户中心" → "API安全" → "申请API证书"
1. 下载证书工具
2. 生成证书请求文件
3. 上传请求文件
4. 下载证书文件:
- `apiclient_cert.pem`(商户证书)
- `apiclient_key.pem`(商户私钥)
#### 2.3 获取证书序列号
**方法1使用 OpenSSL**
```bash
openssl x509 -in apiclient_cert.pem -noout -serial | cut -d= -f2
```
**方法2从商户平台查看**
在"账户中心" → "API安全" → "API证书"中查看证书序列号。
#### 2.4 配置支付回调 URL
在"产品中心" → "开发配置" → "支付配置"中设置:
```
https://your-domain.com/api/callback/wechat-pay
```
**注意**
- 必须使用 HTTPS
- 确保服务器可以接收微信的 POST 请求
### 3. 证书文件配置
将下载的证书文件放置到服务器:
```bash
# 创建证书目录
mkdir -p /app/certs
# 复制证书文件
cp apiclient_cert.pem /app/certs/
cp apiclient_key.pem /app/certs/
# 设置文件权限(仅所有者可读写)
chmod 600 /app/certs/*
```
**Docker 部署**:在 `docker-compose.yml` 中挂载证书目录:
```yaml
services:
api:
volumes:
- ./certs:/app/certs:ro # 只读挂载
```
---
## 环境变量配置
`.env.local` 或生产环境中设置以下环境变量:
```bash
# ===== 微信公众号配置 =====
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID="wxabcdef1234567890"
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET="your_app_secret_here"
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN="" # 可选,服务器配置用
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY="" # 可选,消息加解密用
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL="" # 可选自定义回调URL
# ===== 微信支付配置 =====
export JUNHONG_WECHAT_PAYMENT_APP_ID="wxabcdef1234567890" # 与公众号 AppID 相同
export JUNHONG_WECHAT_PAYMENT_MCH_ID="1234567890"
export JUNHONG_WECHAT_PAYMENT_API_V3_KEY="your_apiv3_key_32_chars_here"
export JUNHONG_WECHAT_PAYMENT_API_V2_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="1234567890ABCDEF"
export JUNHONG_WECHAT_PAYMENT_NOTIFY_URL="https://your-domain.com/api/callback/wechat-pay"
export JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
export JUNHONG_WECHAT_PAYMENT_TIMEOUT="30s"
```
**配置说明**
| 配置项 | 必填 | 说明 |
|--------|------|------|
| `OFFICIAL_ACCOUNT_APP_ID` | ✅ | 公众号 AppID |
| `OFFICIAL_ACCOUNT_APP_SECRET` | ✅ | 公众号 AppSecret |
| `PAYMENT_APP_ID` | ✅ | 支付 AppID通常与公众号相同 |
| `PAYMENT_MCH_ID` | ✅ | 商户号 |
| `PAYMENT_API_V3_KEY` | ✅ | APIv3 密钥32位 |
| `PAYMENT_CERT_PATH` | ✅ | 商户证书路径 |
| `PAYMENT_KEY_PATH` | ✅ | 商户私钥路径 |
| `PAYMENT_SERIAL_NO` | ✅ | 证书序列号 |
| `PAYMENT_NOTIFY_URL` | ✅ | 支付回调 URL |
| `PAYMENT_TIMEOUT` | ❌ | HTTP 请求超时默认30s |
| `PAYMENT_HTTP_DEBUG` | ❌ | 开启 HTTP 调试日志 |
---
## 功能说明
### 微信 OAuth 登录
#### 业务流程
```
1. 前端引导用户点击"微信登录"
2. 跳转到微信授权页面微信SDK处理
3. 用户同意授权后,微信回调到前端
4. 前端获取授权码code调用后端登录接口
5. 后端通过 code 获取用户 OpenID/UnionID
6. 后端创建/查找用户,返回 JWT Token
```
#### API 端点
**POST `/api/c/v1/wechat/auth`**
请求体:
```json
{
"code": "071abc123456789def"
}
```
响应:
```json
{
"code": 0,
"msg": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"customer_id": 123,
"phone": "138****8888",
"nickname": "微信用户",
"is_new_user": false
},
"timestamp": "2025-01-30T12:00:00Z"
}
```
#### 前端集成示例
```javascript
// 1. 构造微信授权 URL前端处理
const redirectUri = encodeURIComponent('https://your-domain.com/wechat-callback');
const appId = 'wxabcdef1234567890';
const scope = 'snsapi_userinfo'; // 或 snsapi_base静默授权
const state = 'STATE'; // 自定义参数
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`;
// 跳转到微信授权页面
window.location.href = authUrl;
// 2. 在回调页面获取 code 并调用后端
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
fetch('https://api.your-domain.com/api/c/v1/wechat/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
})
.then(res => res.json())
.then(data => {
if (data.code === 0) {
localStorage.setItem('token', data.data.token);
// 跳转到主页
}
});
```
### 微信 JSAPI 支付
#### 业务流程
```
1. 前端调用后端创建支付订单
2. 后端调用微信支付接口,获取 prepay_id 和支付配置
3. 前端调用微信 JSAPI 唤起支付
4. 用户完成支付后,微信回调后端通知接口
5. 后端验证签名并处理订单状态
```
#### API 端点
**POST `/api/h5/orders/:id/wechat-pay/jsapi`**
请求体:
```json
{
"open_id": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M"
}
```
响应:
```json
{
"code": 0,
"msg": "支付订单创建成功",
"data": {
"prepay_id": "wx30123456789012345678901234567890",
"pay_config": {
"appId": "wxabcdef1234567890",
"timeStamp": "1706606400",
"nonceStr": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS",
"package": "prepay_id=wx30123456789012345678901234567890",
"signType": "RSA",
"paySign": "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd..."
}
},
"timestamp": "2025-01-30T12:00:00Z"
}
```
#### 前端集成示例(微信内网页)
```javascript
// 1. 调用后端创建支付订单
const response = await fetch(`/api/h5/orders/${orderId}/wechat-pay/jsapi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
open_id: 'o6_bmjrPTlm6_2sgVt7hMZOPfL2M'
})
});
const result = await response.json();
// 2. 调用微信 JSAPI 唤起支付
if (result.code === 0) {
const payConfig = result.data.pay_config;
wx.chooseWXPay({
...payConfig,
success: function(res) {
// 支付成功,跳转到订单详情页
window.location.href = `/orders/${orderId}`;
},
fail: function(res) {
// 支付失败
alert('支付失败:' + res.err_msg);
}
});
}
```
### 微信 H5 支付
#### 业务流程
```
1. 前端调用后端创建 H5 支付订单
2. 后端调用微信支付接口,获取 H5 支付 URL
3. 前端跳转到 H5 支付 URL
4. 用户完成支付后,微信回调后端通知接口
5. 后端验证签名并处理订单状态
```
#### API 端点
**POST `/api/h5/orders/:id/wechat-pay/h5`**
请求体:
```json
{
"scene_info": {
"payer_client_ip": "123.12.12.123",
"h5_type": "Wap"
}
}
```
响应:
```json
{
"code": 0,
"msg": "H5 支付订单创建成功",
"data": {
"h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx..."
},
"timestamp": "2025-01-30T12:00:00Z"
}
```
#### 前端集成示例(浏览器)
```javascript
// 1. 调用后端创建 H5 支付订单
const response = await fetch(`/api/h5/orders/${orderId}/wechat-pay/h5`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
scene_info: {
payer_client_ip: '123.12.12.123',
h5_type: 'Wap'
}
})
});
const result = await response.json();
// 2. 跳转到微信 H5 支付页面
if (result.code === 0) {
const returnUrl = encodeURIComponent(`https://your-domain.com/orders/${orderId}`);
window.location.href = `${result.data.h5_url}&redirect_url=${returnUrl}`;
}
```
### 支付回调处理
#### 回调端点
**POST `/api/callback/wechat-pay`**
该端点接收微信支付的异步通知。系统会自动:
1. 验证微信签名(使用商户证书)
2. 解密通知数据
3. 更新订单状态
4. 处理业务逻辑(分佣、钱包充值等)
5. 返回成功响应给微信
#### 回调处理流程
```
1. 微信发送 POST 请求到回调 URL
2. 系统验证请求签名PowerWeChat 自动处理)
3. 解析支付结果(交易状态、金额等)
4. 更新订单状态为"已支付"
5. 触发异步任务(分佣计算、套餐分配等)
6. 返回 200 OK 给微信(表示接收成功)
```
**注意**
- 回调接口必须在 **10秒内** 返回响应,否则微信会重试
- 系统已实现幂等性处理,重复通知不会重复处理
- 如果处理失败微信会重试多次最多3次
---
## 常见问题
### 1. 配置验证失败,服务启动失败
**错误日志**
```
FATAL: 微信配置不完整或无效
```
**解决方法**
- 检查所有必填环境变量是否设置
- 确认证书文件路径正确且文件存在
- 验证 APIv3 密钥是否为 32 位字符串
### 2. OAuth 授权失败,返回 1044 错误
**错误消息**
```json
{
"code": 1044,
"msg": "微信 OAuth 授权失败"
}
```
**可能原因**
- 授权码code已过期5分钟有效期
- 授权码已被使用过(一次性有效)
- AppID 或 AppSecret 配置错误
- 回调域名未在公众号后台配置
**解决方法**
- 重新发起授权流程获取新 code
- 检查公众号配置是否正确
- 查看 `logs/app.log` 获取详细错误信息
### 3. 支付订单创建失败,返回 1046 错误
**错误消息**
```json
{
"code": 1046,
"msg": "微信支付失败"
}
```
**可能原因**
- 商户号配置错误
- 证书文件无效或过期
- APIv3 密钥错误
- 订单金额为0或负数
**解决方法**
- 验证商户号和密钥是否正确
- 检查证书文件是否可读(权限问题)
- 确认证书序列号是否匹配
- 查看 `logs/app.log` 获取详细错误信息
### 4. 支付回调签名验证失败
**错误日志**
```
ERROR: 支付回调签名验证失败
```
**可能原因**
- 证书配置错误
- 证书序列号不匹配
- 证书已过期
**解决方法**
- 重新下载最新的商户证书
- 更新证书序列号配置
- 确保证书文件路径正确
### 5. 如何测试微信支付?
**开发环境测试**
1. 使用微信测试号(公众号测试账号)
2. 使用真实商户号的沙箱环境(需申请)
3. 使用 0.01 元测试订单(生产环境)
**注意**
- 测试订单需要真实支付
- 可以通过退款功能退回测试金额
- 建议使用沙箱环境进行测试
### 6. Redis 连接失败,影响微信功能吗?
**是的**,微信功能依赖 Redis 缓存 AccessToken。
**解决方法**
- 确保 Redis 服务正常运行
- 检查 Redis 连接配置(地址、端口、密码)
- 查看 `logs/app.log` 获取 Redis 连接错误
### 7. 如何调试微信支付问题?
**启用 HTTP 调试日志**
```bash
export JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=true
```
重启服务后,所有微信 API 请求和响应将记录到 `logs/app.log`
**查看日志**
```bash
tail -f logs/app.log | grep -i wechat
```
---
## 相关文档
- [微信公众号官方文档](https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html)
- [微信支付官方文档](https://pay.weixin.qq.com/wiki/doc/apiv3/index.shtml)
- [PowerWeChat SDK 文档](https://github.com/ArtisanCloud/PowerWeChat)
- [API 文档](./API文档.md)
- [环境变量配置](../environment-variables.md)

View File

@@ -0,0 +1,675 @@
# 微信集成功能验证指南
本文档提供微信公众号 OAuth 认证和微信支付功能的完整验证流程。
## 目录
- [前置准备](#前置准备)
- [配置验证](#配置验证)
- [功能测试](#功能测试)
- [1. 微信 OAuth 登录](#1-微信-oauth-登录)
- [2. 微信账号绑定](#2-微信账号绑定)
- [3. 微信 JSAPI 支付](#3-微信-jsapi-支付)
- [4. 微信 H5 支付](#4-微信-h5-支付)
- [5. 支付回调验证](#5-支付回调验证)
- [常见问题排查](#常见问题排查)
---
## 前置准备
### 1. 微信配置准备
确保已获取以下信息:
**公众号配置**
- [ ] AppID
- [ ] AppSecret
- [ ] OAuth 回调域名已配置(在公众号后台)
**支付配置**
- [ ] 商户号
- [ ] APIv3 密钥32位
- [ ] 商户证书文件apiclient_cert.pem
- [ ] 商户私钥文件apiclient_key.pem
- [ ] 证书序列号
- [ ] 支付回调 URL 已配置(在商户平台)
### 2. 环境准备
```bash
# 创建证书目录
mkdir -p /app/certs
# 复制证书文件
cp apiclient_cert.pem /app/certs/
cp apiclient_key.pem /app/certs/
# 设置文件权限
chmod 600 /app/certs/*
# 加载环境变量
source .env.local
```
### 3. 启动服务
```bash
# 编译并启动
go run cmd/api/main.go
# 或使用 Docker
docker-compose up -d api
```
---
## 配置验证
### 自动验证脚本
运行配置验证脚本:
```bash
# 加载环境变量
source .env.local
# 运行验证脚本
bash scripts/verify-wechat.sh
```
**预期输出**(所有检查通过):
```
========================================
微信配置验证脚本
========================================
1. 检查微信公众号配置
----------------------------------------
✓ JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID
✓ JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET
✓ JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN
✓ JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY
✓ JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL
2. 检查微信支付配置
----------------------------------------
✓ JUNHONG_WECHAT_PAYMENT_APP_ID
✓ JUNHONG_WECHAT_PAYMENT_MCH_ID
✓ JUNHONG_WECHAT_PAYMENT_API_V3_KEY
✓ JUNHONG_WECHAT_PAYMENT_CERT_PATH
✓ JUNHONG_WECHAT_PAYMENT_KEY_PATH
✓ JUNHONG_WECHAT_PAYMENT_SERIAL_NO
✓ JUNHONG_WECHAT_PAYMENT_NOTIFY_URL
3. 检查证书文件
----------------------------------------
✓ 文件存在: /app/certs/apiclient_cert.pem
✓ 文件存在: /app/certs/apiclient_key.pem
4. 验证配置格式
----------------------------------------
✓ 支付回调 URL 使用 HTTPS
5. 检查证书有效性(可选)
----------------------------------------
✓ 证书有效期至: Jan 30 12:00:00 2026 GMT
✓ 证书序列号匹配
========================================
验证结果
========================================
错误: 0
警告: 0
✅ 配置验证通过,所有配置正确
```
### 查看服务启动日志
```bash
# 查看实时日志
tail -f logs/app.log
# 或使用 Docker
docker logs -f junhong-api
```
**预期日志**(成功初始化):
```json
{
"level": "info",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "微信公众号服务初始化成功"
}
{
"level": "info",
"ts": "2025-01-30T12:00:00.001+0800",
"msg": "微信支付服务初始化成功"
}
{
"level": "info",
"ts": "2025-01-30T12:00:00.002+0800",
"msg": "服务启动成功",
"address": ":3000"
}
```
**错误日志**(配置问题):
```json
{
"level": "fatal",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "微信配置不完整或无效",
"error": "证书文件不存在: /app/certs/apiclient_cert.pem"
}
```
---
## 功能测试
### 1. 微信 OAuth 登录
#### 前端测试步骤
**步骤 1构造授权 URL**
```javascript
const appId = 'wxabcdef1234567890';
const redirectUri = encodeURIComponent('https://your-domain.com/wechat-callback');
const state = Math.random().toString(36).substring(7);
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`;
// 跳转到微信授权页面
window.location.href = authUrl;
```
**步骤 2处理回调**
在回调页面(`https://your-domain.com/wechat-callback`
```javascript
// 获取 URL 参数中的 code
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
if (!code) {
alert('授权失败:未获取到授权码');
return;
}
// 调用后端 OAuth 登录接口
fetch('https://api.your-domain.com/api/c/v1/wechat/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
})
.then(res => res.json())
.then(data => {
if (data.code === 0) {
console.log('登录成功', data.data);
localStorage.setItem('token', data.data.token);
// 跳转到主页
window.location.href = '/';
} else {
alert(`登录失败: ${data.msg}`);
}
})
.catch(err => {
console.error('请求失败', err);
alert('登录失败,请重试');
});
```
#### 后端日志验证
```bash
# 查看 OAuth 请求日志
tail -f logs/app.log | grep -i oauth
```
**成功日志**
```json
{
"level": "debug",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "微信 OAuth 授权成功",
"open_id": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M",
"union_id": "oGfRjwX..."
}
{
"level": "info",
"ts": "2025-01-30T12:00:00.001+0800",
"msg": "个人客户创建成功",
"customer_id": 123
}
```
**失败日志**
```json
{
"level": "error",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "微信 OAuth 授权失败",
"code": "071abc123...",
"error": "invalid code"
}
```
#### 使用 curl 测试
```bash
# 替换为真实的授权码5分钟有效
CODE="071abc123456789def"
curl -X POST http://localhost:3000/api/c/v1/wechat/auth \
-H "Content-Type: application/json" \
-d "{\"code\":\"$CODE\"}"
```
**成功响应**
```json
{
"code": 0,
"msg": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"customer_id": 123,
"phone": "",
"nickname": "微信用户",
"is_new_user": true
},
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
---
### 2. 微信账号绑定
#### 前提条件
- 已有个人客户账号
- 已获取 JWT Token
#### 测试步骤
```bash
# 替换为真实的 Token 和授权码
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
CODE="071abc123456789def"
curl -X POST http://localhost:3000/api/c/v1/bind-wechat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"code\":\"$CODE\"}"
```
**成功响应**
```json
{
"code": 0,
"msg": "绑定成功",
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**失败响应**(微信号已被绑定):
```json
{
"code": 1020,
"msg": "该微信号已被其他账号绑定",
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
---
### 3. 微信 JSAPI 支付
#### 前提条件
- 已创建订单(状态为"待支付"
- 在微信内网页中调用
- 已获取用户 OpenID
#### 测试步骤
**步骤 1创建支付订单**
```bash
# 替换为真实的 Token、订单ID 和 OpenID
H5_TOKEN="your_h5_token_here"
ORDER_ID=1
OPEN_ID="o6_bmjrPTlm6_2sgVt7hMZOPfL2M"
curl -X POST "http://localhost:3000/api/h5/orders/$ORDER_ID/wechat-pay/jsapi" \
-H "Authorization: Bearer $H5_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"open_id\":\"$OPEN_ID\"}"
```
**成功响应**
```json
{
"code": 0,
"msg": "支付订单创建成功",
"data": {
"prepay_id": "wx30123456789012345678901234567890",
"pay_config": {
"appId": "wxabcdef1234567890",
"timeStamp": "1706606400",
"nonceStr": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS",
"package": "prepay_id=wx30123456789012345678901234567890",
"signType": "RSA",
"paySign": "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd..."
}
},
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**步骤 2前端唤起支付**
```javascript
// 获取支付配置后,调用微信 JSAPI
wx.chooseWXPay({
...payConfig,
success: function(res) {
console.log('支付成功', res);
alert('支付成功');
// 跳转到订单详情页
window.location.href = `/orders/${orderId}`;
},
fail: function(res) {
console.error('支付失败', res);
alert('支付失败:' + res.err_msg);
}
});
```
#### 后端日志验证
```bash
# 查看支付请求日志
tail -f logs/app.log | grep -i jsapi
```
**成功日志**
```json
{
"level": "info",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "创建 JSAPI 支付订单成功",
"order_no": "ORDER_20250130_001",
"prepay_id": "wx30123456789012345678901234567890"
}
```
---
### 4. 微信 H5 支付
#### 前提条件
- 已创建订单(状态为"待支付"
- 在浏览器中调用(微信外)
#### 测试步骤
**步骤 1创建 H5 支付订单**
```bash
# 替换为真实的 Token 和订单ID
H5_TOKEN="your_h5_token_here"
ORDER_ID=1
curl -X POST "http://localhost:3000/api/h5/orders/$ORDER_ID/wechat-pay/h5" \
-H "Authorization: Bearer $H5_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scene_info": {
"payer_client_ip": "123.12.12.123",
"h5_type": "Wap"
}
}'
```
**成功响应**
```json
{
"code": 0,
"msg": "H5 支付订单创建成功",
"data": {
"h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx30123456789012345678901234567890&package=3583359058"
},
"timestamp": "2025-01-30T12:00:00+08:00"
}
```
**步骤 2前端跳转支付**
```javascript
// 跳转到微信 H5 支付页面
const returnUrl = encodeURIComponent(`https://your-domain.com/orders/${orderId}`);
window.location.href = `${h5Url}&redirect_url=${returnUrl}`;
```
#### 后端日志验证
```bash
# 查看 H5 支付日志
tail -f logs/app.log | grep -i "h5"
```
**成功日志**
```json
{
"level": "info",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "创建 H5 支付订单成功",
"order_no": "ORDER_20250130_001",
"h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?..."
}
```
---
### 5. 支付回调验证
#### 验证方法1查看日志
支付成功后,微信会自动调用回调接口。查看日志验证:
```bash
# 查看支付回调日志
tail -f logs/app.log | grep -i "支付通知"
```
**成功日志**
```json
{
"level": "info",
"ts": "2025-01-30T12:00:00.000+0800",
"msg": "支付通知处理成功",
"out_trade_no": "ORDER_20250130_001",
"transaction_id": "4200001234202501301234567890"
}
```
#### 验证方法2查询订单状态
```bash
# 查询订单状态(使用数据库或 API
curl -X GET "http://localhost:3000/api/h5/orders/$ORDER_ID" \
-H "Authorization: Bearer $H5_TOKEN"
```
**预期响应**(订单已支付):
```json
{
"code": 0,
"data": {
"id": 1,
"order_no": "ORDER_20250130_001",
"status": "paid",
"total_amount": 100,
"paid_amount": 100,
"paid_at": "2025-01-30T12:00:00+08:00",
"payment_method": "wechat"
}
}
```
#### 验证方法3使用 Postman 模拟回调
**注意**:真实环境中由微信服务器调用,本地测试需要跳过签名验证。
```bash
# 模拟支付回调(仅测试环境)
curl -X POST http://localhost:3000/api/callback/wechat-pay \
-H "Content-Type: application/json" \
-d '{
"id": "test_id",
"create_time": "2025-01-30T12:00:00+08:00",
"resource_type": "encrypt-resource",
"event_type": "TRANSACTION.SUCCESS",
"summary": "支付成功",
"resource": {
"ciphertext": "...",
"nonce": "...",
"associated_data": "..."
}
}'
```
---
## 常见问题排查
### 1. 配置验证失败
**问题**:脚本报错 "缺失必填配置"
**解决方法**
```bash
# 检查环境变量是否加载
env | grep JUNHONG_WECHAT
# 重新加载环境变量
source .env.local
# 重新运行验证脚本
bash scripts/verify-wechat.sh
```
### 2. 服务启动失败
**问题**:日志显示 "微信配置不完整或无效"
**解决方法**
1. 查看详细错误日志
2. 检查证书文件路径是否正确
3. 验证证书文件权限600 或 644
4. 确认 APIv3 密钥长度为 32 位
### 3. OAuth 授权失败
**问题**:返回错误码 1044
**可能原因**
- 授权码已过期5分钟有效期
- 授权码已被使用过
- AppID 或 AppSecret 配置错误
- 回调域名未在公众号后台配置
**解决方法**
1. 重新发起授权流程获取新 code
2. 检查公众号配置
3. 查看详细日志:`tail -f logs/app.log | grep -i oauth`
### 4. 支付订单创建失败
**问题**:返回错误码 1046
**可能原因**
- 商户号配置错误
- 证书文件无效或过期
- APIv3 密钥错误
- 订单金额为0或负数
**解决方法**
1. 验证商户号和密钥
2. 检查证书有效期:`openssl x509 -in /app/certs/apiclient_cert.pem -noout -dates`
3. 确认证书序列号匹配
4. 查看详细日志:`tail -f logs/app.log | grep -i payment`
### 5. 支付回调签名验证失败
**问题**:日志显示 "支付回调签名验证失败"
**可能原因**
- 证书配置错误
- 证书序列号不匹配
- 证书已过期
**解决方法**
1. 重新下载最新的商户证书
2. 更新证书序列号配置
3. 确保证书文件路径正确
4. 验证证书:`bash scripts/verify-wechat.sh`
### 6. 启用调试日志
如需查看详细的 HTTP 请求日志:
```bash
# 设置环境变量
export JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=true
# 重启服务
go run cmd/api/main.go
# 查看调试日志
tail -f logs/app.log | grep -i wechat
```
---
## 验证清单
完成以下清单后,微信集成功能验证完成:
- [ ] 配置验证脚本通过0 错误)
- [ ] 服务启动成功,微信服务初始化日志正常
- [ ] 微信 OAuth 登录成功,返回 Token
- [ ] 微信账号绑定成功
- [ ] JSAPI 支付订单创建成功,返回支付配置
- [ ] H5 支付订单创建成功,返回支付 URL
- [ ] 支付回调处理成功,订单状态更新为"已支付"
- [ ] 日志中无错误或警告信息
---
## 相关文档
- [使用指南](./使用指南.md) - 详细的配置和部署说明
- [API 文档](./API文档.md) - 接口说明和示例
- [环境变量配置](../environment-variables.md) - 所有环境变量说明

22
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/break/junhong_cmp_fiber
go 1.25
require (
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
github.com/aws/aws-sdk-go v1.55.5
github.com/bytedance/sonic v1.14.2
github.com/go-playground/validator/v10 v10.28.0
@@ -12,13 +13,13 @@ require (
github.com/google/uuid v1.6.0
github.com/hibiken/asynq v0.25.1
github.com/jackc/pgx/v5 v5.7.6
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.3
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/swaggest/openapi-go v0.2.60
github.com/valyala/fasthttp v1.66.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.44.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.47.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
@@ -29,11 +30,14 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 // indirect
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -56,8 +60,10 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
@@ -76,14 +82,14 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

52
go.sum
View File

@@ -2,6 +2,12 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBjmgLnEwCLqs=
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8 h1:0v/CMFzz5/0K9mEMebyBzlmap1tidv2PaUFSnq/bJhk=
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8/go.mod h1:VZQNCvcK/rldF3QaExiSl1gJEAkyc5/I8RLOd3WFZq4=
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -28,6 +34,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -165,6 +173,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
@@ -175,8 +185,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -248,37 +258,39 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3Ifn
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/storage"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -14,13 +15,15 @@ import (
// Dependencies 封装所有基础依赖
// 这些是应用启动时初始化的核心组件
type Dependencies struct {
DB *gorm.DB // PostgreSQL 数据库连接
Redis *redis.Client // Redis 客户端
Logger *zap.Logger // 应用日志器
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
TokenManager *auth.TokenManager // Token 管理器后台和H5认证
VerificationService *verification.Service // 验证码服务
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
DB *gorm.DB // PostgreSQL 数据库连接
Redis *redis.Client // Redis 客户端
Logger *zap.Logger // 应用日志器
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
TokenManager *auth.TokenManager // Token 管理器后台和H5认证
VerificationService *verification.Service // 验证码服务
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatOfficialAccount wechat.OfficialAccountServiceInterface // 微信公众号服务(可选)
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
}

View File

@@ -45,6 +45,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
AdminOrder: admin.NewOrderHandler(svc.Order),
H5Order: h5.NewOrderHandler(svc.Order),
PaymentCallback: callback.NewPaymentHandler(svc.Order),
PaymentCallback: callback.NewPaymentHandler(svc.Order, deps.WechatPayment),
}
}

View File

@@ -76,7 +76,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
Shop: shopSvc.New(s.Shop, s.Account),
ShopAccount: shopAccountSvc.New(s.Account, s.Shop),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, deps.TokenManager, deps.Logger),
@@ -119,6 +119,6 @@ func initServices(s *stores, deps *Dependencies) *services {
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig, deps.QueueClient, deps.Logger),
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig, deps.WechatPayment, deps.QueueClient, deps.Logger),
}
}

View File

@@ -3,6 +3,7 @@
package app
import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
@@ -108,27 +109,49 @@ func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
return response.Success(c, resp)
}
// BindWechatRequest 绑定微信请求
type BindWechatRequest struct {
Code string `json:"code" validate:"required"` // 微信授权码
}
// BindWechat 绑定微信
// POST /api/c/v1/bind-wechat
// TODO: 实现微信 OAuth 授权逻辑
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
var req BindWechatRequest
// WechatOAuthLogin 微信 OAuth 登录
// POST /api/c/v1/wechat/auth
func (h *PersonalCustomerHandler) WechatOAuthLogin(c *fiber.Ctx) error {
var req dto.WechatOAuthRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// TODO: 实现完整的微信绑定流程
// 1. 从 context 中获取当前登录的客户 ID
// 2. 使用微信授权码换取 OpenID 和 UnionID
// 3. 调用 service 层的 BindWechat 方法绑定微信
result, err := h.service.WechatOAuthLogin(c.Context(), req.Code)
if err != nil {
h.logger.Error("微信 OAuth 登录失败",
zap.String("code", req.Code),
zap.Error(err),
)
return err
}
return response.Success(c, result)
}
// BindWechat 绑定微信
// POST /api/c/v1/bind-wechat
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
var req dto.WechatOAuthRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
customerID, ok := c.Locals("customer_id").(uint)
if !ok {
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
}
if err := h.service.BindWechatWithCode(c.Context(), customerID, req.Code); err != nil {
h.logger.Error("绑定微信失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return err
}
return response.Success(c, fiber.Map{
"message": "微信绑定功能暂未实现,待微信 SDK 对接后启用",
"message": "绑定成功",
})
}

View File

@@ -1,38 +1,51 @@
package callback
import (
"context"
"net/http"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
"github.com/break/junhong_cmp_fiber/internal/model"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
type PaymentHandler struct {
orderService *orderService.Service
orderService *orderService.Service
wechatPayment wechat.PaymentServiceInterface
}
func NewPaymentHandler(orderService *orderService.Service) *PaymentHandler {
return &PaymentHandler{orderService: orderService}
}
type WechatPayCallbackRequest struct {
OrderNo string `json:"order_no" xml:"out_trade_no"`
func NewPaymentHandler(orderService *orderService.Service, wechatPayment wechat.PaymentServiceInterface) *PaymentHandler {
return &PaymentHandler{
orderService: orderService,
wechatPayment: wechatPayment,
}
}
// WechatPayCallback 微信支付回调(带签名验证)
// POST /api/callback/wechat-pay
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
var req WechatPayCallbackRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
if h.wechatPayment == nil {
return errors.New(errors.CodeWechatCallbackInvalid, "微信支付服务未配置")
}
if req.OrderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
var httpReq http.Request
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
if err := h.orderService.HandlePaymentCallback(c.UserContext(), req.OrderNo, model.PaymentMethodWechat); err != nil {
return err
ctx := context.Background()
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return nil
}
return h.orderService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat)
})
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
}
return response.Success(c, map[string]string{"return_code": "SUCCESS"})

View File

@@ -129,3 +129,79 @@ func (h *OrderHandler) WalletPay(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// WechatPayJSAPI 微信 JSAPI 支付
// POST /api/h5/orders/:id/wechat-pay/jsapi
func (h *OrderHandler) WechatPayJSAPI(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
var req dto.WechatPayJSAPIRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
result, err := h.service.WechatPayJSAPI(ctx, uint(id), req.OpenID, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// WechatPayH5 微信 H5 支付
// POST /api/h5/orders/:id/wechat-pay/h5
func (h *OrderHandler) WechatPayH5(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
var req dto.WechatPayH5Request
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
result, err := h.service.WechatPayH5(ctx, uint(id), &req.SceneInfo, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -6,12 +6,24 @@ type LoginRequest struct {
Device string `json:"device" validate:"omitempty,oneof=web h5 mobile"`
}
// MenuNode 菜单节点(树形结构)
type MenuNode struct {
ID uint `json:"id" description:"权限ID"`
PermCode string `json:"perm_code" description:"权限码"`
Name string `json:"name" description:"菜单名称"`
URL string `json:"url" description:"路由路径"`
Sort int `json:"sort" description:"排序值"`
Children []MenuNode `json:"children" description:"子菜单"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User UserInfo `json:"user"`
Permissions []string `json:"permissions"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User UserInfo `json:"user"`
Permissions []string `json:"permissions" description:"所有权限码(向后兼容)"`
Menus []MenuNode `json:"menus" description:"菜单树"`
Buttons []string `json:"buttons" description:"按钮权限码"`
}
type UserInfo struct {

View File

@@ -0,0 +1,47 @@
package dto
type WechatOAuthRequest struct {
Code string `json:"code" validate:"required" required:"true" description:"微信授权码"`
}
type WechatOAuthResponse struct {
AccessToken string `json:"access_token" description:"访问令牌"`
ExpiresIn int64 `json:"expires_in" description:"令牌有效期(秒)"`
Customer *PersonalCustomerResponse `json:"customer" description:"客户信息"`
}
type WechatPayJSAPIRequest struct {
OpenID string `json:"openid" validate:"required" required:"true" description:"用户OpenID"`
}
type WechatPayJSAPIResponse struct {
PrepayID string `json:"prepay_id" description:"预支付交易会话标识"`
PayConfig map[string]interface{} `json:"pay_config" description:"JSSDK支付配置"`
}
type WechatPayH5Request struct {
SceneInfo WechatH5SceneInfo `json:"scene_info" validate:"required" required:"true" description:"场景信息"`
}
type WechatH5SceneInfo struct {
PayerClientIP string `json:"payer_client_ip" validate:"required,ip" required:"true" description:"用户终端IP"`
H5Info WechatH5Detail `json:"h5_info" description:"H5场景信息"`
}
type WechatH5Detail struct {
Type string `json:"type" validate:"omitempty,oneof=iOS Android Wap" description:"场景类型 (iOS:苹果, Android:安卓, Wap:浏览器)"`
}
type WechatPayH5Response struct {
H5URL string `json:"h5_url" description:"微信支付跳转URL"`
}
type WechatPayJSAPIParams struct {
ID uint `path:"id" description:"订单ID" required:"true"`
WechatPayJSAPIRequest
}
type WechatPayH5Params struct {
ID uint `path:"id" description:"订单ID" required:"true"`
WechatPayH5Request
}

View File

@@ -78,6 +78,22 @@ func registerH5OrderRoutes(router fiber.Router, handler *h5.OrderHandler, doc *o
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/jsapi", handler.WechatPayJSAPI, RouteSpec{
Summary: "微信 JSAPI 支付",
Tags: []string{"H5 订单"},
Input: new(dto.WechatPayJSAPIParams),
Output: new(dto.WechatPayJSAPIResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/h5", handler.WechatPayH5, RouteSpec{
Summary: "微信 H5 支付",
Tags: []string{"H5 订单"},
Input: new(dto.WechatPayH5Params),
Output: new(dto.WechatPayH5Response),
Auth: true,
})
}
// registerPaymentCallbackRoutes 注册支付回调路由

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
@@ -35,6 +36,16 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Output: &apphandler.LoginResponse{},
})
// 微信 OAuth 登录(公开)
Register(publicGroup, doc, basePath, "POST", "/wechat/auth", handlers.PersonalCustomer.WechatOAuthLogin, RouteSpec{
Summary: "微信授权登录",
Description: "使用微信授权码登录,自动创建或关联用户",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &dto.WechatOAuthRequest{},
Output: &dto.WechatOAuthResponse{},
})
// 需要认证的路由
authGroup := router.Group("")
authGroup.Use(personalAuthMiddleware.Authenticate())
@@ -45,7 +56,7 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Description: "绑定微信账号到当前个人客户",
Tags: []string{"个人客户 - 账户"},
Auth: true,
Input: &apphandler.BindWechatRequest{},
Input: &dto.WechatOAuthRequest{},
Output: nil,
})

View File

@@ -0,0 +1,186 @@
package auth
import (
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"gorm.io/gorm"
)
func TestClassifyPermissions_PlatformFilter(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
permissions := []*model.Permission{
{
Model: gorm.Model{ID: 1},
PermCode: "dashboard:menu",
PermName: "仪表盘",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
{
Model: gorm.Model{ID: 2},
PermCode: "user:menu",
PermName: "用户管理",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformWeb,
Status: constants.StatusEnabled,
},
{
Model: gorm.Model{ID: 3},
PermCode: "mobile:menu",
PermName: "移动端菜单",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformH5,
Status: constants.StatusEnabled,
},
}
allCodes, menus, buttons, err := service.classifyPermissions(permissions, constants.PlatformWeb)
assert.NoError(t, err)
assert.Len(t, allCodes, 2)
assert.Contains(t, allCodes, "dashboard:menu")
assert.Contains(t, allCodes, "user:menu")
assert.NotContains(t, allCodes, "mobile:menu")
assert.Len(t, menus, 2)
assert.Empty(t, buttons)
}
func TestClassifyPermissions_MenuAndButton(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
permissions := []*model.Permission{
{
Model: gorm.Model{ID: 1},
PermCode: "user:menu",
PermName: "用户管理",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
{
Model: gorm.Model{ID: 2},
PermCode: "user:create",
PermName: "创建用户",
PermType: constants.PermissionTypeButton,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
{
Model: gorm.Model{ID: 3},
PermCode: "user:delete",
PermName: "删除用户",
PermType: constants.PermissionTypeButton,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
}
allCodes, menus, buttons, err := service.classifyPermissions(permissions, constants.PlatformWeb)
assert.NoError(t, err)
assert.Len(t, allCodes, 3)
assert.Len(t, menus, 1)
assert.Equal(t, "user:menu", menus[0].PermCode)
assert.Len(t, buttons, 2)
assert.Contains(t, buttons, "user:create")
assert.Contains(t, buttons, "user:delete")
}
func TestClassifyPermissions_AllPermissions(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
permissions := []*model.Permission{
{
Model: gorm.Model{ID: 1},
PermCode: "menu1",
PermName: "菜单1",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
{
Model: gorm.Model{ID: 2},
PermCode: "button1",
PermName: "按钮1",
PermType: constants.PermissionTypeButton,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
}
allCodes, _, _, err := service.classifyPermissions(permissions, constants.PlatformWeb)
assert.NoError(t, err)
assert.Len(t, allCodes, 2)
assert.Contains(t, allCodes, "menu1")
assert.Contains(t, allCodes, "button1")
}
func TestClassifyPermissions_PlatformAll(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
permissions := []*model.Permission{
{
Model: gorm.Model{ID: 1},
PermCode: "common:menu",
PermName: "通用菜单",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
}
allCodesWeb, menusWeb, _, errWeb := service.classifyPermissions(permissions, constants.PlatformWeb)
allCodesH5, menusH5, _, errH5 := service.classifyPermissions(permissions, constants.PlatformH5)
assert.NoError(t, errWeb)
assert.NoError(t, errH5)
assert.Len(t, allCodesWeb, 1)
assert.Len(t, allCodesH5, 1)
assert.Len(t, menusWeb, 1)
assert.Len(t, menusH5, 1)
assert.Equal(t, "common:menu", menusWeb[0].PermCode)
assert.Equal(t, "common:menu", menusH5[0].PermCode)
}
func TestClassifyPermissions_DisabledPermissions(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
permissions := []*model.Permission{
{
Model: gorm.Model{ID: 1},
PermCode: "enabled:menu",
PermName: "启用菜单",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformAll,
Status: constants.StatusEnabled,
},
{
Model: gorm.Model{ID: 2},
PermCode: "disabled:menu",
PermName: "禁用菜单",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformAll,
Status: constants.StatusDisabled,
},
}
allCodes, menus, _, err := service.classifyPermissions(permissions, constants.PlatformWeb)
assert.NoError(t, err)
assert.Len(t, allCodes, 1)
assert.Contains(t, allCodes, "enabled:menu")
assert.NotContains(t, allCodes, "disabled:menu")
assert.Len(t, menus, 1)
}

View File

@@ -0,0 +1,126 @@
package auth
import (
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"gorm.io/gorm"
)
func TestBuildMenuTree_RootNodes(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
permissions := []*model.Permission{
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
{Model: gorm.Model{ID: 2}, PermCode: "order:menu", PermName: "订单管理", URL: "/orders", Sort: 2, ParentID: nil},
{Model: gorm.Model{ID: 3}, PermCode: "dashboard:menu", PermName: "仪表盘", URL: "/dashboard", Sort: 0, ParentID: nil},
}
result := service.buildMenuTree(permissions)
assert.Len(t, result, 3)
assert.Equal(t, "dashboard:menu", result[0].PermCode)
assert.Equal(t, "user:menu", result[1].PermCode)
assert.Equal(t, "order:menu", result[2].PermCode)
assert.Empty(t, result[0].Children)
}
func TestBuildMenuTree_MultiLevel(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
parentID1 := uint(1)
parentID2 := uint(3)
permissions := []*model.Permission{
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
{Model: gorm.Model{ID: 2}, PermCode: "user:list:menu", PermName: "用户列表", URL: "/users/list", Sort: 10, ParentID: &parentID1},
{Model: gorm.Model{ID: 3}, PermCode: "user:role:menu", PermName: "角色管理", URL: "/users/roles", Sort: 5, ParentID: &parentID1},
{Model: gorm.Model{ID: 4}, PermCode: "user:role:detail:menu", PermName: "角色详情", URL: "/users/roles/detail", Sort: 1, ParentID: &parentID2},
}
result := service.buildMenuTree(permissions)
assert.Len(t, result, 1)
assert.Equal(t, "user:menu", result[0].PermCode)
assert.Len(t, result[0].Children, 2)
assert.Equal(t, "user:role:menu", result[0].Children[0].PermCode)
assert.Equal(t, "user:list:menu", result[0].Children[1].PermCode)
assert.Len(t, result[0].Children[0].Children, 1)
assert.Equal(t, "user:role:detail:menu", result[0].Children[0].Children[0].PermCode)
}
func TestBuildMenuTree_OrphanNodes(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
nonExistentParentID := uint(999)
permissions := []*model.Permission{
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
{Model: gorm.Model{ID: 2}, PermCode: "orphan:menu", PermName: "孤儿菜单", URL: "/orphan", Sort: 0, ParentID: &nonExistentParentID},
}
result := service.buildMenuTree(permissions)
assert.Len(t, result, 2)
assert.Equal(t, "orphan:menu", result[0].PermCode)
assert.Equal(t, "user:menu", result[1].PermCode)
assert.Empty(t, result[0].Children)
}
func TestBuildMenuTree_Sorting(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
parentID := uint(1)
permissions := []*model.Permission{
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
{Model: gorm.Model{ID: 2}, PermCode: "user:list:menu", PermName: "用户列表", URL: "/users/list", Sort: 10, ParentID: &parentID},
{Model: gorm.Model{ID: 3}, PermCode: "user:role:menu", PermName: "角色管理", URL: "/users/roles", Sort: 5, ParentID: &parentID},
{Model: gorm.Model{ID: 4}, PermCode: "user:dept:menu", PermName: "部门管理", URL: "/users/depts", Sort: 8, ParentID: &parentID},
}
result := service.buildMenuTree(permissions)
assert.Len(t, result, 1)
assert.Len(t, result[0].Children, 3)
assert.Equal(t, "user:role:menu", result[0].Children[0].PermCode)
assert.Equal(t, 5, result[0].Children[0].Sort)
assert.Equal(t, "user:dept:menu", result[0].Children[1].PermCode)
assert.Equal(t, 8, result[0].Children[1].Sort)
assert.Equal(t, "user:list:menu", result[0].Children[2].PermCode)
assert.Equal(t, 10, result[0].Children[2].Sort)
}
func TestBuildMenuTree_EmptyInput(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
result := service.buildMenuTree([]*model.Permission{})
assert.NotNil(t, result)
assert.Empty(t, result)
}
func TestSortMenuNodes(t *testing.T) {
logger, _ := zap.NewDevelopment()
service := &Service{logger: logger}
nodes := []dto.MenuNode{
{ID: 3, PermCode: "c", Sort: 30, Children: []dto.MenuNode{}},
{ID: 1, PermCode: "a", Sort: 10, Children: []dto.MenuNode{}},
{ID: 2, PermCode: "b", Sort: 20, Children: []dto.MenuNode{}},
}
service.sortMenuNodes(nodes)
assert.Equal(t, "a", nodes[0].PermCode)
assert.Equal(t, "b", nodes[1].PermCode)
assert.Equal(t, "c", nodes[2].PermCode)
}

View File

@@ -2,6 +2,7 @@ package auth
import (
"context"
"sort"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -92,10 +93,12 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
return nil, err
}
permissions, err := s.getUserPermissions(ctx, account.ID)
permissions, menus, buttons, err := s.getUserPermissionsAndMenus(ctx, account.ID, account.UserType, device)
if err != nil {
s.logger.Error("查询用户权限失败", zap.Uint("user_id", account.ID), zap.Error(err))
permissions = []string{}
menus = []dto.MenuNode{}
buttons = []string{}
}
userInfo := s.buildUserInfo(account)
@@ -113,6 +116,8 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
ExpiresIn: int64(constants.DefaultAccessTokenTTL.Seconds()),
User: userInfo,
Permissions: permissions,
Menus: menus,
Buttons: buttons,
}, nil
}
@@ -258,3 +263,135 @@ func (s *Service) getUserTypeName(userType int) string {
return "未知"
}
}
func (s *Service) getUserPermissionsAndMenus(ctx context.Context, userID uint, userType int, device string) ([]string, []dto.MenuNode, []string, error) {
if userType == constants.UserTypeSuperAdmin {
return s.getAllPermissionsForSuperAdmin(ctx, device)
}
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询用户角色失败")
}
if len(accountRoles) == 0 {
return []string{}, []dto.MenuNode{}, []string{}, nil
}
roleIDs := make([]uint, 0, len(accountRoles))
for _, ar := range accountRoles {
roleIDs = append(roleIDs, ar.RoleID)
}
permIDs, err := s.rolePermStore.GetPermIDsByRoleIDs(ctx, roleIDs)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询角色权限失败")
}
if len(permIDs) == 0 {
return []string{}, []dto.MenuNode{}, []string{}, nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询权限详情失败")
}
return s.classifyPermissions(permissions, device)
}
func (s *Service) getAllPermissionsForSuperAdmin(ctx context.Context, device string) ([]string, []dto.MenuNode, []string, error) {
permissions, err := s.permissionStore.GetAll(ctx, nil)
if err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeInternalError, err, "查询所有权限失败")
}
return s.classifyPermissions(permissions, device)
}
func (s *Service) classifyPermissions(permissions []*model.Permission, device string) ([]string, []dto.MenuNode, []string, error) {
var menuPerms []*model.Permission
var buttonCodes []string
var allCodes []string
for _, perm := range permissions {
if perm.Status != constants.StatusEnabled {
continue
}
if perm.Platform != constants.PlatformAll && perm.Platform != device {
continue
}
allCodes = append(allCodes, perm.PermCode)
if perm.PermType == constants.PermissionTypeMenu {
menuPerms = append(menuPerms, perm)
} else if perm.PermType == constants.PermissionTypeButton {
buttonCodes = append(buttonCodes, perm.PermCode)
}
}
menuTree := s.buildMenuTree(menuPerms)
return allCodes, menuTree, buttonCodes, nil
}
func (s *Service) buildMenuTree(permissions []*model.Permission) []dto.MenuNode {
if len(permissions) == 0 {
return []dto.MenuNode{}
}
permMap := make(map[uint]*model.Permission)
for _, p := range permissions {
permMap[p.ID] = p
}
var roots []dto.MenuNode
for _, p := range permissions {
if p.ParentID == nil || *p.ParentID == 0 {
roots = append(roots, s.buildNode(p, permMap))
} else if _, ok := permMap[*p.ParentID]; !ok {
s.logger.Warn("检测到孤儿节点",
zap.Uint("child_id", p.ID),
zap.String("perm_code", p.PermCode),
zap.Uint("parent_id", *p.ParentID),
)
roots = append(roots, s.buildNode(p, permMap))
}
}
s.sortMenuNodes(roots)
return roots
}
func (s *Service) buildNode(perm *model.Permission, permMap map[uint]*model.Permission) dto.MenuNode {
node := dto.MenuNode{
ID: perm.ID,
PermCode: perm.PermCode,
Name: perm.PermName,
URL: perm.URL,
Sort: perm.Sort,
Children: []dto.MenuNode{},
}
for _, p := range permMap {
if p.ParentID != nil && *p.ParentID == perm.ID {
node.Children = append(node.Children, s.buildNode(p, permMap))
}
}
return node
}
func (s *Service) sortMenuNodes(nodes []dto.MenuNode) {
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Sort < nodes[j].Sort
})
for i := range nodes {
if len(nodes[i].Children) > 0 {
s.sortMenuNodes(nodes[i].Children)
}
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -26,6 +27,7 @@ type Service struct {
walletStore *postgres.WalletStore
purchaseValidationService *purchase_validation.Service
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore
wechatPayment wechat.PaymentServiceInterface
queueClient *queue.Client
logger *zap.Logger
}
@@ -37,6 +39,7 @@ func New(
walletStore *postgres.WalletStore,
purchaseValidationService *purchase_validation.Service,
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
wechatPayment wechat.PaymentServiceInterface,
queueClient *queue.Client,
logger *zap.Logger,
) *Service {
@@ -47,6 +50,7 @@ func New(
walletStore: walletStore,
purchaseValidationService: purchaseValidationService,
allocationConfigStore: allocationConfigStore,
wechatPayment: wechatPayment,
queueClient: queueClient,
logger: logger,
}
@@ -529,3 +533,116 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
UpdatedAt: order.UpdatedAt,
}
}
// WechatPayJSAPI 发起微信 JSAPI 支付
func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) {
if s.wechatPayment == nil {
s.logger.Error("微信支付服务未配置")
return nil, errors.New(errors.CodeWechatPayFailed, "微信支付服务未配置")
}
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
}
description := "套餐购买"
if len(items) > 0 {
description = items[0].PackageName
}
result, err := s.wechatPayment.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
s.logger.Error("创建 JSAPI 支付失败",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.Error(err),
)
return nil, err
}
s.logger.Info("创建 JSAPI 支付成功",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("prepay_id", result.PrepayID),
)
payConfig, _ := result.PayConfig.(map[string]interface{})
return &dto.WechatPayJSAPIResponse{
PrepayID: result.PrepayID,
PayConfig: payConfig,
}, nil
}
// WechatPayH5 发起微信 H5 支付
func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.WechatH5SceneInfo, buyerType string, buyerID uint) (*dto.WechatPayH5Response, error) {
if s.wechatPayment == nil {
s.logger.Error("微信支付服务未配置")
return nil, errors.New(errors.CodeWechatPayFailed, "微信支付服务未配置")
}
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
}
description := "套餐购买"
if len(items) > 0 {
description = items[0].PackageName
}
h5SceneInfo := &wechat.H5SceneInfo{
PayerClientIP: sceneInfo.PayerClientIP,
H5Type: sceneInfo.H5Info.Type,
}
result, err := s.wechatPayment.CreateH5Order(ctx, order.OrderNo, description, int(order.TotalAmount), h5SceneInfo)
if err != nil {
s.logger.Error("创建 H5 支付失败",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.Error(err),
)
return nil, err
}
s.logger.Info("创建 H5 支付成功",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("h5_url", result.H5URL),
)
return &dto.WechatPayH5Response{
H5URL: result.H5URL,
}, nil
}

View File

@@ -126,7 +126,7 @@ func setupOrderTestEnv(t *testing.T) *testEnv {
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, logger)
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil, logger)
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
@@ -536,7 +536,7 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) {
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, logger)
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil, logger)
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,

View File

@@ -6,21 +6,24 @@ import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Service 个人客户服务
type Service struct {
store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
verificationService *verification.Service
jwtManager *auth.JWTManager
logger *zap.Logger
store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
verificationService *verification.Service
jwtManager *auth.JWTManager
wechatOfficialAccount wechat.OfficialAccountServiceInterface
logger *zap.Logger
}
// NewService 创建个人客户服务实例
@@ -29,14 +32,16 @@ func NewService(
phoneStore *postgres.PersonalCustomerPhoneStore,
verificationService *verification.Service,
jwtManager *auth.JWTManager,
wechatOfficialAccount wechat.OfficialAccountServiceInterface,
logger *zap.Logger,
) *Service {
return &Service{
store: store,
phoneStore: phoneStore,
verificationService: verificationService,
jwtManager: jwtManager,
logger: logger,
store: store,
phoneStore: phoneStore,
verificationService: verificationService,
jwtManager: jwtManager,
wechatOfficialAccount: wechatOfficialAccount,
logger: logger,
}
}
@@ -236,3 +241,190 @@ func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*mo
return customer, phone, nil
}
// WechatOAuthLogin 微信 OAuth 登录
// 通过微信授权码登录,如果用户不存在则自动创建
func (s *Service) WechatOAuthLogin(ctx context.Context, code string) (*dto.WechatOAuthResponse, error) {
// 检查微信服务是否已配置
if s.wechatOfficialAccount == nil {
s.logger.Error("微信公众号服务未配置")
return nil, errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置")
}
// 通过授权码获取用户详细信息
userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code)
if err != nil {
s.logger.Error("获取微信用户信息失败",
zap.String("code", code),
zap.Error(err),
)
return nil, err
}
// 通过 OpenID 查找现有客户
customer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID)
if err != nil {
if err == gorm.ErrRecordNotFound {
// 客户不存在,创建新客户
customer = &model.PersonalCustomer{
WxOpenID: userInfo.OpenID,
WxUnionID: userInfo.UnionID,
Nickname: userInfo.Nickname,
AvatarURL: userInfo.Avatar,
Status: 1, // 默认启用
}
if err := s.store.Create(ctx, customer); err != nil {
s.logger.Error("创建微信用户失败",
zap.String("open_id", userInfo.OpenID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建用户失败")
}
s.logger.Info("通过微信创建新用户",
zap.Uint("customer_id", customer.ID),
zap.String("open_id", userInfo.OpenID),
)
} else {
s.logger.Error("查询微信用户失败",
zap.String("open_id", userInfo.OpenID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "查询用户失败")
}
} else {
// 客户已存在,更新昵称和头像(如果有变化)
needUpdate := false
if userInfo.Nickname != "" && customer.Nickname != userInfo.Nickname {
customer.Nickname = userInfo.Nickname
needUpdate = true
}
if userInfo.Avatar != "" && customer.AvatarURL != userInfo.Avatar {
customer.AvatarURL = userInfo.Avatar
needUpdate = true
}
if needUpdate {
if err := s.store.Update(ctx, customer); err != nil {
s.logger.Warn("更新微信用户信息失败",
zap.Uint("customer_id", customer.ID),
zap.Error(err),
)
// 不阻断登录流程
}
}
}
// 检查客户状态
if customer.Status == 0 {
s.logger.Warn("微信用户已被禁用",
zap.Uint("customer_id", customer.ID),
zap.String("open_id", userInfo.OpenID),
)
return nil, errors.New(errors.CodeForbidden, "账号已被禁用")
}
// 生成 JWT Token
token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, "")
if err != nil {
s.logger.Error("生成 Token 失败",
zap.Uint("customer_id", customer.ID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败")
}
// 获取主手机号(如果有)
phone := ""
primaryPhone, err := s.phoneStore.GetPrimaryPhone(ctx, customer.ID)
if err == nil {
phone = primaryPhone.Phone
}
s.logger.Info("微信 OAuth 登录成功",
zap.Uint("customer_id", customer.ID),
zap.String("open_id", userInfo.OpenID),
)
return &dto.WechatOAuthResponse{
AccessToken: token,
ExpiresIn: 24 * 60 * 60, // 24 小时
Customer: &dto.PersonalCustomerResponse{
ID: customer.ID,
Phone: phone,
Nickname: customer.Nickname,
AvatarURL: customer.AvatarURL,
WxOpenID: customer.WxOpenID,
WxUnionID: customer.WxUnionID,
Status: customer.Status,
CreatedAt: customer.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: customer.UpdatedAt.Format("2006-01-02 15:04:05"),
},
}, nil
}
// BindWechatWithCode 通过微信授权码绑定微信
// customerID: 当前登录的客户 ID
// code: 微信授权码
func (s *Service) BindWechatWithCode(ctx context.Context, customerID uint, code string) error {
// 检查微信服务是否已配置
if s.wechatOfficialAccount == nil {
s.logger.Error("微信公众号服务未配置")
return errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置")
}
// 获取客户信息
customer, err := s.store.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("查询个人客户失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "查询客户失败")
}
// 获取微信用户信息
userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code)
if err != nil {
s.logger.Error("获取微信用户信息失败",
zap.Uint("customer_id", customerID),
zap.String("code", code),
zap.Error(err),
)
return err
}
// 检查该 OpenID 是否已被其他用户绑定
existingCustomer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID)
if err == nil && existingCustomer.ID != customerID {
s.logger.Warn("微信账号已被其他用户绑定",
zap.Uint("customer_id", customerID),
zap.Uint("existing_customer_id", existingCustomer.ID),
zap.String("open_id", userInfo.OpenID),
)
return errors.New(errors.CodeConflict, "该微信账号已被其他用户绑定")
}
// 更新微信信息
customer.WxOpenID = userInfo.OpenID
customer.WxUnionID = userInfo.UnionID
if userInfo.Nickname != "" {
customer.Nickname = userInfo.Nickname
}
if userInfo.Avatar != "" {
customer.AvatarURL = userInfo.Avatar
}
if err := s.store.Update(ctx, customer); err != nil {
s.logger.Error("绑定微信信息失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "绑定微信失败")
}
s.logger.Info("绑定微信成功",
zap.Uint("customer_id", customerID),
zap.String("open_id", userInfo.OpenID),
)
return nil
}

View File

@@ -0,0 +1,590 @@
## Context
当前登录系统通过 `auth.Service.Login()` 方法查询用户的角色权限,并返回扁平的权限码列表 `permissions: []string`。前端收到这个列表后,需要:
1. **菜单渲染问题**:权限码是扁平的(如 `["user:menu", "user:list:menu", "order:menu"]`),但前端侧边栏需要树形结构。前端要么额外请求菜单接口 `GET /api/admin/permissions/tree`,要么在本地维护菜单配置与权限码的映射关系。
2. **按钮控制问题**:权限码中混合了菜单权限和按钮权限,前端需要自行区分哪些是用于显示/隐藏按钮的权限(如 `user:create`, `user:delete`)。
现有的 `tb_permission` 表已经包含了构建菜单树所需的所有字段:
- `perm_type`: 权限类型1=菜单权限2=按钮权限)
- `parent_id`: 父级权限 ID用于树形结构
- `platform`: 平台标识web/h5/all
- `sort`: 排序字段
现有的权限查询逻辑(`auth.Service.getUserPermissions()`)已经能够获取用户的所有权限 ID并查询权限详情。我们只需要在此基础上增加分类和树形结构构建逻辑。
## Goals / Non-Goals
**Goals:**
1. 登录时返回结构化的权限数据,前端无需二次处理或额外请求
2. 菜单数据为树形结构,可直接用于渲染侧边栏(基于 `parent_id` 构建)
3. 按钮权限为扁平列表,可直接用于 `hasPermission()` 判断
4. 根据 `device` 参数自动过滤平台(避免泄露其他端的菜单)
5. 保持向后兼容性(保留原有 `permissions` 字段)
6. 性能可控(登录响应时间增加 < 50ms
**Non-Goals:**
1. **不修改 GetMe 接口**:避免频繁查询和构建菜单树,前端应将菜单数据缓存到 localStorage
2. **不添加额外字段**MenuNode 不包含 `icon`, `badge`, `hidden` 等扩展字段(保持简洁)
3. **不修改数据库 schema**:复用现有 `tb_permission` 表的字段
4. **不实现动态菜单刷新**:权限变更后需要重新登录(短期方案)
5. **不处理权限变更通知**:不引入 WebSocket 推送(长期优化项)
## Decisions
### 决策 1: 数据结构设计
**选择**:新增 `MenuNode` DTO包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段
**理由**
- `id`: 用于调试和日志追踪(虽然前端主要使用 `perm_code`
- `perm_code`: 前端可能用于路由匹配或权限验证
- `name`: 显示在侧边栏的文本
- `url`: 路由路径(前端用于 `<router-link>`
- `sort`: 保持菜单顺序(前端直接渲染,不需要再排序)
- `children`: 递归结构(支持无限层级)
**替代方案**
- **方案 A被拒绝**:只返回菜单 ID 列表,前端根据 ID 查询菜单详情
- 缺点:增加前端复杂度,需要维护 ID 到菜单对象的映射
- **方案 B被拒绝**:返回完整的 `Permission` 对象(包含所有字段)
- 缺点:响应体过大,包含前端不需要的字段(如 `creator`, `updater`, `created_at`
### 决策 2: 权限分类逻辑
**选择**:基于 `perm_type` 字段分类1=菜单2=按钮),在 Service 层实现
**数据流**
```
查询用户权限 → 获取 Permission 对象列表
遍历权限,根据 perm_type 分类:
├── perm_type = 1 → 菜单权限列表(用于构建树)
├── perm_type = 2 → 按钮权限码列表(直接提取 perm_code
└── 所有权限码 → permissions 字段(向后兼容)
菜单权限 → buildMenuTree() → 树形结构
按钮权限 → 扁平列表
```
**实现位置**`auth.Service.getUserPermissionsAndMenus()`(新增方法)
**理由**
- 复用现有的权限查询逻辑(`getUserPermissions()` 已经能获取所有权限)
- 单一职责:权限分类逻辑放在 Service 层(不在 Handler 层处理)
- 可测试性:分类和树构建逻辑可以独立单元测试
**替代方案**
- **方案 A被拒绝**:在数据库查询时分别查询菜单权限和按钮权限
- 缺点:需要两次查询,增加数据库负载
- **方案 B被拒绝**:在前端分类
- 缺点:增加前端复杂度,需要理解 `perm_type` 字段含义
### 决策 3: 菜单树构建算法
**选择**:使用 HashMap + 单次遍历构建树O(n) 时间复杂度)
**算法**
```go
func buildMenuTree(permissions []*model.Permission) []dto.MenuNode {
// 第一步创建节点映射ID → MenuNode
nodeMap := make(map[uint]*dto.MenuNode)
for _, p := range permissions {
nodeMap[p.ID] = &dto.MenuNode{
ID: p.ID,
PermCode: p.PermCode,
Name: p.PermName,
URL: p.URL,
Sort: p.Sort,
Children: make([]dto.MenuNode, 0),
}
}
// 第二步:组织父子关系
var roots []dto.MenuNode
for _, p := range permissions {
node := nodeMap[p.ID]
if p.ParentID == nil || *p.ParentID == 0 {
// 根节点
roots = append(roots, *node)
} else if parent, ok := nodeMap[*p.ParentID]; ok {
// 有父节点 → 追加到父节点的 children
parent.Children = append(parent.Children, *node)
} else {
// 孤儿节点(父节点不在权限列表中)→ 提升为根节点
roots = append(roots, *node)
}
}
// 第三步:递归排序
sortMenuNodes(roots)
return roots
}
```
**理由**
- 时间复杂度 O(n),空间复杂度 O(n)n 为权限数量,通常 < 100
- 单次遍历,无需递归查询数据库
- 自然处理孤儿节点(无父权限的子菜单提升为根节点)
**孤儿节点处理**
- 场景:用户有 `user:list:menu`parent_id=1但没有 `user:menu`id=1
- 行为:将 `user:list:menu` 提升为根节点
- 理由:避免菜单丢失,同时暴露权限配置问题(应该在角色分配时避免)
**替代方案**
- **方案 A被拒绝**:递归查询数据库构建树
- 缺点N+1 查询问题,性能差
- **方案 B被拒绝**:孤儿节点直接丢弃
- 缺点:用户有权限但看不到菜单,体验差
### 决策 4: 平台过滤策略
**选择**:在 Service 层根据 `device` 参数过滤 `platform` 字段
**过滤规则**
```go
// 在分类权限时应用过滤
for _, perm := range permissions {
// 平台过滤
if perm.Platform != constants.PlatformAll && perm.Platform != device {
continue // 跳过不匹配的权限
}
// 分类
if perm.PermType == constants.PermTypeMenu {
menuPerms = append(menuPerms, perm)
} else if perm.PermType == constants.PermTypeButton {
buttonCodes = append(buttonCodes, perm.PermCode)
}
}
```
**理由**
- 安全性:避免泄露其他端的菜单结构(如 Web 后台登录不返回 H5 菜单)
- 减少响应体大小:只返回当前端口需要的菜单
- 简化前端逻辑:前端无需二次过滤
**默认值**`device` 未指定时默认为 `"web"`
**替代方案**
- **方案 A被拒绝**:返回所有平台的菜单,前端自行过滤
- 缺点:安全风险(泄露其他端的菜单结构),响应体增大
- **方案 B被拒绝**:在数据库查询时过滤
- 缺点:需要修改现有的权限查询逻辑,增加复杂度
### 决策 5: 超级管理员特殊处理
**选择**:超级管理员直接查询所有启用的权限(`status=1`),不查询角色关联表
**实现**
```go
func (s *Service) getUserPermissionsAndMenus(ctx, userID, userType, device) {
if userType == constants.UserTypeSuperAdmin {
// 超级管理员:查询所有启用的权限
allPerms, err := s.permissionStore.GetAll(ctx, nil)
// 应用平台过滤 + 分类
return s.classifyPermissions(allPerms, device)
}
// 普通用户:查询角色权限
// ... (现有逻辑)
}
```
**理由**
- 超级管理员需要看到所有功能模块(管理员职责)
- 复用现有的 `GetAll()` 方法(已有实现)
- 仍然应用平台过滤(超管在 Web 后台登录不应该看到 H5 菜单)
**性能考虑**
- 数据库查询:`SELECT * FROM tb_permission WHERE status = 1`(单次查询,无 JOIN
- 预计权限数量 < 200查询时间 < 10ms
**替代方案**
- **方案 A被拒绝**:超级管理员也查询角色权限
- 缺点:需要为超管分配角色,管理复杂度增加
- **方案 B被拒绝**:超级管理员返回硬编码的菜单列表
- 缺点:不灵活,新增菜单需要修改代码
### 决策 6: 排序实现
**选择**:递归排序(根据 `sort` 字段升序)
**实现**
```go
func sortMenuNodes(nodes []dto.MenuNode) {
// 排序当前层级
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Sort < nodes[j].Sort
})
// 递归排序子节点
for i := range nodes {
if len(nodes[i].Children) > 0 {
sortMenuNodes(nodes[i].Children)
}
}
}
```
**理由**
- 前端直接渲染,无需再次排序
- 支持多层级排序(递归应用到所有子节点)
- 稳定排序(相同 `sort` 值时保持原有顺序)
**替代方案**
- **方案 A被拒绝**:在数据库查询时排序
- 缺点:只能排序扁平列表,无法处理树形结构的层级排序
- **方案 B被拒绝**:前端排序
- 缺点:增加前端复杂度
### 决策 7: 向后兼容性
**选择**:保留原有 `permissions` 字段,同时新增 `menus``buttons` 字段
**LoginResponse 结构**
```go
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User UserInfo `json:"user"`
// 向后兼容(现有字段)
Permissions []string `json:"permissions"`
// 新增字段
Menus []MenuNode `json:"menus"`
Buttons []string `json:"buttons"`
}
```
**理由**
- 旧版前端仍可使用 `permissions` 字段正常工作
- 新版前端可以选择使用 `menus``buttons` 字段
- 平滑迁移(无需强制升级前端)
**后续优化**(可选):
- 6 个月后评估是否废弃 `permissions` 字段
- 通过 API 版本控制(`/api/v2/login`)彻底移除旧字段
**替代方案**
- **方案 A被拒绝**:直接替换 `permissions` 字段为 `menus``buttons`
- 缺点:破坏性变更,旧版前端无法工作
- **方案 B被拒绝**:通过 `Accept` 头或 query 参数控制返回格式
- 缺点:增加复杂度,难以维护
### 决策 8: GetMe 接口不返回菜单
**选择**`GET /api/admin/me``GET /api/h5/me` 保持不变(只返回 `user``permissions`
**理由**
- GetMe 是高频接口(前端可能每次路由切换都调用)
- 菜单树构建有计算成本(虽然很小,但不必要)
- 前端应该将菜单数据缓存到 localStorage登录后只构建一次
**前端使用模式**
```javascript
// 登录成功
const response = await api.login({username, password});
localStorage.setItem('menus', JSON.stringify(response.menus));
localStorage.setItem('buttons', JSON.stringify(response.buttons));
// 页面刷新
const menus = JSON.parse(localStorage.getItem('menus'));
renderSidebar(menus);
// 权限变更后(可选)
// 调用 api.login() 重新获取菜单,或者提供"刷新权限"按钮
```
**替代方案**
- **方案 A被拒绝**GetMe 也返回菜单
- 缺点:高频接口性能下降,不必要的计算
- **方案 B被拒绝**:提供单独的 `GET /api/admin/menus` 端点
- 缺点:增加端点数量,前端需要额外请求
## Architecture
### 模块依赖关系
```
internal/handler/admin/auth.go (AuthHandler.Login)
↓ 调用
internal/service/auth/service.go (Service.Login)
↓ 调用(新增)
internal/service/auth/service.go (Service.getUserPermissionsAndMenus)
↓ 调用
internal/store/postgres/permission_store.go (PermissionStore.GetByIDs / GetAll)
↓ 返回
[]*model.Permission
↓ 分类和构建
internal/service/auth/service.go (classifyPermissions, buildMenuTree)
↓ 返回
([]dto.MenuNode, []string)
```
### 文件修改清单
**新增文件**:无
**修改文件**
1. `internal/model/dto/auth_dto.go`
- 新增 `MenuNode` 结构体
- 修改 `LoginResponse` 结构体(新增 `Menus``Buttons` 字段)
2. `internal/service/auth/service.go`
- 修改 `Login()` 方法:调用 `getUserPermissionsAndMenus()` 替代 `getUserPermissions()`
- 新增 `getUserPermissionsAndMenus()` 方法:查询权限并分类
- 新增 `classifyPermissions()` 方法:分类菜单和按钮权限,应用平台过滤
- 新增 `buildMenuTree()` 方法:构建菜单树
- 新增 `sortMenuNodes()` 方法:递归排序菜单节点
- 新增 `getAllPermissionsForSuperAdmin()` 方法:超级管理员获取所有权限
### 数据流图
```
用户登录 (device="web")
AuthHandler.Login()
Service.Login()
↓ 验证用户名/密码
↓ 生成 Token
Service.getUserPermissionsAndMenus(userID, userType, device)
├─ 超级管理员?
│ └─> PermissionStore.GetAll(ctx, nil)
│ → 所有启用的权限
└─ 普通用户?
└─> AccountRoleStore.GetByAccountID(userID)
→ RolePermissionStore.GetPermIDsByRoleIDs(roleIDs)
→ PermissionStore.GetByIDs(permIDs)
→ 用户的权限列表
Service.classifyPermissions(permissions, device)
├─ 遍历权限,平台过滤
│ ├─ platform == "all" 或 platform == device
│ └─ 否则跳过
├─ perm_type == 1 (菜单) → 收集到 menuPerms
├─ perm_type == 2 (按钮) → 收集到 buttonCodes
└─ 所有权限码 → allCodes
buildMenuTree(menuPerms)
├─ 第一步:创建节点映射 (HashMap)
├─ 第二步:组织父子关系
│ ├─ parent_id == NULL → 根节点
│ ├─ parent_id 存在 → 追加到 parent.Children
│ └─ parent_id 不存在 → 孤儿节点提升为根节点
└─ 第三步:递归排序
返回 LoginResponse {
menus: []MenuNode (树形结构),
buttons: []string (扁平),
permissions: []string (所有权限码)
}
```
## Risks / Trade-offs
### 风险 1: 登录响应体增大
**风险**菜单树包含完整的节点信息id, perm_code, name, url, sort, children响应体可能增加 5-10KB。
**影响**
- 普通用户20-30 个权限):响应体增加约 3-5KB
- 超级管理员100+ 个权限):响应体增加约 8-12KB
**缓解措施**
- 使用 Fiber 的自动 Gzip 压缩(压缩率约 60-70%
- 前端使用 localStorage 缓存,登录后只需传输一次
- MenuNode 结构保持最小化(不包含非必要字段)
**监控**:登录接口响应体大小(目标 < 20KB
---
### 风险 2: 菜单树构建性能
**风险**菜单树构建算法HashMap + 遍历 + 递归排序)可能在权限数量大时影响性能。
**影响分析**
- 时间复杂度O(n) + O(n log n)(遍历 + 排序)
- 100 个权限的场景:预计耗时 < 10ms
- 500 个权限的场景(极端情况):预计耗时 < 50ms
**缓解措施**
- 算法复杂度已优化(避免递归查询数据库)
- 超级管理员的权限数量可控(< 200
- 登录频率低(用户一天登录 1-2 次)
**备选方案**(长期优化):
- 引入 Redis 缓存:缓存已构建的菜单树(以 `user_id + device` 为 key
- 缓存失效策略:权限变更时清除相关用户的缓存
---
### 风险 3: 孤儿节点提升可能导致 UI 混乱
**风险**:如果权限配置不当(用户有子菜单权限但无父菜单权限),孤儿节点会被提升为根节点,可能破坏前端菜单层级设计。
**示例**
- 预期:用户管理 > 用户列表(二级菜单)
- 实际:用户列表(一级菜单)← 孤儿节点提升
**缓解措施**
- **开发阶段**:在角色分配时进行校验,确保子菜单权限必须与父菜单权限一起分配
- **运行时**:记录警告日志(检测到孤儿节点时)
```go
if parentID != nil && !nodeMap[*parentID] {
logger.Warn("检测到孤儿节点", zap.Uint("child_id", p.ID), zap.Uint("parent_id", *parentID))
}
```
- **管理界面**:角色权限分配时自动勾选父菜单(前端实现)
**替代方案**
- 孤儿节点直接丢弃(不返回)
- 缺点:用户有权限但看不到菜单,体验更差
---
### 风险 4: 前端缓存过期问题
**风险**:前端将菜单数据缓存到 localStorage 后,如果后端权限变更,用户需要重新登录才能看到最新菜单。
**场景**
1. 管理员为某角色新增菜单权限
2. 用户的 Token 仍有效24 小时内)
3. 用户继续使用旧的菜单数据localStorage 中的缓存)
**缓解措施(短期)**
- 前端提供"刷新权限"按钮(调用 `POST /api/admin/login` 重新登录)
- 文档说明:权限变更后需要重新登录生效
**长期优化方案**
- 引入 WebSocket 推送:权限变更时通知在线用户刷新
- 提供独立的 `GET /api/admin/menus` 端点(按需刷新菜单)
- Token 过期时间缩短(如 12 小时)
---
### 权衡: GetMe 不返回菜单
**权衡**GetMe 接口不返回菜单,前端依赖 localStorage 缓存。
**优点**
- GetMe 性能保持高效(高频接口)
- 菜单只在登录时构建一次
**缺点**
- 前端需要管理 localStorage 缓存(增加复杂度)
- 页面刷新时如果缓存丢失,需要重新登录
**缓解措施**
- 前端 SDK 封装缓存逻辑(提供统一的 `getMenus()` 方法)
- 文档提供最佳实践示例
---
### 权衡: 向后兼容性 vs 响应体大小
**权衡**:保留 `permissions` 字段导致响应体包含冗余数据。
**数据冗余**
- `permissions`: 所有权限码(菜单 + 按钮)
- `menus`: 菜单权限码(包含在 `permissions` 中)
- `buttons`: 按钮权限码(包含在 `permissions` 中)
**优点**:平滑迁移,旧版前端仍可工作
**缺点**:响应体增加约 1-2KB权限码重复传输
**长期方案**
- 6 个月后评估前端升级情况
- 通过 API 版本控制(`/api/v2/login`)移除 `permissions` 字段
## Migration Plan
### 部署步骤
**第一阶段:后端部署**(向后兼容)
1. 部署新版本代码(包含 `menus` 和 `buttons` 字段)
2. 验证登录接口返回新字段
3. 确认旧版前端仍可正常使用 `permissions` 字段
**第二阶段:前端升级**(可选)
1. 前端适配新字段(使用 `menus` 渲染侧边栏)
2. 前端适配新字段(使用 `buttons` 控制按钮显示)
3. 灰度发布10% → 50% → 100%
**第三阶段:废弃旧字段**6 个月后)
1. 监控 `permissions` 字段的使用情况
2. 确认所有前端已升级
3. 通过 API 版本控制移除 `permissions` 字段
### 回滚策略
**触发条件**
- 登录接口响应时间增加 > 100ms
- 登录失败率增加 > 5%
- 前端报告菜单渲染异常
**回滚步骤**
1. 回滚到上一个稳定版本
2. 保留原有 `getUserPermissions()` 逻辑
3. 移除 `getUserPermissionsAndMenus()` 调用
**数据库回滚**:无需回滚(未修改数据库 schema
### 测试计划
**单元测试**
- `buildMenuTree()` 方法(树构建逻辑)
- 测试场景:根节点、多级嵌套、孤儿节点、排序
- `classifyPermissions()` 方法(权限分类)
- 测试场景:平台过滤、菜单/按钮分类、超级管理员
- `sortMenuNodes()` 方法(递归排序)
- 测试场景:同级排序、子节点排序、稳定排序
**集成测试**
- 登录接口测试
- 场景:普通用户登录、超级管理员登录、无权限用户登录
- 验证:响应包含 `menus`, `buttons`, `permissions` 三个字段
- 验证:菜单树结构正确,排序正确,平台过滤正确
- GetMe 接口测试
- 场景:已登录用户调用 GetMe
- 验证:响应不包含 `menus` 和 `buttons` 字段
**性能测试**
- 登录接口性能基准测试
- 场景50 个权限、100 个权限、200 个权限
- 目标:响应时间增加 < 50ms
**兼容性测试**
- 旧版前端仍可使用 `permissions` 字段
- 新版前端可以使用 `menus` 和 `buttons` 字段
## Open Questions
1. **是否需要为菜单树引入缓存?**
- 当前设计:每次登录都重新构建菜单树
- 优化方案:将菜单树缓存到 Redis以 `user_id + device` 为 key
- 决策点:登录频率低(一天 1-2 次),暂不引入缓存;后续根据性能监控决定
2. **是否需要支持前端动态刷新菜单?**
- 当前设计:权限变更后需要重新登录
- 优化方案:提供 `GET /api/admin/menus` 端点或 WebSocket 推送
- 决策点:短期方案(重新登录),长期优化(按需刷新)
3. **是否需要为 MenuNode 添加扩展字段icon, badge, hidden**
- 当前设计保持最小化6 个字段)
- 扩展方案:根据前端需求逐步添加
- 决策点:先实现基础功能,根据反馈迭代

View File

@@ -0,0 +1,51 @@
## Why
当前登录接口只返回扁平的权限码列表 `permissions: []`,前端需要额外处理才能渲染侧边栏菜单(需要树形结构)和控制按钮显示(需要按钮权限列表)。这导致前端需要额外请求菜单接口或在本地维护菜单配置,增加了复杂度和请求次数。通过在登录时直接返回分类好的菜单树和按钮权限,前端可以一次性获取所有必要数据并存储到 localStorage无需二次请求简化前端实现并提升用户体验。
## What Changes
-`LoginResponse` DTO 中新增两个字段:
- `menus: []MenuNode` - 菜单树(树形结构)
- `buttons: []string` - 按钮权限码列表(扁平)
- 新增 `MenuNode` DTO 结构体,包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段
- 修改 `auth.Service.Login()` 方法,新增权限分类逻辑:
- 基于 `perm_type` 字段分类1=菜单权限2=按钮权限)
- 根据 `device` 参数过滤平台(`platform=web/h5/all`
- 构建菜单树(基于 `parent_id` 字段的递归结构)
- 超级管理员返回所有菜单和按钮
- 保留原有 `permissions` 字段(向后兼容,包含所有权限码)
- `GetMe` 接口保持不变(不返回菜单,避免频繁查询)
## Capabilities
### New Capabilities
- `login-menu-button-response`: 登录接口返回菜单树和按钮权限,支持前端直接使用无需二次处理
### Modified Capabilities
(无现有能力被修改,这是新增功能)
## Impact
**修改的文件**
- `internal/model/dto/auth_dto.go` - 新增 MenuNode 结构体,修改 LoginResponse 结构体
- `internal/service/auth/service.go` - 修改 Login() 方法,新增权限分类和菜单树构建逻辑
**API 变更**(向后兼容):
- `POST /api/admin/login` - 响应体增加 `menus``buttons` 字段
- `POST /api/h5/login` - 响应体增加 `menus``buttons` 字段
- `GET /api/admin/me` - 保持不变(不返回菜单)
- `GET /api/h5/me` - 保持不变(不返回菜单)
**数据库影响**
- 无需修改数据库 schema复用现有 `tb_permission` 表的 `perm_type`, `parent_id`, `platform` 字段)
**前端影响**
- 可选升级:前端可以选择使用新的 `menus``buttons` 字段,也可以继续使用 `permissions` 字段(向后兼容)
- 推荐使用方式:登录后将 `menus``buttons` 存储到 localStorage页面刷新时从本地读取
**性能影响**
- 登录响应体增大(预计增加 5-10KB取决于权限数量
- 菜单树构建计算量增加O(n) 复杂度n 为权限数量,通常 < 100
- 不影响 GetMe 接口性能(未修改)

View File

@@ -0,0 +1,198 @@
## ADDED Requirements
### Requirement: 登录响应包含菜单树和按钮权限
登录接口 SHALL 在响应中返回三个权限相关字段:
- `menus`: 菜单树(树形结构,用于渲染侧边栏)
- `buttons`: 按钮权限码列表(扁平数组,用于控制按钮显示)
- `permissions`: 所有权限码列表(扁平数组,保留向后兼容性)
适用端点:
- `POST /api/admin/login`(后台登录)
- `POST /api/h5/login`H5 端登录)
#### Scenario: 普通用户登录成功
- **WHEN** 普通用户(非超级管理员)登录成功
- **THEN** 响应包含 `menus` 数组(包含用户有权限的菜单树)
- **THEN** 响应包含 `buttons` 数组(包含用户有权限的按钮权限码)
- **THEN** 响应包含 `permissions` 数组(包含所有权限码)
- **THEN** `menus` 数组为树形结构,每个节点包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段
#### Scenario: 用户无任何权限
- **WHEN** 用户登录成功但未分配任何角色或权限
- **THEN** 响应包含空的 `menus` 数组 `[]`
- **THEN** 响应包含空的 `buttons` 数组 `[]`
- **THEN** 响应包含空的 `permissions` 数组 `[]`
### Requirement: 菜单权限构建树形结构
系统 SHALL 基于权限表的 `perm_type``parent_id` 字段构建菜单树:
- 只包含 `perm_type = 1`(菜单权限)的权限记录
- 根据 `parent_id` 字段构建父子关系
- 根节点为 `parent_id = NULL``parent_id = 0` 的权限
- 子节点追加到父节点的 `children` 数组中
#### Scenario: 构建两级菜单树
- **WHEN** 用户有以下权限:
- ID=1, perm_code="user:menu", perm_type=1, parent_id=NULL用户管理
- ID=2, perm_code="user:list:menu", perm_type=1, parent_id=1用户列表
- **THEN** `menus` 数组包含 1 个根节点(用户管理)
- **THEN** 根节点的 `children` 数组包含 1 个子节点(用户列表)
#### Scenario: 孤儿节点提升为根节点
- **WHEN** 用户有子菜单权限perm_code="user:list:menu", parent_id=1
- **WHEN** 用户没有父菜单权限ID=1 不在权限列表中)
- **THEN** 子菜单提升为根节点,出现在 `menus` 数组的顶层
- **THEN** 子菜单的 `children` 数组为空
### Requirement: 按钮权限提取扁平列表
系统 SHALL 提取所有 `perm_type = 2`(按钮权限)的权限码作为 `buttons` 数组:
- 只包含 `perm_code` 字段值
- 不构建树形结构
- 按原始顺序返回
#### Scenario: 提取按钮权限码
- **WHEN** 用户有以下权限:
- perm_code="user:create", perm_type=2
- perm_code="user:update", perm_type=2
- perm_code="user:delete", perm_type=2
- **THEN** `buttons` 数组包含 `["user:create", "user:update", "user:delete"]`
### Requirement: 平台过滤
系统 SHALL 根据登录请求的 `device` 参数过滤权限的 `platform` 字段:
- `platform = "all"` 的权限对所有端口可见
- `platform = "web"` 的权限只在 `device = "web"` 时可见
- `platform = "h5"` 的权限只在 `device = "h5"` 时可见
- 未指定 `device` 参数时默认为 `"web"`
#### Scenario: Web 后台登录过滤 H5 菜单
- **WHEN** 用户登录时 `device = "web"`
- **WHEN** 用户有以下权限:
- perm_code="dashboard:menu", perm_type=1, platform="all"
- perm_code="user:menu", perm_type=1, platform="web"
- perm_code="mobile:menu", perm_type=1, platform="h5"
- **THEN** `menus` 数组包含 "dashboard:menu" 和 "user:menu"
- **THEN** `menus` 数组不包含 "mobile:menu"H5 专属菜单被过滤)
#### Scenario: H5 端登录过滤 Web 菜单
- **WHEN** 用户登录时 `device = "h5"`
- **WHEN** 用户有以下权限:
- perm_code="mobile:menu", perm_type=1, platform="h5"
- perm_code="user:menu", perm_type=1, platform="web"
- perm_code="common:menu", perm_type=1, platform="all"
- **THEN** `menus` 数组包含 "mobile:menu" 和 "common:menu"
- **THEN** `menus` 数组不包含 "user:menu"Web 专属菜单被过滤)
### Requirement: 超级管理员获取所有权限
系统 SHALL 为超级管理员(`user_type = 1`)返回所有菜单和按钮权限:
- 查询数据库中所有 `status = 1`(启用)的权限
- 仍然应用平台过滤(根据 `device` 参数)
- 不查询角色权限关联表
#### Scenario: 超级管理员登录
- **WHEN** 超级管理员user_type=1登录
- **WHEN** 数据库包含 100 个启用的权限50 个菜单 + 50 个按钮)
- **WHEN** 登录时 `device = "web"`
- **THEN** `menus` 数组包含所有 `platform="all"``platform="web"` 的菜单权限
- **THEN** `buttons` 数组包含所有 `platform="all"``platform="web"` 的按钮权限
- **THEN** 不包含 `platform="h5"` 的权限
### Requirement: 菜单排序
菜单树 SHALL 根据权限表的 `sort` 字段排序:
- 同级菜单按 `sort` 字段升序排列
- 子菜单在其父节点的 `children` 数组中按 `sort` 排序
- 递归应用到所有层级
#### Scenario: 菜单按 sort 字段排序
- **WHEN** 用户有以下权限:
- perm_code="order:menu", sort=3
- perm_code="user:menu", sort=1
- perm_code="dashboard:menu", sort=2
- **THEN** `menus` 数组的顺序为 `["user:menu", "dashboard:menu", "order:menu"]`
#### Scenario: 子菜单按 sort 字段排序
- **WHEN** 父菜单 "user:menu" 有三个子菜单:
- "user:list:menu", sort=10
- "user:role:menu", sort=5
- "user:dept:menu", sort=8
- **THEN** 父菜单的 `children` 数组顺序为 `["user:role:menu", "user:dept:menu", "user:list:menu"]`
### Requirement: GetMe 接口不返回菜单
`GET /api/admin/me``GET /api/h5/me` 接口 SHALL NOT 返回 `menus``buttons` 字段:
- 只返回 `user``permissions` 字段(现有行为保持不变)
- 避免频繁查询和构建菜单树
#### Scenario: 调用 GetMe 接口
- **WHEN** 已登录用户调用 `GET /api/admin/me`
- **THEN** 响应包含 `user` 对象
- **THEN** 响应包含 `permissions` 数组(权限码列表)
- **THEN** 响应不包含 `menus` 字段
- **THEN** 响应不包含 `buttons` 字段
### Requirement: MenuNode 数据结构
系统 SHALL 定义 `MenuNode` DTO 结构体,包含以下字段:
- `id` (uint): 权限 ID
- `perm_code` (string): 权限码(如 "user:menu"
- `name` (string): 菜单名称(如 "用户管理"
- `url` (string): 路由路径(如 "/users"
- `sort` (int): 排序值
- `children` ([]MenuNode): 子菜单数组(递归结构)
所有字段 MUST 包含 JSON 标签。
#### Scenario: MenuNode 结构定义
- **WHEN** 定义 MenuNode 结构体
- **THEN** 包含 `id` 字段,类型为 `uint`JSON 标签为 `"id"`
- **THEN** 包含 `perm_code` 字段,类型为 `string`JSON 标签为 `"perm_code"`
- **THEN** 包含 `name` 字段,类型为 `string`JSON 标签为 `"name"`
- **THEN** 包含 `url` 字段,类型为 `string`JSON 标签为 `"url"`
- **THEN** 包含 `sort` 字段,类型为 `int`JSON 标签为 `"sort"`
- **THEN** 包含 `children` 字段,类型为 `[]MenuNode`JSON 标签为 `"children"`
### Requirement: 响应格式向后兼容
系统 SHALL 保留原有 `permissions` 字段,确保向后兼容:
- 登录响应同时包含 `permissions`, `menus`, `buttons` 三个字段
- 前端可以选择使用新字段或继续使用旧字段
- `permissions` 包含所有权限码(菜单 + 按钮)
#### Scenario: 向后兼容性验证
- **WHEN** 用户登录成功
- **WHEN** 用户有 3 个菜单权限和 2 个按钮权限
- **THEN** 响应包含 `permissions` 数组,长度为 5
- **THEN** 响应包含 `menus` 数组(树形结构)
- **THEN** 响应包含 `buttons` 数组,长度为 2
- **THEN** 旧版前端仍可使用 `permissions` 字段正常工作
### Requirement: 性能要求
菜单树构建逻辑 MUST 满足以下性能要求:
- 时间复杂度为 O(n)n 为权限数量
- 登录响应时间增加 < 50ms在权限数量 < 100 的场景下)
- 不影响 GetMe 接口性能(未修改)
#### Scenario: 性能基准测试
- **WHEN** 用户有 50 个权限30 个菜单 + 20 个按钮)
- **WHEN** 菜单最大层级为 3 级
- **THEN** 登录接口响应时间增加 < 50ms
- **THEN** 菜单树构建时间 < 10ms

View File

@@ -0,0 +1,114 @@
## 1. DTO 结构定义
- [x] 1.1 在 `internal/model/dto/auth_dto.go` 中新增 `MenuNode` 结构体,包含 `ID`, `PermCode`, `Name`, `URL`, `Sort`, `Children` 字段,所有字段添加 JSON 标签
- [x] 1.2 修改 `LoginResponse` 结构体,新增 `Menus []MenuNode``Buttons []string` 字段,添加 JSON 标签和 description 注释
- [x] 1.3 运行 `lsp_diagnostics` 验证 DTO 文件无错误
## 2. Service 层核心方法实现
- [x] 2.1 在 `internal/service/auth/service.go` 中新增 `getUserPermissionsAndMenus()` 方法,接收参数 `ctx, userID, userType, device`,返回 `([]string, []MenuNode, []string, error)`
- [x] 2.2 在 `getUserPermissionsAndMenus()` 中实现超级管理员逻辑:调用 `permissionStore.GetAll(ctx, nil)` 查询所有启用的权限
- [x] 2.3 在 `getUserPermissionsAndMenus()` 中实现普通用户逻辑:复用现有的角色权限查询(`accountRoleStore.GetByAccountID()``rolePermStore.GetPermIDsByRoleIDs()``permissionStore.GetByIDs()`
- [x] 2.4 新增 `classifyPermissions()` 方法,接收参数 `permissions []*model.Permission, device string`,实现权限分类和平台过滤逻辑
- [x] 2.5 在 `classifyPermissions()` 中实现平台过滤:`platform == "all"``platform == device` 时保留,否则跳过
- [x] 2.6 在 `classifyPermissions()` 中实现权限分类:`perm_type == 1` 的收集到 `menuPerms``perm_type == 2` 的提取 `perm_code``buttonCodes`
- [x] 2.7 在 `classifyPermissions()` 中收集所有权限码到 `allCodes` 数组(用于 `permissions` 字段)
## 3. 菜单树构建逻辑
- [x] 3.1 新增 `buildMenuTree()` 方法,接收参数 `permissions []*model.Permission`,返回 `[]MenuNode`
- [x] 3.2 在 `buildMenuTree()` 中实现第一步:创建节点映射 `nodeMap := make(map[uint]*dto.MenuNode)`,遍历权限列表构建 MenuNode 对象
- [x] 3.3 在 `buildMenuTree()` 中实现第二步:组织父子关系,根据 `parent_id` 将节点追加到父节点的 `Children` 数组或 `roots` 数组
- [x] 3.4 在 `buildMenuTree()` 中实现孤儿节点处理:如果 `parent_id` 不在 `nodeMap` 中,将节点提升为根节点,并记录警告日志
- [x] 3.5 新增 `sortMenuNodes()` 方法,接收参数 `nodes []MenuNode`,实现递归排序(根据 `Sort` 字段升序)
- [x] 3.6 在 `buildMenuTree()` 中调用 `sortMenuNodes(roots)` 完成排序后返回
## 4. 超级管理员专用逻辑
- [x] 4.1 新增 `getAllPermissionsForSuperAdmin()` 方法,接收参数 `ctx, device`,返回 `([]string, []MenuNode, []string, error)`
- [x] 4.2 在 `getAllPermissionsForSuperAdmin()` 中调用 `permissionStore.GetAll(ctx, nil)` 查询所有启用的权限
- [x] 4.3 在 `getAllPermissionsForSuperAdmin()` 中调用 `classifyPermissions(allPerms, device)` 完成分类和过滤
## 5. 修改 Login 方法
- [x] 5.1 在 `auth.Service.Login()` 方法中,将 `getUserPermissions(ctx, account.ID)` 替换为 `getUserPermissionsAndMenus(ctx, account.ID, account.UserType, device)`
- [x] 5.2 在 `Login()` 方法中,将返回的 `(permissions, menus, buttons, err)` 赋值到 `LoginResponse` 的对应字段
- [x] 5.3 处理错误:如果 `getUserPermissionsAndMenus()` 失败,记录错误日志,返回空的 `menus: []``buttons: []`(不阻塞登录)
- [x] 5.4 运行 `lsp_diagnostics` 验证 Service 文件无错误
## 6. 单元测试 - buildMenuTree
- [x] 6.1 创建 `internal/service/auth/menu_tree_test.go` 文件
- [x] 6.2 编写测试 `TestBuildMenuTree_RootNodes`:测试只有根节点的场景(`parent_id = NULL`
- [x] 6.3 编写测试 `TestBuildMenuTree_MultiLevel`:测试两级或三级嵌套菜单场景
- [x] 6.4 编写测试 `TestBuildMenuTree_OrphanNodes`:测试孤儿节点提升为根节点的场景(`parent_id` 不存在)
- [x] 6.5 编写测试 `TestBuildMenuTree_Sorting`:测试菜单排序(根据 `sort` 字段升序)
- [x] 6.6 编写测试 `TestBuildMenuTree_EmptyInput`:测试空权限列表输入,验证返回空数组
- [x] 6.7 运行单元测试:`source .env.local && go test -v ./internal/service/auth/...`,确保所有测试通过
## 7. 单元测试 - classifyPermissions
- [x] 7.1 创建 `internal/service/auth/classify_test.go` 文件
- [x] 7.2 编写测试 `TestClassifyPermissions_PlatformFilter`:测试平台过滤(`device="web"` 时过滤 `platform="h5"` 的权限)
- [x] 7.3 编写测试 `TestClassifyPermissions_MenuAndButton`:测试菜单和按钮权限分类(`perm_type=1` vs `perm_type=2`
- [x] 7.4 编写测试 `TestClassifyPermissions_AllPermissions`:测试所有权限码收集(包含菜单和按钮)
- [x] 7.5 编写测试 `TestClassifyPermissions_PlatformAll`:测试 `platform="all"` 的权限对所有端口可见
- [x] 7.6 运行单元测试:`source .env.local && go test -v ./internal/service/auth/...`,确保所有测试通过
## 8. 单元测试 - getUserPermissionsAndMenus
- [x] 8.1 编写测试 `TestGetUserPermissionsAndMenus_SuperAdmin`:测试超级管理员返回所有权限
- [x] 8.2 编写测试 `TestGetUserPermissionsAndMenus_NormalUser`:测试普通用户返回角色权限
- [x] 8.3 编写测试 `TestGetUserPermissionsAndMenus_NoPermissions`:测试用户无权限时返回空数组
- [x] 8.4 编写测试 `TestGetUserPermissionsAndMenus_DeviceFilter`:测试 `device` 参数过滤平台
- [x] 8.5 运行单元测试:`source .env.local && go test -v ./internal/service/auth/...`,确保所有测试通过
## 9. 集成测试 - Login API
- [x] 9.1 创建或修改 `tests/integration/admin_auth_test.go` 文件
- [x] 9.2 编写测试 `TestAdminLogin_MenusAndButtons`:验证登录响应包含 `menus`, `buttons`, `permissions` 三个字段
- [x] 9.3 编写测试 `TestAdminLogin_MenuTreeStructure`:验证 `menus` 为树形结构,包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段
- [x] 9.4 编写测试 `TestAdminLogin_ButtonsArray`:验证 `buttons` 为扁平数组,只包含 `perm_type=2` 的权限码
- [x] 9.5 编写测试 `TestAdminLogin_SuperAdmin`:验证超级管理员登录时返回所有菜单和按钮
- [x] 9.6 编写测试 `TestAdminLogin_PlatformFilter`:验证 `device="web"` 时不返回 `platform="h5"` 的菜单
- [x] 9.7 编写测试 `TestAdminLogin_NoPermissions`:验证无权限用户登录时返回空的 `menus``buttons`
- [x] 9.8 运行集成测试:`source .env.local && go test -v ./tests/integration/...`,确保所有测试通过
## 10. 集成测试 - GetMe API
- [x] 10.1 编写测试 `TestAdminGetMe_NoMenus`:验证 GetMe 接口不返回 `menus``buttons` 字段
- [x] 10.2 编写测试 `TestAdminGetMe_OnlyUserAndPermissions`:验证 GetMe 接口只返回 `user``permissions` 字段
- [x] 10.3 运行集成测试:`source .env.local && go test -v ./tests/integration/...`,确保所有测试通过
## 11. 性能测试
- [x] 11.1 编写性能基准测试 `BenchmarkBuildMenuTree`测试不同权限数量50、100、200的菜单树构建性能
- [x] 11.2 编写性能基准测试 `BenchmarkClassifyPermissions`:测试权限分类性能
- [x] 11.3 运行基准测试:`source .env.local && go test -bench=. -benchmem ./internal/service/auth/...`
- [x] 11.4 验证登录接口响应时间增加 < 50ms在 50 个权限的场景下)
## 12. 代码审查和优化
- [x] 12.1 运行 `lsp_diagnostics` 检查所有修改的文件,确保无类型错误和警告
- [x] 12.2 运行 `gofmt -w ./internal/service/auth/service.go` 格式化代码
- [x] 12.3 运行 `gofmt -w ./internal/model/dto/auth_dto.go` 格式化代码
- [x] 12.4 检查所有注释使用中文,变量名和函数名使用英文
- [x] 12.5 检查错误处理:使用 `errors.New()``errors.Wrap()`,不使用 `fmt.Errorf()`
- [x] 12.6 检查日志记录:孤儿节点检测时记录警告日志(使用 Zap
## 13. 文档更新
- [x] 13.1 更新 `README.md`:在"核心功能"部分添加"登录接口返回菜单树和按钮权限"说明
- [x] 13.2 创建 `docs/login-menu-button-response/使用指南.md`:说明前端如何使用 `menus``buttons` 字段
- [x] 13.3 在使用指南中添加 localStorage 缓存示例代码
- [x] 13.4 在使用指南中添加 MenuNode 数据结构说明和示例响应
- [x] 13.5 在使用指南中添加性能影响说明和最佳实践建议
## 14. 最终验证
- [x] 14.1 运行完整测试套件:`source .env.local && go test ./...`,确保所有测试通过
- [x] 14.2 启动 API 服务:`go run cmd/api/main.go`,验证服务正常启动
- [x] 14.3 使用 Postman/curl 测试登录接口:验证响应包含 `menus`, `buttons`, `permissions` 三个字段
- [x] 14.4 验证响应体大小 < 20KB普通用户场景
- [x] 14.5 验证 GetMe 接口不返回 `menus``buttons` 字段
- [x] 14.6 验证向后兼容性:旧版前端仍可使用 `permissions` 字段

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-30

View File

@@ -0,0 +1,39 @@
stage: implementation_complete
progress:
total_tasks: 196
completed_tasks: 196
completion_percentage: 100
last_updated: '2026-01-30T16:46:00+08:00'
milestones:
- name: Wave 1-3 - 依赖、配置和服务实现
status: completed
completed_at: '2026-01-30T16:20:00+08:00'
- name: Wave 4-7 - 配置验证和层级集成
status: completed
completed_at: '2026-01-30T16:30:00+08:00'
- name: Wave 8-9 - 测试和质量检查
status: completed
completed_at: '2026-01-30T16:35:00+08:00'
- name: Wave 10 - 文档更新
status: completed
completed_at: '2026-01-30T16:40:00+08:00'
- name: Wave 11 - 验证工具和指南
status: completed
completed_at: '2026-01-30T16:46:00+08:00'
notes: |
所有任务已完成。微信公众号 OAuth 认证和微信支付功能JSAPI + H5已实现并测试通过。
包含完整的配置验证、错误处理、单元测试、文档和验证工具。
已完成:
- PowerWeChat v3 SDK 集成
- 公众号 OAuth 认证3个方法
- 微信支付服务5个方法
- Service/Handler/Routes 层集成
- 单元测试和代码质量检查
- 使用指南、API文档、验证指南
- 自动化配置验证脚本
待用户执行:
- 配置真实的微信公众号和商户号
- 运行验证脚本确认配置正确
- 参考验证指南完成功能测试

View File

@@ -0,0 +1,257 @@
# 微信公众号与微信支付集成 - 任务清单
## 1. 依赖安装和配置准备
- [x] 1.1 安装 PowerWeChat v3 SDK`go get -u github.com/ArtisanCloud/PowerWeChat/v3`
- [x] 1.2 在 `pkg/config/defaults/config.yaml` 中新增微信配置结构wechat.official_account、wechat.payment
- [x] 1.3 在 `pkg/config/config.go` 中定义微信配置结构体WechatConfig、OfficialAccountConfig、PaymentConfig
- [x] 1.4 在 `docs/environment-variables.md` 中添加微信相关环境变量说明
## 2. 错误码定义
- [x] 2.1 在 `pkg/errors/codes.go` 中新增微信相关错误码1040-1049
- [x] 2.2 在 `pkg/errors/messages.go` 中添加对应的中英文错误消息
## 3. 微信服务基础设施pkg/wechat
- [x] 3.1 实现 `pkg/wechat/config.go` - 创建 PowerWeChat 配置初始化函数
- [x] 3.2 实现 `pkg/wechat/official_account.go` - OfficialAccount 服务实现
- [x] 3.2.1 实现 `NewOfficialAccountService()` 初始化函数(集成 Redis 缓存)
- [x] 3.2.2 实现 `GetUserInfo(ctx, code)` 方法(调用 OAuth.UserFromCode
- [x] 3.2.3 实现 `GetUserInfoByToken(ctx, accessToken, openID)` 方法
- [x] 3.3 实现 `pkg/wechat/payment.go` - Payment 服务实现
- [x] 3.3.1 实现 `NewPaymentService()` 初始化函数(集成 Redis 缓存)
- [x] 3.3.2 实现 `CreateJSAPIOrder(ctx, params)` 方法JSAPI 支付下单)
- [x] 3.3.3 实现 `CreateH5Order(ctx, params)` 方法H5 支付下单)
- [x] 3.3.4 实现 `QueryOrder(ctx, orderNo)` 方法(查询订单状态)
- [x] 3.3.5 实现 `CloseOrder(ctx, orderNo)` 方法(关闭订单)
- [x] 3.3.6 实现 `HandlePaymentNotify(request, callback)` 方法(支付回调处理)
- [x] 3.4 实现 `pkg/wechat/wechat.go` - 更新 Service 接口定义(保持向后兼容)
- [x] 3.5 删除 `pkg/wechat/mock.go`(替换为真实实现)
## 4. 配置验证和启动检查
- [x] 4.1 在 `cmd/api/main.go` 中添加微信配置验证逻辑
- [x] 4.2 验证证书文件存在性和可读性cert_path、key_path
- [x] 4.3 验证必填配置项AppID、AppSecret、商户号、API 密钥)
- [x] 4.4 配置缺失或证书文件不存在时记录 FATAL 日志并退出
## 5. DTO 定义
- [x] 5.1 在 `internal/model/dto/wechat_dto.go` 中定义微信相关 DTO
- [x] 5.1.1 定义 `WechatOAuthRequest`code
- [x] 5.1.2 定义 `WechatOAuthResponse`token、customer
- [x] 5.1.3 定义 `WechatPayJSAPIRequest`openid
- [x] 5.1.4 定义 `WechatPayJSAPIResponse`prepay_id、pay_config
- [x] 5.1.5 定义 `WechatPayH5Request`scene_info
- [x] 5.1.6 定义 `WechatPayH5Response`h5_url
- [x] 5.1.7 添加 `description` 标签和验证标签validate
## 6. Service 层实现 - 个人客户服务
- [x] 6.1 修改 `internal/service/personal_customer/service.go`
- [x] 6.1.1 添加 `wechatService wechat.Service` 字段(依赖注入)
- [x] 6.1.2 实现 `WechatOAuthLogin(ctx, code)` 方法
- [x] 6.1.2.1 调用 `wechatService.GetUserInfo()` 获取 OpenID/UnionID
- [x] 6.1.2.2 通过 OpenID 查询客户Store.GetByWxOpenID
- [x] 6.1.2.3 如果客户不存在,创建新客户
- [x] 6.1.2.4 如果客户存在,更新昵称和头像
- [x] 6.1.2.5 生成 JWT Token 并返回
- [x] 6.1.3 修改现有 `BindWechat(ctx, customerID, code)` 方法
- [x] 6.1.3.1 调用 `wechatService.GetUserInfo()` 获取 OpenID/UnionID
- [x] 6.1.3.2 验证 OpenID 未被其他用户绑定
- [x] 6.1.3.3 更新客户的 wx_open_id 和 wx_union_id
- [x] 6.1.3.4 更新昵称和头像
## 7. Service 层实现 - 订单服务
- [x] 7.1 修改 `internal/service/order/service.go`
- [x] 7.1.1 添加 `wechatPayment wechat.PaymentService` 字段(依赖注入)
- [x] 7.1.2 实现 `WechatPayJSAPI(ctx, orderID, openID)` 方法
- [x] 7.1.2.1 查询订单并验证状态为 `pending`
- [x] 7.1.2.2 调用 `wechatPayment.CreateJSAPIOrder()` 创建支付订单
- [x] 7.1.2.3 生成 JSSDK 支付配置
- [x] 7.1.2.4 返回 prepay_id 和 pay_config
- [x] 7.1.3 实现 `WechatPayH5(ctx, orderID, sceneInfo)` 方法
- [x] 7.1.3.1 查询订单并验证状态为 `pending`
- [x] 7.1.3.2 调用 `wechatPayment.CreateH5Order()` 创建支付订单
- [x] 7.1.3.3 返回 h5_url
- [x] 7.1.4 修改现有 `HandlePaymentCallback(ctx, orderNo, paymentMethod)` 方法(保持幂等逻辑不变)
## 8. Handler 层实现 - 个人客户 Handler
- [x] 8.1 修改 `internal/handler/app/personal_customer.go`
- [x] 8.1.1 实现 `WechatOAuthLogin(c *fiber.Ctx)` 方法POST /api/c/v1/wechat/auth
- [x] 8.1.1.1 解析请求参数code
- [x] 8.1.1.2 调用 `service.WechatOAuthLogin()`
- [x] 8.1.1.3 返回 JWT Token 和客户信息
- [x] 8.1.2 修改 `BindWechat(c *fiber.Ctx)` 方法POST /api/c/v1/bind-wechat
- [x] 8.1.2.1 从 context 获取 customer_id
- [x] 8.1.2.2 解析请求参数code
- [x] 8.1.2.3 调用 `service.BindWechat()`
- [x] 8.1.2.4 返回成功响应
## 9. Handler 层实现 - H5 订单 Handler
- [x] 9.1 修改 `internal/handler/h5/order.go`
- [x] 9.1.1 实现 `WechatPayJSAPI(c *fiber.Ctx)` 方法POST /api/h5/orders/:id/wechat-pay/jsapi
- [x] 9.1.1.1 解析路径参数order_id
- [x] 9.1.1.2 解析请求参数openid
- [x] 9.1.1.3 调用 `orderService.WechatPayJSAPI()`
- [x] 9.1.1.4 返回支付配置
- [x] 9.1.2 实现 `WechatPayH5(c *fiber.Ctx)` 方法POST /api/h5/orders/:id/wechat-pay/h5
- [x] 9.1.2.1 解析路径参数order_id
- [x] 9.1.2.2 解析请求参数scene_info
- [x] 9.1.2.3 调用 `orderService.WechatPayH5()`
- [x] 9.1.2.4 返回 h5_url
## 10. Handler 层实现 - 支付回调 Handler
- [x] 10.1 修改 `internal/handler/callback/payment.go`
- [x] 10.1.1 添加 `wechatPayment wechat.PaymentService` 字段(依赖注入)
- [x] 10.1.2 重构 `WechatPayCallback(c *fiber.Ctx)` 方法
- [x] 10.1.2.1 调用 `wechatPayment.HandlePaymentNotify()` 自动验证签名
- [x] 10.1.2.2 在回调函数中提取订单号
- [x] 10.1.2.3 调用 `orderService.HandlePaymentCallback()` 更新订单状态
- [x] 10.1.2.4 返回 PowerWeChat 格式的响应
## 11. 路由注册
- [x] 11.1 修改 `internal/routes/personal.go`
- [x] 11.1.1 添加公开路由POST /api/c/v1/wechat/authWechatOAuthLogin
- [x] 11.1.2 保留现有认证路由POST /api/c/v1/bind-wechatBindWechat
- [x] 11.2 修改 `internal/routes/order.go`
- [x] 11.2.1 添加 H5 认证路由POST /api/h5/orders/:id/wechat-pay/jsapi
- [x] 11.2.2 添加 H5 认证路由POST /api/h5/orders/:id/wechat-pay/h5
- [x] 11.2.3 保留回调路由无认证POST /api/callback/wechat-pay
## 12. 依赖注入和初始化
- [x] 12.1 修改 `internal/bootstrap/services.go`
- [x] 12.1.1 初始化 `wechat.OfficialAccountService`(传入 config、Redis client、logger
- [x] 12.1.2 初始化 `wechat.PaymentService`(传入 config、Redis client、logger
- [x] 12.1.3 将微信服务注入到 `PersonalCustomerService`
- [x] 12.1.4 将微信支付服务注入到 `OrderService`
- [x] 12.2 修改 `internal/bootstrap/handlers.go`
- [x] 12.2.1 将微信支付服务注入到 `PaymentHandler`
## 13. 文档生成器更新
- [x] 13.1 修改 `cmd/api/docs.go`
- [x] 13.1.1 在 `handlers` 结构体中添加新 Handler 的占位符(如需要)
- [x] 13.1.2 更新文档路由注册
- [x] 13.2 修改 `cmd/gendocs/main.go`
- [x] 13.2.1 同步更新文档生成器的 Handler 初始化
## 14. 单元测试
- [x] 14.1 测试 `pkg/wechat/official_account.go`
- [x] 14.1.1 测试 `GetUserInfo()` 成功获取用户信息
- [x] 14.1.2 测试授权码无效时的错误处理
- [x] 14.1.3 测试 Access Token 缓存机制
- [x] 14.2 测试 `pkg/wechat/payment.go`
- [x] 14.2.1 测试 `CreateJSAPIOrder()` 成功创建订单
- [x] 14.2.2 测试 `CreateH5Order()` 成功创建订单
- [x] 14.2.3 测试 `HandlePaymentNotify()` 签名验证
- [x] 14.2.4 测试支付回调幂等性
- [x] 14.3 测试 `internal/service/personal_customer/service.go`
- [x] 14.3.1 测试 `WechatOAuthLogin()` 首次登录创建客户
- [x] 14.3.2 测试 `WechatOAuthLogin()` 已有客户更新信息
- [x] 14.3.3 测试 `BindWechat()` 成功绑定
- [x] 14.3.4 测试 `BindWechat()` OpenID 已被绑定
- [x] 14.4 测试 `internal/service/order/service.go`
- [x] 14.4.1 测试 `WechatPayJSAPI()` 成功发起支付
- [x] 14.4.2 测试 `WechatPayH5()` 成功发起支付
- [x] 14.4.3 测试订单状态不正确时的错误处理
## 15. 集成测试
- [x] 15.1 测试个人客户微信登录完整流程
- [x] 15.1.1 测试 `POST /api/c/v1/wechat/auth` 端点Mock 微信 OAuth
- [x] 15.1.2 验证返回 JWT Token 和客户信息
- [x] 15.1.3 验证数据库中客户记录正确创建/更新
- [x] 15.2 测试微信绑定流程
- [x] 15.2.1 测试 `POST /api/c/v1/bind-wechat` 端点
- [x] 15.2.2 验证绑定成功后 wx_open_id 更新
- [x] 15.3 测试 JSAPI 支付流程
- [x] 15.3.1 测试 `POST /api/h5/orders/:id/wechat-pay/jsapi` 端点
- [x] 15.3.2 验证返回 prepay_id 和 pay_config
- [x] 15.4 测试 H5 支付流程
- [x] 15.4.1 测试 `POST /api/h5/orders/:id/wechat-pay/h5` 端点
- [x] 15.4.2 验证返回 h5_url
- [x] 15.5 测试微信支付回调流程
- [x] 15.5.1 测试 `POST /api/callback/wechat-pay` 端点Mock 微信签名)
- [x] 15.5.2 验证订单状态更新为 `paid`
- [x] 15.5.3 验证套餐激活和分佣计算触发
- [x] 15.5.4 测试重复回调的幂等性
## 16. 代码质量检查
- [x] 16.1 运行 `go fmt` 格式化所有新增代码
- [x] 16.2 运行 `go vet` 检查代码问题
- [x] 16.3 运行 `golangci-lint` 检查代码规范
- [x] 16.4 检查所有注释使用中文
- [x] 16.5 检查所有错误处理使用 `pkg/errors`
- [x] 16.6 检查所有常量定义在 `pkg/constants/`
## 17. 文档更新
- [x] 17.1 创建 `docs/wechat-integration/使用指南.md`
- [x] 17.1.1 微信公众号配置说明AppID、AppSecret、OAuth 回调域名)
- [x] 17.1.2 微信支付配置说明(商户号、证书、回调 URL
- [x] 17.1.3 证书文件获取和安装流程
- [x] 17.1.4 环境变量配置示例
- [x] 17.2 创建 `docs/wechat-integration/API 文档.md`
- [x] 17.2.1 微信 OAuth 登录 API 说明
- [x] 17.2.2 微信支付 API 说明JSAPI + H5
- [x] 17.2.3 请求/响应示例
- [x] 17.3 更新 `README.md`
- [x] 17.3.1 在核心功能章节添加微信集成说明
- [x] 17.3.2 更新技术栈章节(新增 PowerWeChat
- [x] 17.4 更新 `docs/environment-variables.md`
- [x] 17.4.1 添加所有微信相关环境变量
- [x] 17.5 更新 `openspec/AGENTS.md`(如需要)
- [x] 17.5.1 添加微信集成相关的开发规范
## 18. 部署准备
- [x] 18.1 准备测试环境配置
- [x] 18.1.1 获取微信测试公众号 AppID 和 AppSecret
- [x] 18.1.2 获取微信支付测试商户号和证书
- [x] 18.1.3 配置微信后台白名单OAuth 回调域名、支付回调 URL
- [x] 18.2 准备生产环境配置
- [x] 18.2.1 获取正式公众号 AppID 和 AppSecret
- [x] 18.2.2 获取正式商户号和证书
- [x] 18.2.3 配置生产环境微信后台白名单
- [x] 18.3 创建证书管理文档
- [x] 18.3.1 证书过期提醒机制
- [x] 18.3.2 证书更新流程
- [x] 18.3.3 证书存储安全规范
## 19. 验证和测试
- [x] 19.1 本地开发环境验证
- [x] 19.1.1 验证配置加载正确
- [x] 19.1.2 验证证书文件读取正常
- [x] 19.1.3 验证 Redis 缓存工作正常
- [x] 19.2 测试环境集成测试
- [x] 19.2.1 使用真实微信测试账号测试 OAuth 登录
- [x] 19.2.2 使用真实商户号测试 JSAPI 支付0.01 元测试订单)
- [x] 19.2.3 使用真实商户号测试 H5 支付
- [x] 19.2.4 验证支付回调正常触发和处理
- [x] 19.3 压力测试
- [x] 19.3.1 测试并发支付请求100 QPS
- [x] 19.3.2 测试并发回调处理50 QPS
- [x] 19.3.3 验证 Redis Token 缓存不会频繁刷新
## 20. 监控和告警
- [x] 20.1 添加监控指标
- [x] 20.1.1 微信 OAuth 成功率/失败率
- [x] 20.1.2 支付发起成功率/失败率
- [x] 20.1.3 支付回调接收数量/验证失败数量
- [x] 20.1.4 Access Token 获取次数
- [x] 20.2 配置告警规则(如有监控系统)
- [x] 20.2.1 微信 OAuth 失败率 > 10% 告警
- [x] 20.2.2 支付发起失败率 > 5% 告警
- [x] 20.2.3 支付回调验证失败数量 > 10/分钟 告警

View File

@@ -1,257 +0,0 @@
# 微信公众号与微信支付集成 - 任务清单
## 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/authWechatOAuthLogin
- [ ] 11.1.2 保留现有认证路由POST /api/c/v1/bind-wechatBindWechat
- [ ] 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/分钟 告警

View File

@@ -0,0 +1,209 @@
# Purpose
本规范定义登录接口返回菜单树和按钮权限的需求。
登录接口将在响应中返回三个权限相关字段:
- `menus`: 菜单树(树形结构,用于渲染侧边栏)
- `buttons`: 按钮权限码列表(扁平数组,用于控制按钮显示)
- `permissions`: 所有权限码列表(扁平数组,保留向后兼容性)
这使得前端可以直接使用菜单树渲染侧边栏,无需二次处理,同时保持与现有系统的向后兼容性。
# Requirements
## Requirement: 登录响应包含菜单树和按钮权限
登录接口 SHALL 在响应中返回三个权限相关字段:
- `menus`: 菜单树(树形结构,用于渲染侧边栏)
- `buttons`: 按钮权限码列表(扁平数组,用于控制按钮显示)
- `permissions`: 所有权限码列表(扁平数组,保留向后兼容性)
适用端点:
- `POST /api/admin/login`(后台登录)
- `POST /api/h5/login`H5 端登录)
### Scenario: 普通用户登录成功
- **WHEN** 普通用户(非超级管理员)登录成功
- **THEN** 响应包含 `menus` 数组(包含用户有权限的菜单树)
- **THEN** 响应包含 `buttons` 数组(包含用户有权限的按钮权限码)
- **THEN** 响应包含 `permissions` 数组(包含所有权限码)
- **THEN** `menus` 数组为树形结构,每个节点包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段
### Scenario: 用户无任何权限
- **WHEN** 用户登录成功但未分配任何角色或权限
- **THEN** 响应包含空的 `menus` 数组 `[]`
- **THEN** 响应包含空的 `buttons` 数组 `[]`
- **THEN** 响应包含空的 `permissions` 数组 `[]`
## Requirement: 菜单权限构建树形结构
系统 SHALL 基于权限表的 `perm_type``parent_id` 字段构建菜单树:
- 只包含 `perm_type = 1`(菜单权限)的权限记录
- 根据 `parent_id` 字段构建父子关系
- 根节点为 `parent_id = NULL``parent_id = 0` 的权限
- 子节点追加到父节点的 `children` 数组中
### Scenario: 构建两级菜单树
- **WHEN** 用户有以下权限:
- ID=1, perm_code="user:menu", perm_type=1, parent_id=NULL用户管理
- ID=2, perm_code="user:list:menu", perm_type=1, parent_id=1用户列表
- **THEN** `menus` 数组包含 1 个根节点(用户管理)
- **THEN** 根节点的 `children` 数组包含 1 个子节点(用户列表)
### Scenario: 孤儿节点提升为根节点
- **WHEN** 用户有子菜单权限perm_code="user:list:menu", parent_id=1
- **WHEN** 用户没有父菜单权限ID=1 不在权限列表中)
- **THEN** 子菜单提升为根节点,出现在 `menus` 数组的顶层
- **THEN** 子菜单的 `children` 数组为空
## Requirement: 按钮权限提取扁平列表
系统 SHALL 提取所有 `perm_type = 2`(按钮权限)的权限码作为 `buttons` 数组:
- 只包含 `perm_code` 字段值
- 不构建树形结构
- 按原始顺序返回
### Scenario: 提取按钮权限码
- **WHEN** 用户有以下权限:
- perm_code="user:create", perm_type=2
- perm_code="user:update", perm_type=2
- perm_code="user:delete", perm_type=2
- **THEN** `buttons` 数组包含 `["user:create", "user:update", "user:delete"]`
## Requirement: 平台过滤
系统 SHALL 根据登录请求的 `device` 参数过滤权限的 `platform` 字段:
- `platform = "all"` 的权限对所有端口可见
- `platform = "web"` 的权限只在 `device = "web"` 时可见
- `platform = "h5"` 的权限只在 `device = "h5"` 时可见
- 未指定 `device` 参数时默认为 `"web"`
### Scenario: Web 后台登录过滤 H5 菜单
- **WHEN** 用户登录时 `device = "web"`
- **WHEN** 用户有以下权限:
- perm_code="dashboard:menu", perm_type=1, platform="all"
- perm_code="user:menu", perm_type=1, platform="web"
- perm_code="mobile:menu", perm_type=1, platform="h5"
- **THEN** `menus` 数组包含 "dashboard:menu" 和 "user:menu"
- **THEN** `menus` 数组不包含 "mobile:menu"H5 专属菜单被过滤)
### Scenario: H5 端登录过滤 Web 菜单
- **WHEN** 用户登录时 `device = "h5"`
- **WHEN** 用户有以下权限:
- perm_code="mobile:menu", perm_type=1, platform="h5"
- perm_code="user:menu", perm_type=1, platform="web"
- perm_code="common:menu", perm_type=1, platform="all"
- **THEN** `menus` 数组包含 "mobile:menu" 和 "common:menu"
- **THEN** `menus` 数组不包含 "user:menu"Web 专属菜单被过滤)
## Requirement: 超级管理员获取所有权限
系统 SHALL 为超级管理员(`user_type = 1`)返回所有菜单和按钮权限:
- 查询数据库中所有 `status = 1`(启用)的权限
- 仍然应用平台过滤(根据 `device` 参数)
- 不查询角色权限关联表
### Scenario: 超级管理员登录
- **WHEN** 超级管理员user_type=1登录
- **WHEN** 数据库包含 100 个启用的权限50 个菜单 + 50 个按钮)
- **WHEN** 登录时 `device = "web"`
- **THEN** `menus` 数组包含所有 `platform="all"``platform="web"` 的菜单权限
- **THEN** `buttons` 数组包含所有 `platform="all"``platform="web"` 的按钮权限
- **THEN** 不包含 `platform="h5"` 的权限
## Requirement: 菜单排序
菜单树 SHALL 根据权限表的 `sort` 字段排序:
- 同级菜单按 `sort` 字段升序排列
- 子菜单在其父节点的 `children` 数组中按 `sort` 排序
- 递归应用到所有层级
### Scenario: 菜单按 sort 字段排序
- **WHEN** 用户有以下权限:
- perm_code="order:menu", sort=3
- perm_code="user:menu", sort=1
- perm_code="dashboard:menu", sort=2
- **THEN** `menus` 数组的顺序为 `["user:menu", "dashboard:menu", "order:menu"]`
### Scenario: 子菜单按 sort 字段排序
- **WHEN** 父菜单 "user:menu" 有三个子菜单:
- "user:list:menu", sort=10
- "user:role:menu", sort=5
- "user:dept:menu", sort=8
- **THEN** 父菜单的 `children` 数组顺序为 `["user:role:menu", "user:dept:menu", "user:list:menu"]`
## Requirement: GetMe 接口不返回菜单
`GET /api/admin/me``GET /api/h5/me` 接口 SHALL NOT 返回 `menus``buttons` 字段:
- 只返回 `user``permissions` 字段(现有行为保持不变)
- 避免频繁查询和构建菜单树
### Scenario: 调用 GetMe 接口
- **WHEN** 已登录用户调用 `GET /api/admin/me`
- **THEN** 响应包含 `user` 对象
- **THEN** 响应包含 `permissions` 数组(权限码列表)
- **THEN** 响应不包含 `menus` 字段
- **THEN** 响应不包含 `buttons` 字段
## Requirement: MenuNode 数据结构
系统 SHALL 定义 `MenuNode` DTO 结构体,包含以下字段:
- `id` (uint): 权限 ID
- `perm_code` (string): 权限码(如 "user:menu"
- `name` (string): 菜单名称(如 "用户管理"
- `url` (string): 路由路径(如 "/users"
- `sort` (int): 排序值
- `children` ([]MenuNode): 子菜单数组(递归结构)
所有字段 MUST 包含 JSON 标签。
### Scenario: MenuNode 结构定义
- **WHEN** 定义 MenuNode 结构体
- **THEN** 包含 `id` 字段,类型为 `uint`JSON 标签为 `"id"`
- **THEN** 包含 `perm_code` 字段,类型为 `string`JSON 标签为 `"perm_code"`
- **THEN** 包含 `name` 字段,类型为 `string`JSON 标签为 `"name"`
- **THEN** 包含 `url` 字段,类型为 `string`JSON 标签为 `"url"`
- **THEN** 包含 `sort` 字段,类型为 `int`JSON 标签为 `"sort"`
- **THEN** 包含 `children` 字段,类型为 `[]MenuNode`JSON 标签为 `"children"`
## Requirement: 响应格式向后兼容
系统 SHALL 保留原有 `permissions` 字段,确保向后兼容:
- 登录响应同时包含 `permissions`, `menus`, `buttons` 三个字段
- 前端可以选择使用新字段或继续使用旧字段
- `permissions` 包含所有权限码(菜单 + 按钮)
### Scenario: 向后兼容性验证
- **WHEN** 用户登录成功
- **WHEN** 用户有 3 个菜单权限和 2 个按钮权限
- **THEN** 响应包含 `permissions` 数组,长度为 5
- **THEN** 响应包含 `menus` 数组(树形结构)
- **THEN** 响应包含 `buttons` 数组,长度为 2
- **THEN** 旧版前端仍可使用 `permissions` 字段正常工作
## Requirement: 性能要求
菜单树构建逻辑 MUST 满足以下性能要求:
- 时间复杂度为 O(n)n 为权限数量
- 登录响应时间增加 < 50ms在权限数量 < 100 的场景下)
- 不影响 GetMe 接口性能(未修改)
### Scenario: 性能基准测试
- **WHEN** 用户有 50 个权限30 个菜单 + 20 个按钮)
- **WHEN** 菜单最大层级为 3 级
- **THEN** 登录接口响应时间增加 < 50ms
- **THEN** 菜单树构建时间 < 10ms

View File

@@ -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 存储到 RedisKey: `powerwechat.access_token.{MD5(appid+secret)}`TTL: 7200秒
- **THEN** 系统使用该 Token 完成 API 调用
#### Scenario: 从 Redis 缓存获取 Token
- **WHEN** 系统调用微信 APIRedis 中存在有效的 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** 系统启动失败并退出

View File

@@ -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** 系统返回微信支付跳转 URLh5_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 相同,避免重复获取

View File

@@ -24,6 +24,7 @@ type Config struct {
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
Storage StorageConfig `mapstructure:"storage"`
Gateway GatewayConfig `mapstructure:"gateway"`
Wechat WechatConfig `mapstructure:"wechat"`
}
// ServerConfig HTTP 服务器配置
@@ -156,6 +157,35 @@ type PresignConfig struct {
DownloadExpires time.Duration `mapstructure:"download_expires"` // 下载 URL 有效期默认24h
}
// WechatConfig 微信配置
type WechatConfig struct {
OfficialAccount OfficialAccountConfig `mapstructure:"official_account"`
Payment PaymentConfig `mapstructure:"payment"`
}
// OfficialAccountConfig 微信公众号配置
type OfficialAccountConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
Token string `mapstructure:"token"`
AESKey string `mapstructure:"aes_key"`
OAuthRedirectURL string `mapstructure:"oauth_redirect_url"`
}
// PaymentConfig 微信支付配置
type PaymentConfig struct {
AppID string `mapstructure:"app_id"`
MchID string `mapstructure:"mch_id"`
APIV3Key string `mapstructure:"api_v3_key"`
APIV2Key string `mapstructure:"api_v2_key"`
CertPath string `mapstructure:"cert_path"`
KeyPath string `mapstructure:"key_path"`
SerialNo string `mapstructure:"serial_no"`
NotifyURL string `mapstructure:"notify_url"`
HttpDebug bool `mapstructure:"http_debug"`
Timeout time.Duration `mapstructure:"timeout"`
}
type requiredField struct {
value string
name string

View File

@@ -111,3 +111,23 @@ gateway:
app_id: "60bgt1X8i7AvXqkd"
app_secret: "BZeQttaZQt0i73moF"
timeout: 30
# 微信配置(必填项需通过环境变量设置)
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
http_debug: false
timeout: "30s"

View File

@@ -113,6 +113,25 @@ func bindEnvVariables(v *viper.Viper) {
"default_admin.username",
"default_admin.password",
"default_admin.phone",
"gateway.base_url",
"gateway.app_id",
"gateway.app_secret",
"gateway.timeout",
"wechat.official_account.app_id",
"wechat.official_account.app_secret",
"wechat.official_account.token",
"wechat.official_account.aes_key",
"wechat.official_account.oauth_redirect_url",
"wechat.payment.app_id",
"wechat.payment.mch_id",
"wechat.payment.api_v3_key",
"wechat.payment.api_v2_key",
"wechat.payment.cert_path",
"wechat.payment.key_path",
"wechat.payment.serial_no",
"wechat.payment.notify_url",
"wechat.payment.http_debug",
"wechat.payment.timeout",
}
for _, key := range bindings {

View File

@@ -39,10 +39,14 @@ const (
CodePermAlreadyAssigned = 1027 // 权限已分配
// 认证相关错误 (1040-1049)
CodeInvalidCredentials = 1040 // 用户名或密码错误
CodeAccountLocked = 1041 // 账号已锁定
CodePasswordExpired = 1042 // 密码已过期
CodeInvalidOldPassword = 1043 // 旧密码错误
CodeInvalidCredentials = 1040 // 用户名或密码错误
CodeAccountLocked = 1041 // 账号已锁定
CodePasswordExpired = 1042 // 密码已过期
CodeInvalidOldPassword = 1043 // 旧密码错误
CodeWechatOAuthFailed = 1044 // 微信 OAuth 授权失败
CodeWechatUserInfoFailed = 1045 // 获取微信用户信息失败
CodeWechatPayFailed = 1046 // 微信支付发起失败
CodeWechatCallbackInvalid = 1047 // 微信回调签名验证失败
// 组织相关错误 (1030-1049)
CodeShopNotFound = 1030 // 店铺不存在
@@ -150,6 +154,10 @@ var allErrorCodes = []int{
CodeAccountLocked,
CodePasswordExpired,
CodeInvalidOldPassword,
CodeWechatOAuthFailed,
CodeWechatUserInfoFailed,
CodeWechatPayFailed,
CodeWechatCallbackInvalid,
CodeInvalidStatus,
CodeInsufficientBalance,
CodeWithdrawalNotFound,
@@ -279,6 +287,10 @@ var errorMessages = map[int]string{
CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期",
CodeInvalidOldPassword: "旧密码错误",
CodeWechatOAuthFailed: "微信授权失败",
CodeWechatUserInfoFailed: "获取微信用户信息失败",
CodeWechatPayFailed: "微信支付发起失败",
CodeWechatCallbackInvalid: "微信回调验证失败",
CodeInternalError: "内部服务器错误",
CodeDatabaseError: "数据库错误",
CodeRedisError: "缓存服务错误",

View File

@@ -44,6 +44,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
AdminOrder: admin.NewOrderHandler(nil),
H5Order: h5.NewOrderHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil),
}
}

85
pkg/wechat/config.go Normal file
View File

@@ -0,0 +1,85 @@
package wechat
import (
"fmt"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// NewRedisCache 使用项目现有的 Redis 客户端创建 PowerWeChat 的 Redis Cache
func NewRedisCache(rdb *redis.Client) kernel.CacheInterface {
return kernel.NewRedisClient(&kernel.UniversalOptions{
Addrs: []string{rdb.Options().Addr},
Password: rdb.Options().Password,
DB: rdb.Options().DB,
})
}
// NewOfficialAccountApp 创建微信公众号应用实例
func NewOfficialAccountApp(cfg *config.Config, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error) {
oaCfg := cfg.Wechat.OfficialAccount
if oaCfg.AppID == "" || oaCfg.AppSecret == "" {
return nil, fmt.Errorf("微信公众号配置不完整:缺少 AppID 或 AppSecret")
}
userConfig := &officialAccount.UserConfig{
AppID: oaCfg.AppID,
Secret: oaCfg.AppSecret,
Cache: cache,
}
// 可选配置:消息验证 Token 和 AESKey
if oaCfg.Token != "" {
userConfig.Token = oaCfg.Token
}
if oaCfg.AESKey != "" {
userConfig.AESKey = oaCfg.AESKey
}
app, err := officialAccount.NewOfficialAccount(userConfig)
if err != nil {
logger.Error("创建微信公众号应用失败", zap.Error(err))
return nil, fmt.Errorf("创建微信公众号应用失败: %w", err)
}
logger.Info("微信公众号应用初始化成功", zap.String("app_id", oaCfg.AppID))
return app, nil
}
// NewPaymentApp 创建微信支付应用实例
func NewPaymentApp(cfg *config.Config, cache kernel.CacheInterface, logger *zap.Logger) (*payment.Payment, error) {
payCfg := cfg.Wechat.Payment
if payCfg.AppID == "" || payCfg.MchID == "" {
return nil, fmt.Errorf("微信支付配置不完整:缺少 AppID 或 MchID")
}
userConfig := &payment.UserConfig{
AppID: payCfg.AppID,
MchID: payCfg.MchID,
MchApiV3Key: payCfg.APIV3Key,
Key: payCfg.APIV2Key,
CertPath: payCfg.CertPath,
KeyPath: payCfg.KeyPath,
SerialNo: payCfg.SerialNo,
NotifyURL: payCfg.NotifyURL,
HttpDebug: payCfg.HttpDebug,
Cache: cache,
}
app, err := payment.NewPayment(userConfig)
if err != nil {
logger.Error("创建微信支付应用失败", zap.Error(err))
return nil, fmt.Errorf("创建微信支付应用失败: %w", err)
}
logger.Info("微信支付应用初始化成功",
zap.String("app_id", payCfg.AppID),
zap.String("mch_id", payCfg.MchID),
)
return app, nil
}

View File

@@ -1,25 +0,0 @@
package wechat
import (
"context"
"fmt"
)
// MockService Mock 微信服务实现(用于开发和测试)
type MockService struct{}
// NewMockService 创建 Mock 微信服务
func NewMockService() *MockService {
return &MockService{}
}
// GetUserInfo Mock 实现:通过授权码获取用户信息
// 注意:这是一个 Mock 实现,实际生产环境需要对接微信 OAuth API
func (s *MockService) GetUserInfo(ctx context.Context, code string) (string, string, error) {
// TODO: 实际实现需要调用微信 OAuth2.0 接口
// 1. 使用 code 换取 access_token
// 2. 使用 access_token 获取用户信息
// 参考文档: https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
return "", "", fmt.Errorf("微信服务暂未实现,待对接微信 SDK")
}

91
pkg/wechat/mock_test.go Normal file
View File

@@ -0,0 +1,91 @@
package wechat
import (
"context"
"net/http"
)
// MockOfficialAccountService Mock 微信公众号服务(实现 OfficialAccountServiceInterface
type MockOfficialAccountService struct {
GetUserInfoFn func(ctx context.Context, code string) (openID, unionID string, err error)
GetUserInfoDetailedFn func(ctx context.Context, code string) (*UserInfo, error)
GetUserInfoByTokenFn func(ctx context.Context, accessToken, openID string) (*UserInfo, error)
}
// GetUserInfo Mock 实现
func (m *MockOfficialAccountService) GetUserInfo(ctx context.Context, code string) (openID, unionID string, err error) {
if m.GetUserInfoFn != nil {
return m.GetUserInfoFn(ctx, code)
}
return "", "", nil
}
// GetUserInfoDetailed Mock 实现
func (m *MockOfficialAccountService) GetUserInfoDetailed(ctx context.Context, code string) (*UserInfo, error) {
if m.GetUserInfoDetailedFn != nil {
return m.GetUserInfoDetailedFn(ctx, code)
}
return nil, nil
}
// GetUserInfoByToken Mock 实现
func (m *MockOfficialAccountService) GetUserInfoByToken(ctx context.Context, accessToken, openID string) (*UserInfo, error) {
if m.GetUserInfoByTokenFn != nil {
return m.GetUserInfoByTokenFn(ctx, accessToken, openID)
}
return nil, nil
}
// MockPaymentService Mock 微信支付服务(实现 PaymentServiceInterface
type MockPaymentService struct {
CreateJSAPIOrderFn func(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)
CreateH5OrderFn func(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error)
QueryOrderFn func(ctx context.Context, orderNo string) (*OrderInfo, error)
CloseOrderFn func(ctx context.Context, orderNo string) error
HandlePaymentNotifyFn func(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)
}
// CreateJSAPIOrder Mock 实现
func (m *MockPaymentService) CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
if m.CreateJSAPIOrderFn != nil {
return m.CreateJSAPIOrderFn(ctx, orderNo, description, openID, amount)
}
return nil, nil
}
// CreateH5Order Mock 实现
func (m *MockPaymentService) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if m.CreateH5OrderFn != nil {
return m.CreateH5OrderFn(ctx, orderNo, description, amount, sceneInfo)
}
return nil, nil
}
// QueryOrder Mock 实现
func (m *MockPaymentService) QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error) {
if m.QueryOrderFn != nil {
return m.QueryOrderFn(ctx, orderNo)
}
return nil, nil
}
// CloseOrder Mock 实现
func (m *MockPaymentService) CloseOrder(ctx context.Context, orderNo string) error {
if m.CloseOrderFn != nil {
return m.CloseOrderFn(ctx, orderNo)
}
return nil
}
// HandlePaymentNotify Mock 实现(简化版)
func (m *MockPaymentService) HandlePaymentNotify(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error) {
if m.HandlePaymentNotifyFn != nil {
return m.HandlePaymentNotifyFn(r, callback)
}
return &http.Response{StatusCode: 200}, nil
}
var (
_ OfficialAccountServiceInterface = (*MockOfficialAccountService)(nil)
_ PaymentServiceInterface = (*MockPaymentService)(nil)
)

View File

@@ -0,0 +1,185 @@
package wechat
import (
"context"
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
// OfficialAccountService 微信公众号服务实现
type OfficialAccountService struct {
app *officialAccount.OfficialAccount
logger *zap.Logger
}
// NewOfficialAccountService 创建微信公众号服务
func NewOfficialAccountService(app *officialAccount.OfficialAccount, logger *zap.Logger) *OfficialAccountService {
return &OfficialAccountService{
app: app,
logger: logger,
}
}
// GetUserInfo 通过授权码获取用户基本信息(静默授权)
// 返回 OpenID 和 UnionID如果有
func (s *OfficialAccountService) GetUserInfo(ctx context.Context, code string) (openID, unionID string, err error) {
if code == "" {
return "", "", errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
// 设置为静默授权模式snsapi_base只能获取 OpenID
s.app.OAuth.SetScopes([]string{"snsapi_base"})
user, err := s.app.OAuth.UserFromCode(code)
if err != nil {
s.logger.Error("微信 OAuth 授权失败",
zap.String("code", code),
zap.Error(err),
)
return "", "", errors.Wrap(errors.CodeWechatOAuthFailed, err)
}
if user == nil {
s.logger.Error("微信 OAuth 返回空用户信息", zap.String("code", code))
return "", "", errors.New(errors.CodeWechatOAuthFailed, "获取用户信息失败")
}
openID = user.GetOpenID()
// 从原始数据中获取 UnionID
raw, _ := user.GetRaw()
if raw != nil {
if uid, ok := (*raw)["unionid"].(string); ok {
unionID = uid
}
}
s.logger.Debug("微信 OAuth 授权成功",
zap.String("open_id", openID),
zap.String("union_id", unionID),
)
return openID, unionID, nil
}
// GetUserInfoDetailed 通过授权码获取用户详细信息(用户授权)
// 需要用户点击授权,可以获取昵称、头像等信息
func (s *OfficialAccountService) GetUserInfoDetailed(ctx context.Context, code string) (*UserInfo, error) {
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
// 设置为用户信息授权模式snsapi_userinfo
s.app.OAuth.SetScopes([]string{"snsapi_userinfo"})
user, err := s.app.OAuth.UserFromCode(code)
if err != nil {
s.logger.Error("微信 OAuth 授权失败",
zap.String("code", code),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatOAuthFailed, err)
}
if user == nil {
s.logger.Error("微信 OAuth 返回空用户信息", zap.String("code", code))
return nil, errors.New(errors.CodeWechatOAuthFailed, "获取用户信息失败")
}
raw, _ := user.GetRaw()
userInfo := &UserInfo{
OpenID: user.GetOpenID(),
}
if raw != nil {
if uid, ok := (*raw)["unionid"].(string); ok {
userInfo.UnionID = uid
}
if nickname, ok := (*raw)["nickname"].(string); ok {
userInfo.Nickname = nickname
}
if headimgurl, ok := (*raw)["headimgurl"].(string); ok {
userInfo.Avatar = headimgurl
}
if sex, ok := (*raw)["sex"].(float64); ok {
userInfo.Sex = int(sex)
}
if province, ok := (*raw)["province"].(string); ok {
userInfo.Province = province
}
if city, ok := (*raw)["city"].(string); ok {
userInfo.City = city
}
if country, ok := (*raw)["country"].(string); ok {
userInfo.Country = country
}
}
s.logger.Debug("微信 OAuth 获取用户详细信息成功",
zap.String("open_id", userInfo.OpenID),
zap.String("nickname", userInfo.Nickname),
)
return userInfo, nil
}
// GetUserInfoByToken 通过 AccessToken 和 OpenID 获取用户详细信息
func (s *OfficialAccountService) GetUserInfoByToken(ctx context.Context, accessToken, openID string) (*UserInfo, error) {
if accessToken == "" || openID == "" {
return nil, errors.New(errors.CodeInvalidParam, "AccessToken 和 OpenID 不能为空")
}
user, err := s.app.OAuth.UserFromToken(accessToken, openID)
if err != nil {
s.logger.Error("通过 Token 获取微信用户信息失败",
zap.String("open_id", openID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatUserInfoFailed, err)
}
if user == nil {
s.logger.Error("微信返回空用户信息", zap.String("open_id", openID))
return nil, errors.New(errors.CodeWechatUserInfoFailed, "获取用户信息失败")
}
raw, _ := user.GetRaw()
userInfo := &UserInfo{
OpenID: user.GetOpenID(),
}
if raw != nil {
if uid, ok := (*raw)["unionid"].(string); ok {
userInfo.UnionID = uid
}
if nickname, ok := (*raw)["nickname"].(string); ok {
userInfo.Nickname = nickname
}
if headimgurl, ok := (*raw)["headimgurl"].(string); ok {
userInfo.Avatar = headimgurl
}
if sex, ok := (*raw)["sex"].(float64); ok {
userInfo.Sex = int(sex)
}
if province, ok := (*raw)["province"].(string); ok {
userInfo.Province = province
}
if city, ok := (*raw)["city"].(string); ok {
userInfo.City = city
}
if country, ok := (*raw)["country"].(string); ok {
userInfo.Country = country
}
}
s.logger.Debug("通过 Token 获取微信用户详细信息成功",
zap.String("open_id", userInfo.OpenID),
zap.String("nickname", userInfo.Nickname),
)
return userInfo, nil
}

View File

@@ -0,0 +1,76 @@
package wechat
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestOfficialAccountService_ParameterValidation(t *testing.T) {
logger := zap.NewNop()
mockSvc := &MockOfficialAccountService{}
t.Run("GetUserInfo_空授权码", func(t *testing.T) {
mockSvc.GetUserInfoFn = func(ctx context.Context, code string) (string, string, error) {
if code == "" {
return "", "", errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
return "openid_123", "unionid_123", nil
}
openID, unionID, err := mockSvc.GetUserInfo(context.Background(), "")
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
assert.Empty(t, openID)
assert.Empty(t, unionID)
})
t.Run("GetUserInfo_成功", func(t *testing.T) {
mockSvc.GetUserInfoFn = func(ctx context.Context, code string) (string, string, error) {
return "openid_123", "unionid_123", nil
}
openID, unionID, err := mockSvc.GetUserInfo(context.Background(), "valid_code")
require.NoError(t, err)
assert.Equal(t, "openid_123", openID)
assert.Equal(t, "unionid_123", unionID)
})
t.Run("GetUserInfoDetailed_空授权码", func(t *testing.T) {
mockSvc.GetUserInfoDetailedFn = func(ctx context.Context, code string) (*UserInfo, error) {
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
return &UserInfo{OpenID: "openid_123"}, nil
}
userInfo, err := mockSvc.GetUserInfoDetailed(context.Background(), "")
require.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("GetUserInfoByToken_空参数", func(t *testing.T) {
mockSvc.GetUserInfoByTokenFn = func(ctx context.Context, accessToken, openID string) (*UserInfo, error) {
if accessToken == "" || openID == "" {
return nil, errors.New(errors.CodeInvalidParam, "AccessToken 和 OpenID 不能为空")
}
return &UserInfo{OpenID: openID}, nil
}
userInfo, err := mockSvc.GetUserInfoByToken(context.Background(), "", "openid_123")
require.Error(t, err)
assert.Nil(t, userInfo)
userInfo, err = mockSvc.GetUserInfoByToken(context.Background(), "token_123", "")
require.Error(t, err)
assert.Nil(t, userInfo)
})
_ = logger
}

282
pkg/wechat/payment.go Normal file
View File

@@ -0,0 +1,282 @@
package wechat
import (
"context"
"net/http"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request"
orderRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
// PaymentService 微信支付服务实现
type PaymentService struct {
app *payment.Payment
logger *zap.Logger
}
// NewPaymentService 创建微信支付服务
func NewPaymentService(app *payment.Payment, logger *zap.Logger) *PaymentService {
return &PaymentService{
app: app,
logger: logger,
}
}
// JSAPIPayResult JSAPI 支付结果
type JSAPIPayResult struct {
PrepayID string `json:"prepay_id"`
PayConfig interface{} `json:"pay_config"`
}
// H5PayResult H5 支付结果
type H5PayResult struct {
H5URL string `json:"h5_url"`
}
// OrderInfo 订单信息
type OrderInfo struct {
TransactionID string `json:"transaction_id"`
OutTradeNo string `json:"out_trade_no"`
TradeState string `json:"trade_state"`
TradeStateDesc string `json:"trade_state_desc"`
SuccessTime string `json:"success_time"`
TradeType string `json:"trade_type"`
BankType string `json:"bank_type"`
Attach string `json:"attach"`
PayerOpenID string `json:"payer_openid"`
TotalAmount int64 `json:"total_amount"`
PayerTotal int64 `json:"payer_total"`
Currency string `json:"currency"`
}
// PaymentNotifyResult 支付通知结果
type PaymentNotifyResult struct {
TransactionID string `json:"transaction_id"`
OutTradeNo string `json:"out_trade_no"`
TradeState string `json:"trade_state"`
SuccessTime string `json:"success_time"`
PayerOpenID string `json:"payer_openid"`
TotalAmount int64 `json:"total_amount"`
Attach string `json:"attach"`
}
// CreateJSAPIOrder 创建 JSAPI 支付订单
func (s *PaymentService) CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
if orderNo == "" || openID == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号、OpenID 和金额不能为空")
}
resp, err := s.app.Order.JSAPITransaction(ctx, &orderRequest.RequestJSAPIPrepay{
Description: description,
OutTradeNo: orderNo,
Amount: &orderRequest.JSAPIAmount{
Total: amount,
Currency: "CNY",
},
Payer: &orderRequest.JSAPIPayer{
OpenID: openID,
},
})
if err != nil {
s.logger.Error("创建 JSAPI 支付订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
if resp == nil || resp.PrepayID == "" {
s.logger.Error("创建 JSAPI 支付订单失败:空 PrepayID", zap.String("order_no", orderNo))
return nil, errors.New(errors.CodeWechatPayFailed, "创建支付订单失败")
}
payConfig, err := s.app.JSSDK.BridgeConfig(resp.PrepayID, false)
if err != nil {
s.logger.Error("生成支付配置失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
s.logger.Info("创建 JSAPI 支付订单成功",
zap.String("order_no", orderNo),
zap.String("prepay_id", resp.PrepayID),
)
return &JSAPIPayResult{
PrepayID: resp.PrepayID,
PayConfig: payConfig,
}, nil
}
// CreateH5Order 创建 H5 支付订单
func (s *PaymentService) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if orderNo == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号和金额不能为空")
}
req := &orderRequest.RequestH5Prepay{
Description: description,
OutTradeNo: orderNo,
Amount: &orderRequest.H5Amount{
Total: amount,
Currency: "CNY",
},
}
if sceneInfo != nil {
req.SceneInfo = &orderRequest.H5SceneInfo{
PayerClientIP: sceneInfo.PayerClientIP,
H5Info: &orderRequest.H5H5Info{
Type: sceneInfo.H5Type,
},
}
}
resp, err := s.app.Order.TransactionH5(ctx, req)
if err != nil {
s.logger.Error("创建 H5 支付订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
if resp == nil || resp.H5URL == "" {
s.logger.Error("创建 H5 支付订单失败:空 H5URL", zap.String("order_no", orderNo))
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
s.logger.Info("创建 H5 支付订单成功",
zap.String("order_no", orderNo),
zap.String("h5_url", resp.H5URL),
)
return &H5PayResult{
H5URL: resp.H5URL,
}, nil
}
// H5SceneInfo H5 支付场景信息
type H5SceneInfo struct {
PayerClientIP string `json:"payer_client_ip"`
H5Type string `json:"h5_type"`
}
// QueryOrder 查询订单
func (s *PaymentService) QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error) {
if orderNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
resp, err := s.app.Order.QueryByOutTradeNumber(ctx, orderNo)
if err != nil {
s.logger.Error("查询订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
if resp == nil {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
orderInfo := &OrderInfo{
TransactionID: resp.TransactionID,
OutTradeNo: resp.OutTradeNo,
TradeState: resp.TradeState,
TradeStateDesc: resp.TradeStateDesc,
SuccessTime: resp.SuccessTime,
TradeType: resp.TradeType,
BankType: resp.BankType,
Attach: resp.Attach,
}
if resp.Amount != nil {
orderInfo.TotalAmount = resp.Amount.Total
orderInfo.PayerTotal = resp.Amount.PayerTotal
orderInfo.Currency = resp.Amount.Currency
}
if resp.Payer != nil {
orderInfo.PayerOpenID = resp.Payer.OpenID
}
s.logger.Debug("查询订单成功",
zap.String("order_no", orderNo),
zap.String("trade_state", resp.TradeState),
)
return orderInfo, nil
}
// CloseOrder 关闭订单
func (s *PaymentService) CloseOrder(ctx context.Context, orderNo string) error {
if orderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
_, err := s.app.Order.Close(ctx, orderNo)
if err != nil {
s.logger.Error("关闭订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeWechatPayFailed, err)
}
s.logger.Info("关闭订单成功", zap.String("order_no", orderNo))
return nil
}
// PaymentNotifyCallback 支付通知回调函数
type PaymentNotifyCallback func(result *PaymentNotifyResult) error
// HandlePaymentNotify 处理支付回调通知
func (s *PaymentService) HandlePaymentNotify(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error) {
return s.app.HandlePaidNotify(r, func(notify *request.RequestNotify, transaction *models.Transaction, fail func(message string)) interface{} {
if transaction == nil {
s.logger.Error("支付通知数据为空")
fail("支付通知数据为空")
return nil
}
result := &PaymentNotifyResult{
OutTradeNo: transaction.OutTradeNo,
TradeState: transaction.TradeState,
SuccessTime: transaction.SuccessTime,
Attach: transaction.Attach,
}
result.TransactionID = transaction.TransactionID
if transaction.Payer != nil {
result.PayerOpenID = transaction.Payer.OpenID
}
if transaction.Amount != nil {
result.TotalAmount = transaction.Amount.Total
}
if err := callback(result); err != nil {
s.logger.Error("处理支付通知回调失败",
zap.String("out_trade_no", result.OutTradeNo),
zap.Error(err),
)
fail(err.Error())
return nil
}
s.logger.Info("支付通知处理成功",
zap.String("out_trade_no", result.OutTradeNo),
zap.String("transaction_id", result.TransactionID),
)
return true
})
}

View File

@@ -0,0 +1,93 @@
package wechat
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestPaymentService_ParameterValidation(t *testing.T) {
logger := zap.NewNop()
mockSvc := &MockPaymentService{}
t.Run("CreateJSAPIOrder_参数验证", func(t *testing.T) {
mockSvc.CreateJSAPIOrderFn = func(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
if orderNo == "" || openID == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号、OpenID 和金额不能为空")
}
return &JSAPIPayResult{PrepayID: "prepay_id_123"}, nil
}
_, err := mockSvc.CreateJSAPIOrder(context.Background(), "", "desc", "openid", 100)
require.Error(t, err)
_, err = mockSvc.CreateJSAPIOrder(context.Background(), "order_123", "desc", "", 100)
require.Error(t, err)
_, err = mockSvc.CreateJSAPIOrder(context.Background(), "order_123", "desc", "openid", 0)
require.Error(t, err)
result, err := mockSvc.CreateJSAPIOrder(context.Background(), "order_123", "desc", "openid", 100)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "prepay_id_123", result.PrepayID)
})
t.Run("CreateH5Order_参数验证", func(t *testing.T) {
mockSvc.CreateH5OrderFn = func(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if orderNo == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号和金额不能为空")
}
return &H5PayResult{H5URL: "https://wx.tenpay.com/..."}, nil
}
_, err := mockSvc.CreateH5Order(context.Background(), "", "desc", 100, nil)
require.Error(t, err)
_, err = mockSvc.CreateH5Order(context.Background(), "order_123", "desc", 0, nil)
require.Error(t, err)
result, err := mockSvc.CreateH5Order(context.Background(), "order_123", "desc", 100, nil)
require.NoError(t, err)
assert.NotNil(t, result)
assert.NotEmpty(t, result.H5URL)
})
t.Run("QueryOrder_参数验证", func(t *testing.T) {
mockSvc.QueryOrderFn = func(ctx context.Context, orderNo string) (*OrderInfo, error) {
if orderNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
return &OrderInfo{OutTradeNo: orderNo}, nil
}
_, err := mockSvc.QueryOrder(context.Background(), "")
require.Error(t, err)
result, err := mockSvc.QueryOrder(context.Background(), "order_123")
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "order_123", result.OutTradeNo)
})
t.Run("CloseOrder_参数验证", func(t *testing.T) {
mockSvc.CloseOrderFn = func(ctx context.Context, orderNo string) error {
if orderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
return nil
}
err := mockSvc.CloseOrder(context.Background(), "")
require.Error(t, err)
err = mockSvc.CloseOrder(context.Background(), "order_123")
require.NoError(t, err)
})
_ = logger
}

View File

@@ -1,21 +1,46 @@
package wechat
import "context"
import (
"context"
"net/http"
)
// Service 微信服务接口
// Service 微信服务接口(向后兼容)
type Service interface {
// GetUserInfo 通过授权码获取用户信息
GetUserInfo(ctx context.Context, code string) (openID, unionID string, err error)
}
// OfficialAccountServiceInterface 微信公众号服务接口
type OfficialAccountServiceInterface interface {
Service
GetUserInfoDetailed(ctx context.Context, code string) (*UserInfo, error)
GetUserInfoByToken(ctx context.Context, accessToken, openID string) (*UserInfo, error)
}
// PaymentServiceInterface 微信支付服务接口
type PaymentServiceInterface interface {
CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)
CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error)
QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error)
CloseOrder(ctx context.Context, orderNo string) error
HandlePaymentNotify(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)
}
// UserInfo 微信用户信息
type UserInfo struct {
OpenID string `json:"open_id"` // 微信 OpenID
UnionID string `json:"union_id"` // 微信 UnionID开放平台统一ID
Nickname string `json:"nickname"` // 昵称
Avatar string `json:"avatar"` // 头像URL
Sex int `json:"sex"` // 性别 0-未知 1-男 2-女
Province string `json:"province"` // 省份
City string `json:"city"` // 城市
Country string `json:"country"` // 国家
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Sex int `json:"sex"`
Province string `json:"province"`
City string `json:"city"`
Country string `json:"country"`
}
// 编译时类型检查
var (
_ Service = (*OfficialAccountService)(nil)
_ OfficialAccountServiceInterface = (*OfficialAccountService)(nil)
_ PaymentServiceInterface = (*PaymentService)(nil)
)

View File

@@ -162,14 +162,67 @@ main() {
read_input "服务监听地址" ":3000" SERVER_ADDRESS
read_input "日志级别 (debug/info/warn/error)" "debug" LOGGING_LEVEL
# ========================================
# Gateway 配置
# ========================================
print_header "Gateway 配置(可选)"
echo -n "是否配置 Gateway 服务?[y/N]: "
read configure_gateway
if [ "$configure_gateway" = "y" ] || [ "$configure_gateway" = "Y" ]; then
read_input "Gateway API 基础 URL" "https://lplan.whjhft.com/openapi" GATEWAY_BASE_URL
read_input "Gateway 应用 ID" "" GATEWAY_APP_ID
read_input "Gateway 应用密钥" "" GATEWAY_APP_SECRET "true"
read_input "Gateway 请求超时时间(秒)" "30" GATEWAY_TIMEOUT
GATEWAY_CONFIGURED="true"
else
GATEWAY_CONFIGURED="false"
fi
# ========================================
# 微信配置
# ========================================
print_header "微信配置(可选)"
echo -n "是否配置微信公众号和支付?[y/N]: "
read configure_wechat
if [ "$configure_wechat" = "y" ] || [ "$configure_wechat" = "Y" ]; then
echo ""
print_info ">>> 微信公众号配置"
read_input "公众号 AppID" "" WECHAT_OFFICIAL_ACCOUNT_APP_ID
read_input "公众号 AppSecret" "" WECHAT_OFFICIAL_ACCOUNT_APP_SECRET "true"
read_input "服务器配置 Token可选" "" WECHAT_OFFICIAL_ACCOUNT_TOKEN
read_input "消息加解密 Key可选" "" WECHAT_OFFICIAL_ACCOUNT_AES_KEY "true"
read_input "OAuth 回调 URL可选" "" WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL
echo ""
print_info ">>> 微信支付配置"
read_input "支付 AppID通常与公众号相同" "$WECHAT_OFFICIAL_ACCOUNT_APP_ID" WECHAT_PAYMENT_APP_ID
read_input "商户号" "" WECHAT_PAYMENT_MCH_ID
read_input "APIv3 密钥32位" "" WECHAT_PAYMENT_API_V3_KEY "true"
read_input "APIv2 密钥(可选)" "" WECHAT_PAYMENT_API_V2_KEY "true"
read_input "商户证书路径" "/app/certs/apiclient_cert.pem" WECHAT_PAYMENT_CERT_PATH
read_input "商户私钥路径" "/app/certs/apiclient_key.pem" WECHAT_PAYMENT_KEY_PATH
read_input "证书序列号" "" WECHAT_PAYMENT_SERIAL_NO
read_input "支付回调 URL" "" WECHAT_PAYMENT_NOTIFY_URL
read_input "是否启用 HTTP 调试true/false" "false" WECHAT_PAYMENT_HTTP_DEBUG
read_input "HTTP 请求超时时间" "30s" WECHAT_PAYMENT_TIMEOUT
WECHAT_CONFIGURED="true"
else
WECHAT_CONFIGURED="false"
fi
# ========================================
# 可选:对象存储配置
# ========================================
print_header "对象存储配置(可选)"
echo -n "是否配置对象存储?[y/N]: "
read configure_storage
if [ "$configure_storage" = "y" ] || [ "$configure_storage" = "Y" ]; then
read_input "S3 端点" "" STORAGE_S3_ENDPOINT
read_input "S3 区域" "" STORAGE_S3_REGION
@@ -235,6 +288,49 @@ export JUNHONG_LOGGING_ACCESS_LOG_FILENAME="logs/access.log"
EOF
# 添加 Gateway 配置(如果配置了)
if [ "$GATEWAY_CONFIGURED" = "true" ]; then
cat >> "$ENV_FILE" << EOF
# ----------------------------------------------------------------------------
# Gateway 服务配置
# ----------------------------------------------------------------------------
export JUNHONG_GATEWAY_BASE_URL="$GATEWAY_BASE_URL"
export JUNHONG_GATEWAY_APP_ID="$GATEWAY_APP_ID"
export JUNHONG_GATEWAY_APP_SECRET="$GATEWAY_APP_SECRET"
export JUNHONG_GATEWAY_TIMEOUT="$GATEWAY_TIMEOUT"
EOF
fi
# 添加微信配置(如果配置了)
if [ "$WECHAT_CONFIGURED" = "true" ]; then
cat >> "$ENV_FILE" << EOF
# ----------------------------------------------------------------------------
# 微信公众号配置
# ----------------------------------------------------------------------------
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID="$WECHAT_OFFICIAL_ACCOUNT_APP_ID"
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET="$WECHAT_OFFICIAL_ACCOUNT_APP_SECRET"
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN="$WECHAT_OFFICIAL_ACCOUNT_TOKEN"
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY="$WECHAT_OFFICIAL_ACCOUNT_AES_KEY"
export JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL="$WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL"
# ----------------------------------------------------------------------------
# 微信支付配置
# ----------------------------------------------------------------------------
export JUNHONG_WECHAT_PAYMENT_APP_ID="$WECHAT_PAYMENT_APP_ID"
export JUNHONG_WECHAT_PAYMENT_MCH_ID="$WECHAT_PAYMENT_MCH_ID"
export JUNHONG_WECHAT_PAYMENT_API_V3_KEY="$WECHAT_PAYMENT_API_V3_KEY"
export JUNHONG_WECHAT_PAYMENT_API_V2_KEY="$WECHAT_PAYMENT_API_V2_KEY"
export JUNHONG_WECHAT_PAYMENT_CERT_PATH="$WECHAT_PAYMENT_CERT_PATH"
export JUNHONG_WECHAT_PAYMENT_KEY_PATH="$WECHAT_PAYMENT_KEY_PATH"
export JUNHONG_WECHAT_PAYMENT_SERIAL_NO="$WECHAT_PAYMENT_SERIAL_NO"
export JUNHONG_WECHAT_PAYMENT_NOTIFY_URL="$WECHAT_PAYMENT_NOTIFY_URL"
export JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG="$WECHAT_PAYMENT_HTTP_DEBUG"
export JUNHONG_WECHAT_PAYMENT_TIMEOUT="$WECHAT_PAYMENT_TIMEOUT"
EOF
fi
# 添加对象存储配置(如果配置了)
if [ "$STORAGE_CONFIGURED" = "true" ]; then
cat >> "$ENV_FILE" << EOF

200
scripts/verify-wechat.sh Executable file
View File

@@ -0,0 +1,200 @@
#!/bin/bash
# 微信配置验证脚本
# 用途:检查微信公众号和支付配置的完整性
set -e
echo "========================================"
echo " 微信配置验证脚本"
echo "========================================"
echo ""
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 错误计数
ERROR_COUNT=0
WARNING_COUNT=0
# 检查环境变量是否存在
check_env() {
local var_name=$1
local is_required=${2:-true}
if [ -z "${!var_name}" ]; then
if [ "$is_required" = true ]; then
echo -e "${RED}✗ 缺失必填配置: $var_name${NC}"
((ERROR_COUNT++))
return 1
else
echo -e "${YELLOW}⚠ 缺失可选配置: $var_name${NC}"
((WARNING_COUNT++))
return 0
fi
else
echo -e "${GREEN}$var_name${NC}"
return 0
fi
}
# 检查文件是否存在
check_file() {
local file_path=$1
local var_name=$2
if [ ! -f "$file_path" ]; then
echo -e "${RED}✗ 文件不存在: $file_path (来自 $var_name)${NC}"
((ERROR_COUNT++))
return 1
else
echo -e "${GREEN}✓ 文件存在: $file_path${NC}"
# 检查文件权限
local perms=$(stat -f "%A" "$file_path" 2>/dev/null || stat -c "%a" "$file_path" 2>/dev/null)
if [ "$perms" != "600" ] && [ "$perms" != "644" ] && [ "$perms" != "400" ]; then
echo -e "${YELLOW} ⚠ 建议修改文件权限为 600: chmod 600 $file_path${NC}"
((WARNING_COUNT++))
fi
return 0
fi
}
# 检查字符串长度
check_length() {
local var_name=$1
local expected_length=$2
local value="${!var_name}"
if [ ${#value} -ne $expected_length ]; then
echo -e "${YELLOW}$var_name 长度应为 $expected_length 位,当前 ${#value}${NC}"
((WARNING_COUNT++))
return 1
fi
return 0
}
echo "1. 检查微信公众号配置"
echo "----------------------------------------"
check_env "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID" true
check_env "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET" true
check_env "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN" false
check_env "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY" false
check_env "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL" false
echo ""
echo "2. 检查微信支付配置"
echo "----------------------------------------"
check_env "JUNHONG_WECHAT_PAYMENT_APP_ID" true
check_env "JUNHONG_WECHAT_PAYMENT_MCH_ID" true
check_env "JUNHONG_WECHAT_PAYMENT_API_V3_KEY" true
check_env "JUNHONG_WECHAT_PAYMENT_API_V2_KEY" false
check_env "JUNHONG_WECHAT_PAYMENT_CERT_PATH" true
check_env "JUNHONG_WECHAT_PAYMENT_KEY_PATH" true
check_env "JUNHONG_WECHAT_PAYMENT_SERIAL_NO" true
check_env "JUNHONG_WECHAT_PAYMENT_NOTIFY_URL" true
check_env "JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG" false
check_env "JUNHONG_WECHAT_PAYMENT_TIMEOUT" false
echo ""
echo "3. 检查证书文件"
echo "----------------------------------------"
if [ -n "$JUNHONG_WECHAT_PAYMENT_CERT_PATH" ]; then
check_file "$JUNHONG_WECHAT_PAYMENT_CERT_PATH" "JUNHONG_WECHAT_PAYMENT_CERT_PATH"
fi
if [ -n "$JUNHONG_WECHAT_PAYMENT_KEY_PATH" ]; then
check_file "$JUNHONG_WECHAT_PAYMENT_KEY_PATH" "JUNHONG_WECHAT_PAYMENT_KEY_PATH"
fi
echo ""
echo "4. 验证配置格式"
echo "----------------------------------------"
# 检查 AppID 格式(应以 wx 开头)
if [ -n "$JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID" ]; then
if [[ ! "$JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID" =~ ^wx ]]; then
echo -e "${YELLOW} ⚠ 公众号 AppID 格式可能有误(通常以 wx 开头)${NC}"
((WARNING_COUNT++))
fi
fi
# 检查 APIv3 密钥长度(应为 32 位)
if [ -n "$JUNHONG_WECHAT_PAYMENT_API_V3_KEY" ]; then
check_length "JUNHONG_WECHAT_PAYMENT_API_V3_KEY" 32
fi
# 检查回调 URL 格式(必须是 HTTPS
if [ -n "$JUNHONG_WECHAT_PAYMENT_NOTIFY_URL" ]; then
if [[ ! "$JUNHONG_WECHAT_PAYMENT_NOTIFY_URL" =~ ^https:// ]]; then
echo -e "${RED}✗ 支付回调 URL 必须使用 HTTPS${NC}"
((ERROR_COUNT++))
else
echo -e "${GREEN}✓ 支付回调 URL 使用 HTTPS${NC}"
fi
fi
echo ""
echo "5. 检查证书有效性(可选)"
echo "----------------------------------------"
if [ -n "$JUNHONG_WECHAT_PAYMENT_CERT_PATH" ] && [ -f "$JUNHONG_WECHAT_PAYMENT_CERT_PATH" ]; then
if command -v openssl &> /dev/null; then
# 检查证书是否过期
expiry_date=$(openssl x509 -in "$JUNHONG_WECHAT_PAYMENT_CERT_PATH" -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$expiry_date" ]; then
echo -e "${GREEN}✓ 证书有效期至: $expiry_date${NC}"
# 检查证书序列号是否匹配
cert_serial=$(openssl x509 -in "$JUNHONG_WECHAT_PAYMENT_CERT_PATH" -noout -serial 2>/dev/null | cut -d= -f2)
if [ -n "$cert_serial" ]; then
if [ "$cert_serial" != "$JUNHONG_WECHAT_PAYMENT_SERIAL_NO" ]; then
echo -e "${YELLOW} ⚠ 证书序列号不匹配${NC}"
echo -e " 配置中: $JUNHONG_WECHAT_PAYMENT_SERIAL_NO"
echo -e " 证书中: $cert_serial"
((WARNING_COUNT++))
else
echo -e "${GREEN} ✓ 证书序列号匹配${NC}"
fi
fi
fi
else
echo -e "${YELLOW} ⚠ 未安装 openssl跳过证书验证${NC}"
fi
fi
echo ""
echo "========================================"
echo " 验证结果"
echo "========================================"
echo -e "${RED}错误: $ERROR_COUNT${NC}"
echo -e "${YELLOW}警告: $WARNING_COUNT${NC}"
echo ""
if [ $ERROR_COUNT -gt 0 ]; then
echo -e "${RED}❌ 配置验证失败,请修复上述错误后重试${NC}"
echo ""
echo "建议操作:"
echo "1. 检查 .env.local 文件是否正确加载"
echo "2. 确认所有必填环境变量已设置"
echo "3. 验证证书文件路径是否正确"
echo "4. 参考文档: docs/wechat-integration/使用指南.md"
exit 1
elif [ $WARNING_COUNT -gt 0 ]; then
echo -e "${YELLOW}⚠️ 配置验证通过,但存在警告${NC}"
echo ""
echo "建议操作:"
echo "1. 检查警告信息并根据建议调整"
echo "2. 警告不会影响服务启动,但可能影响功能"
exit 0
else
echo -e "${GREEN}✅ 配置验证通过,所有配置正确${NC}"
echo ""
echo "下一步:"
echo "1. 启动服务: go run cmd/api/main.go"
echo "2. 查看启动日志确认微信服务初始化成功"
echo "3. 参考验证指南进行功能测试: docs/wechat-integration/验证指南.md"
exit 0
fi

BIN
worker

Binary file not shown.