Compare commits
14 Commits
7f18765911
...
242e0b1f40
| Author | SHA1 | Date | |
|---|---|---|---|
| 242e0b1f40 | |||
| 060d8fd65e | |||
| f3297f0529 | |||
| 63ca12393b | |||
| 429edf0d19 | |||
| 7c64e433e8 | |||
| 269769bfe4 | |||
| 1980c846f2 | |||
| 89f9875a97 | |||
| 30c56e66dd | |||
| c86afbfa8f | |||
| aa41a5ed5e | |||
| a308ee228b | |||
| b0da71bd25 |
39
AGENTS.md
39
AGENTS.md
@@ -38,6 +38,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
## 语言要求
|
## 语言要求
|
||||||
|
|
||||||
**必须遵守:**
|
**必须遵守:**
|
||||||
|
|
||||||
- 永远用中文交互
|
- 永远用中文交互
|
||||||
- 注释必须使用中文
|
- 注释必须使用中文
|
||||||
- 文档必须使用中文
|
- 文档必须使用中文
|
||||||
@@ -63,6 +64,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
| 缓存 | Redis 6.0+ |
|
| 缓存 | Redis 6.0+ |
|
||||||
|
|
||||||
**禁止:**
|
**禁止:**
|
||||||
|
|
||||||
- 直接使用 `database/sql`(必须通过 GORM)
|
- 直接使用 `database/sql`(必须通过 GORM)
|
||||||
- 使用 `net/http` 替代 Fiber
|
- 使用 `net/http` 替代 Fiber
|
||||||
- 使用 `encoding/json` 替代 sonic(除非必要)
|
- 使用 `encoding/json` 替代 sonic(除非必要)
|
||||||
@@ -83,21 +85,25 @@ Handler → Service → Store → Model
|
|||||||
## 核心原则
|
## 核心原则
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- 所有错误必须在 `pkg/errors/` 中定义
|
- 所有错误必须在 `pkg/errors/` 中定义
|
||||||
- 使用统一错误码系统
|
- 使用统一错误码系统
|
||||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||||
|
|
||||||
#### 错误报错规范(必须遵守)
|
#### 错误报错规范(必须遵守)
|
||||||
|
|
||||||
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||||
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||||
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||||
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||||
|
|
||||||
### 响应格式
|
### 响应格式
|
||||||
|
|
||||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||||
- 格式: `{code, msg, data, timestamp}`
|
- 格式: `{code, msg, data, timestamp}`
|
||||||
|
|
||||||
### 常量管理
|
### 常量管理
|
||||||
|
|
||||||
- 所有常量定义在 `pkg/constants/`
|
- 所有常量定义在 `pkg/constants/`
|
||||||
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
||||||
- 禁止硬编码字符串和 magic numbers
|
- 禁止硬编码字符串和 magic numbers
|
||||||
@@ -177,6 +183,7 @@ func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) erro
|
|||||||
#### 未导出符号的注释
|
#### 未导出符号的注释
|
||||||
|
|
||||||
未导出(小写)的函数/方法:
|
未导出(小写)的函数/方法:
|
||||||
|
|
||||||
- **简单逻辑**(< 15 行):可以不加注释
|
- **简单逻辑**(< 15 行):可以不加注释
|
||||||
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||||
|
|
||||||
@@ -199,6 +206,7 @@ func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.Pe
|
|||||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||||
|
|
||||||
**✅ 好的内联注释(解释为什么)**:
|
**✅ 好的内联注释(解释为什么)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||||
@@ -212,6 +220,7 @@ if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**❌ 废话注释(禁止)**:
|
**❌ 废话注释(禁止)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||||
userID := middleware.GetUserIDFromContext(ctx)
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
@@ -248,6 +257,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Go 代码风格
|
### Go 代码风格
|
||||||
|
|
||||||
- 使用 `gofmt` 格式化
|
- 使用 `gofmt` 格式化
|
||||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||||
- 包名: 简短、小写、单数、无下划线
|
- 包名: 简短、小写、单数、无下划线
|
||||||
@@ -256,6 +266,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## 数据库设计
|
## 数据库设计
|
||||||
|
|
||||||
**核心规则:**
|
**核心规则:**
|
||||||
|
|
||||||
- ❌ 禁止建立外键约束
|
- ❌ 禁止建立外键约束
|
||||||
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
||||||
- ✅ 关联通过存储 ID 字段手动维护
|
- ✅ 关联通过存储 ID 字段手动维护
|
||||||
@@ -264,6 +275,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Go 惯用法 vs Java 风格
|
## Go 惯用法 vs Java 风格
|
||||||
|
|
||||||
### ✅ Go 风格(推荐)
|
### ✅ Go 风格(推荐)
|
||||||
|
|
||||||
- 扁平化包结构(最多 2-3 层)
|
- 扁平化包结构(最多 2-3 层)
|
||||||
- 小而专注的接口(1-3 个方法)
|
- 小而专注的接口(1-3 个方法)
|
||||||
- 直接访问导出字段(不用 getter/setter)
|
- 直接访问导出字段(不用 getter/setter)
|
||||||
@@ -271,6 +283,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 显式错误返回和检查
|
- 显式错误返回和检查
|
||||||
|
|
||||||
### ❌ Java 风格(禁止)
|
### ❌ Java 风格(禁止)
|
||||||
|
|
||||||
- 过度抽象(不必要的接口、工厂)
|
- 过度抽象(不必要的接口、工厂)
|
||||||
- Getter/Setter 方法
|
- Getter/Setter 方法
|
||||||
- 深层继承层次
|
- 深层继承层次
|
||||||
@@ -282,6 +295,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
**本项目不使用任何形式的自动化测试代码。**
|
**本项目不使用任何形式的自动化测试代码。**
|
||||||
|
|
||||||
**绝对禁止:**
|
**绝对禁止:**
|
||||||
|
|
||||||
- ❌ **禁止编写单元测试** - 无论任何场景
|
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||||
- ❌ **禁止编写集成测试** - 无论任何场景
|
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||||
- ❌ **禁止编写验收测试** - 无论任何场景
|
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||||
@@ -292,15 +306,18 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||||
|
|
||||||
**唯一例外:**
|
**唯一例外:**
|
||||||
|
|
||||||
- ✅ **仅当用户明确要求**时才编写测试代码
|
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||||
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||||
|
|
||||||
**原因说明:**
|
**原因说明:**
|
||||||
|
|
||||||
- 业务系统的正确性通过人工验证和生产环境监控保证
|
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||||
- 测试代码的维护成本高于价值
|
- 测试代码的维护成本高于价值
|
||||||
- 快速迭代优先于测试覆盖率
|
- 快速迭代优先于测试覆盖率
|
||||||
|
|
||||||
**替代方案:**
|
**替代方案:**
|
||||||
|
|
||||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||||
- 使用 Postman/curl 手动测试 API
|
- 使用 Postman/curl 手动测试 API
|
||||||
- 依赖生产环境日志和监控发现问题
|
- 依赖生产环境日志和监控发现问题
|
||||||
@@ -349,23 +366,27 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Code Review 检查清单
|
## Code Review 检查清单
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||||
- [ ] Handler 层参数校验不泄露细节
|
- [ ] Handler 层参数校验不泄露细节
|
||||||
- [ ] 错误码使用正确(4xx vs 5xx)
|
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||||
- [ ] 错误日志完整(包含上下文)
|
- [ ] 错误日志完整(包含上下文)
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- [ ] 遵循 Handler → Service → Store → Model 分层
|
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||||
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||||
- [ ] 常量定义在 `pkg/constants/`
|
- [ ] 常量定义在 `pkg/constants/`
|
||||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||||
|
|
||||||
### 文档和注释
|
### 文档和注释
|
||||||
|
|
||||||
- [ ] 所有注释使用中文
|
- [ ] 所有注释使用中文
|
||||||
- [ ] 导出函数/类型有文档注释
|
- [ ] 导出函数/类型有文档注释
|
||||||
- [ ] API 路径注释与真实路由一致
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
### 幂等性
|
### 幂等性
|
||||||
|
|
||||||
- [ ] 创建类写操作有 Redis 业务键防重
|
- [ ] 创建类写操作有 Redis 业务键防重
|
||||||
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||||
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||||
@@ -381,6 +402,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
1. **路由层中间件**(粗粒度拦截)
|
1. **路由层中间件**(粗粒度拦截)
|
||||||
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
||||||
- 示例:
|
- 示例:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
group.Use(func(c *fiber.Ctx) error {
|
group.Use(func(c *fiber.Ctx) error {
|
||||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
@@ -404,6 +426,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 无需手动调用
|
- 无需手动调用
|
||||||
|
|
||||||
**统一错误返回**:
|
**统一错误返回**:
|
||||||
|
|
||||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
- 不区分"不存在"和"无权限",防止信息泄露
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
@@ -522,6 +545,7 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
**使用方式**:
|
**使用方式**:
|
||||||
|
|
||||||
1. **Service 层集成审计日志**:
|
1. **Service 层集成审计日志**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
@@ -585,3 +609,18 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
||||||
|
|
||||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||||
|
|
||||||
|
# English Learning Mode
|
||||||
|
|
||||||
|
The user is learning English through practical use. Apply these rules in every conversation:
|
||||||
|
|
||||||
|
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
|
||||||
|
|
||||||
|
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
|
||||||
|
→ `[natural version of what they wrote]`
|
||||||
|
No explanation needed — just the corrected phrase.
|
||||||
|
|
||||||
|
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
|
||||||
|
big deal of it.
|
||||||
|
|
||||||
|
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.
|
||||||
|
|||||||
39
CLAUDE.md
39
CLAUDE.md
@@ -38,6 +38,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
## 语言要求
|
## 语言要求
|
||||||
|
|
||||||
**必须遵守:**
|
**必须遵守:**
|
||||||
|
|
||||||
- 永远用中文交互
|
- 永远用中文交互
|
||||||
- 注释必须使用中文
|
- 注释必须使用中文
|
||||||
- 文档必须使用中文
|
- 文档必须使用中文
|
||||||
@@ -63,6 +64,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
| 缓存 | Redis 6.0+ |
|
| 缓存 | Redis 6.0+ |
|
||||||
|
|
||||||
**禁止:**
|
**禁止:**
|
||||||
|
|
||||||
- 直接使用 `database/sql`(必须通过 GORM)
|
- 直接使用 `database/sql`(必须通过 GORM)
|
||||||
- 使用 `net/http` 替代 Fiber
|
- 使用 `net/http` 替代 Fiber
|
||||||
- 使用 `encoding/json` 替代 sonic(除非必要)
|
- 使用 `encoding/json` 替代 sonic(除非必要)
|
||||||
@@ -83,21 +85,25 @@ Handler → Service → Store → Model
|
|||||||
## 核心原则
|
## 核心原则
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- 所有错误必须在 `pkg/errors/` 中定义
|
- 所有错误必须在 `pkg/errors/` 中定义
|
||||||
- 使用统一错误码系统
|
- 使用统一错误码系统
|
||||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||||
|
|
||||||
#### 错误报错规范(必须遵守)
|
#### 错误报错规范(必须遵守)
|
||||||
|
|
||||||
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||||
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||||
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||||
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||||
|
|
||||||
### 响应格式
|
### 响应格式
|
||||||
|
|
||||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||||
- 格式: `{code, msg, data, timestamp}`
|
- 格式: `{code, msg, data, timestamp}`
|
||||||
|
|
||||||
### 常量管理
|
### 常量管理
|
||||||
|
|
||||||
- 所有常量定义在 `pkg/constants/`
|
- 所有常量定义在 `pkg/constants/`
|
||||||
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
||||||
- 禁止硬编码字符串和 magic numbers
|
- 禁止硬编码字符串和 magic numbers
|
||||||
@@ -177,6 +183,7 @@ func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) erro
|
|||||||
#### 未导出符号的注释
|
#### 未导出符号的注释
|
||||||
|
|
||||||
未导出(小写)的函数/方法:
|
未导出(小写)的函数/方法:
|
||||||
|
|
||||||
- **简单逻辑**(< 15 行):可以不加注释
|
- **简单逻辑**(< 15 行):可以不加注释
|
||||||
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||||
|
|
||||||
@@ -199,6 +206,7 @@ func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.Pe
|
|||||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||||
|
|
||||||
**✅ 好的内联注释(解释为什么)**:
|
**✅ 好的内联注释(解释为什么)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||||
@@ -212,6 +220,7 @@ if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**❌ 废话注释(禁止)**:
|
**❌ 废话注释(禁止)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||||
userID := middleware.GetUserIDFromContext(ctx)
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
@@ -248,6 +257,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Go 代码风格
|
### Go 代码风格
|
||||||
|
|
||||||
- 使用 `gofmt` 格式化
|
- 使用 `gofmt` 格式化
|
||||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||||
- 包名: 简短、小写、单数、无下划线
|
- 包名: 简短、小写、单数、无下划线
|
||||||
@@ -256,6 +266,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## 数据库设计
|
## 数据库设计
|
||||||
|
|
||||||
**核心规则:**
|
**核心规则:**
|
||||||
|
|
||||||
- ❌ 禁止建立外键约束
|
- ❌ 禁止建立外键约束
|
||||||
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
||||||
- ✅ 关联通过存储 ID 字段手动维护
|
- ✅ 关联通过存储 ID 字段手动维护
|
||||||
@@ -264,6 +275,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Go 惯用法 vs Java 风格
|
## Go 惯用法 vs Java 风格
|
||||||
|
|
||||||
### ✅ Go 风格(推荐)
|
### ✅ Go 风格(推荐)
|
||||||
|
|
||||||
- 扁平化包结构(最多 2-3 层)
|
- 扁平化包结构(最多 2-3 层)
|
||||||
- 小而专注的接口(1-3 个方法)
|
- 小而专注的接口(1-3 个方法)
|
||||||
- 直接访问导出字段(不用 getter/setter)
|
- 直接访问导出字段(不用 getter/setter)
|
||||||
@@ -271,6 +283,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 显式错误返回和检查
|
- 显式错误返回和检查
|
||||||
|
|
||||||
### ❌ Java 风格(禁止)
|
### ❌ Java 风格(禁止)
|
||||||
|
|
||||||
- 过度抽象(不必要的接口、工厂)
|
- 过度抽象(不必要的接口、工厂)
|
||||||
- Getter/Setter 方法
|
- Getter/Setter 方法
|
||||||
- 深层继承层次
|
- 深层继承层次
|
||||||
@@ -282,6 +295,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
**本项目不使用任何形式的自动化测试代码。**
|
**本项目不使用任何形式的自动化测试代码。**
|
||||||
|
|
||||||
**绝对禁止:**
|
**绝对禁止:**
|
||||||
|
|
||||||
- ❌ **禁止编写单元测试** - 无论任何场景
|
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||||
- ❌ **禁止编写集成测试** - 无论任何场景
|
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||||
- ❌ **禁止编写验收测试** - 无论任何场景
|
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||||
@@ -292,15 +306,18 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||||
|
|
||||||
**唯一例外:**
|
**唯一例外:**
|
||||||
|
|
||||||
- ✅ **仅当用户明确要求**时才编写测试代码
|
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||||
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||||
|
|
||||||
**原因说明:**
|
**原因说明:**
|
||||||
|
|
||||||
- 业务系统的正确性通过人工验证和生产环境监控保证
|
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||||
- 测试代码的维护成本高于价值
|
- 测试代码的维护成本高于价值
|
||||||
- 快速迭代优先于测试覆盖率
|
- 快速迭代优先于测试覆盖率
|
||||||
|
|
||||||
**替代方案:**
|
**替代方案:**
|
||||||
|
|
||||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||||
- 使用 Postman/curl 手动测试 API
|
- 使用 Postman/curl 手动测试 API
|
||||||
- 依赖生产环境日志和监控发现问题
|
- 依赖生产环境日志和监控发现问题
|
||||||
@@ -349,23 +366,27 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Code Review 检查清单
|
## Code Review 检查清单
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||||
- [ ] Handler 层参数校验不泄露细节
|
- [ ] Handler 层参数校验不泄露细节
|
||||||
- [ ] 错误码使用正确(4xx vs 5xx)
|
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||||
- [ ] 错误日志完整(包含上下文)
|
- [ ] 错误日志完整(包含上下文)
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- [ ] 遵循 Handler → Service → Store → Model 分层
|
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||||
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||||
- [ ] 常量定义在 `pkg/constants/`
|
- [ ] 常量定义在 `pkg/constants/`
|
||||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||||
|
|
||||||
### 文档和注释
|
### 文档和注释
|
||||||
|
|
||||||
- [ ] 所有注释使用中文
|
- [ ] 所有注释使用中文
|
||||||
- [ ] 导出函数/类型有文档注释
|
- [ ] 导出函数/类型有文档注释
|
||||||
- [ ] API 路径注释与真实路由一致
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
### 幂等性
|
### 幂等性
|
||||||
|
|
||||||
- [ ] 创建类写操作有 Redis 业务键防重
|
- [ ] 创建类写操作有 Redis 业务键防重
|
||||||
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||||
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||||
@@ -381,6 +402,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
1. **路由层中间件**(粗粒度拦截)
|
1. **路由层中间件**(粗粒度拦截)
|
||||||
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
||||||
- 示例:
|
- 示例:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
group.Use(func(c *fiber.Ctx) error {
|
group.Use(func(c *fiber.Ctx) error {
|
||||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
@@ -404,6 +426,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 无需手动调用
|
- 无需手动调用
|
||||||
|
|
||||||
**统一错误返回**:
|
**统一错误返回**:
|
||||||
|
|
||||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
- 不区分"不存在"和"无权限",防止信息泄露
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
@@ -522,6 +545,7 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
**使用方式**:
|
**使用方式**:
|
||||||
|
|
||||||
1. **Service 层集成审计日志**:
|
1. **Service 层集成审计日志**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
@@ -585,3 +609,18 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
||||||
|
|
||||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||||
|
|
||||||
|
# English Learning Mode
|
||||||
|
|
||||||
|
The user is learning English through practical use. Apply these rules in every conversation:
|
||||||
|
|
||||||
|
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
|
||||||
|
|
||||||
|
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
|
||||||
|
→ `[natural version of what they wrote]`
|
||||||
|
No explanation needed — just the corrected phrase.
|
||||||
|
|
||||||
|
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
|
||||||
|
big deal of it.
|
||||||
|
|
||||||
|
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.
|
||||||
|
|||||||
@@ -359,60 +359,16 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
|||||||
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
|
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
|
||||||
wechatCfg := cfg.Wechat
|
wechatCfg := cfg.Wechat
|
||||||
|
|
||||||
if wechatCfg.OfficialAccount.AppID == "" && wechatCfg.Payment.AppID == "" {
|
if wechatCfg.OfficialAccount.AppID == "" {
|
||||||
appLogger.Warn("微信配置未设置,微信相关功能将不可用")
|
appLogger.Warn("微信公众号配置未设置,OAuth 相关功能将不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if wechatCfg.OfficialAccount.AppID != "" {
|
if wechatCfg.OfficialAccount.AppSecret == "" {
|
||||||
if wechatCfg.OfficialAccount.AppSecret == "" {
|
appLogger.Fatal("微信公众号配置不完整",
|
||||||
appLogger.Fatal("微信公众号配置不完整",
|
zap.String("missing", "app_secret"),
|
||||||
zap.String("missing", "app_secret"),
|
zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_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))
|
|
||||||
}
|
}
|
||||||
|
appLogger.Info("微信公众号配置已验证",
|
||||||
|
zap.String("app_id", wechatCfg.OfficialAccount.AppID))
|
||||||
}
|
}
|
||||||
|
|||||||
227
docs/agent-recharge/功能总结.md
Normal file
227
docs/agent-recharge/功能总结.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# 代理预充值功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
代理商(店铺)余额钱包的在线充值系统,支持微信在线支付和线下转账两种充值方式,具备完整的 Service/Handler/回调处理链路。充值仅针对余额钱包(`wallet_type=main`),佣金钱包通过分佣自动入账。
|
||||||
|
|
||||||
|
### 背景与动机
|
||||||
|
|
||||||
|
原有 `tb_agent_recharge_record` 表和 Store 层骨架已存在,但缺少 Service 层和 Handler 层,无法通过 API 发起充值。本次补全完整实现,并集成至支付配置管理体系(按 `payment_config_id` 动态路由至微信直连或富友通道)。
|
||||||
|
|
||||||
|
## 核心流程
|
||||||
|
|
||||||
|
### 在线充值流程(微信)
|
||||||
|
|
||||||
|
```
|
||||||
|
代理/平台 → POST /api/admin/agent-recharges
|
||||||
|
│
|
||||||
|
├─ 验证权限:代理只能充自己店铺,平台可指定任意店铺
|
||||||
|
├─ 验证金额范围(100 元~100 万元)
|
||||||
|
├─ 查找目标店铺的 main 钱包
|
||||||
|
├─ 查询 active 支付配置 → 无配置则拒绝(返回 1175)
|
||||||
|
├─ 记录 payment_config_id
|
||||||
|
└─ 创建充值订单(status=1 待支付)
|
||||||
|
└─ 返回订单信息(客户端支付发起【留桩】)
|
||||||
|
|
||||||
|
支付成功 → POST /api/callback/wechat-pay 或 /api/callback/fuiou-pay
|
||||||
|
│
|
||||||
|
├─ 按订单号前缀 "ARCH" 识别为代理充值
|
||||||
|
├─ 查询充值记录,取 payment_config_id
|
||||||
|
├─ 按配置验签
|
||||||
|
└─ agentRechargeService.HandlePaymentCallback()
|
||||||
|
├─ 幂等检查(WHERE status = 1)
|
||||||
|
├─ 更新充值记录状态 → 2(已完成)
|
||||||
|
├─ 代理主钱包余额增加(乐观锁防并发)
|
||||||
|
└─ 创建钱包流水记录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 线下充值流程(仅平台)
|
||||||
|
|
||||||
|
```
|
||||||
|
平台 → POST /api/admin/agent-recharges
|
||||||
|
└─ payment_method = "offline"
|
||||||
|
└─ 创建充值订单(status=1 待支付)
|
||||||
|
|
||||||
|
平台确认 → POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
├─ 验证操作密码(二次鉴权)
|
||||||
|
└─ 事务内:
|
||||||
|
├─ 更新充值记录状态 → 2(已完成)
|
||||||
|
├─ 记录 paid_at、completed_at
|
||||||
|
├─ 代理主钱包余额增加(乐观锁 version 字段)
|
||||||
|
├─ 创建钱包流水记录
|
||||||
|
└─ 记录审计日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口说明
|
||||||
|
|
||||||
|
### 基础路径
|
||||||
|
|
||||||
|
`/api/admin/agent-recharges`
|
||||||
|
|
||||||
|
**权限要求**:企业账号(`user_type=4`)在路由层被拦截,返回 `1005`。
|
||||||
|
|
||||||
|
### 接口列表
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| POST | `/api/admin/agent-recharges` | 创建充值订单 | 代理(自己店铺)/ 平台(任意店铺)|
|
||||||
|
| GET | `/api/admin/agent-recharges` | 查询充值记录列表 | 代理(自己店铺)/ 平台(全部)|
|
||||||
|
| GET | `/api/admin/agent-recharges/:id` | 查询充值记录详情 | 代理(自己店铺)/ 平台(全部)|
|
||||||
|
| POST | `/api/admin/agent-recharges/:id/offline-pay` | 确认线下充值到账 | 仅平台 |
|
||||||
|
|
||||||
|
### 创建充值订单
|
||||||
|
|
||||||
|
**请求体示例(在线充值)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体示例(线下充值)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 200000,
|
||||||
|
"payment_method": "offline"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求字段**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| shop_id | integer | 是 | 目标店铺 ID(代理只能填自己所属店铺)|
|
||||||
|
| amount | integer | 是 | 充值金额(单位:分),范围 10000~100000000 |
|
||||||
|
| payment_method | string | 是 | `wechat`(在线)/ `offline`(线下,仅平台)|
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 88,
|
||||||
|
"recharge_no": "ARCH20260316100001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat",
|
||||||
|
"payment_channel": "wechat_direct",
|
||||||
|
"payment_config_id": 3,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 线下充值确认
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation_password": "Abc123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
操作密码验证通过后,事务内同步完成:余额到账 + 钱包流水 + 审计日志。
|
||||||
|
|
||||||
|
## 权限控制矩阵
|
||||||
|
|
||||||
|
| 操作 | 平台账号 | 代理账号 | 企业账号 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| 创建充值(在线) | ✅ 任意店铺 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
| 创建充值(线下) | ✅ 任意店铺 | ❌ | ❌ |
|
||||||
|
| 线下充值确认 | ✅ | ❌ | ❌ |
|
||||||
|
| 查询充值列表 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
| 查询充值详情 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
|
||||||
|
**越权统一响应**:代理访问他人店铺充值记录时,返回 `1121 CodeRechargeNotFound`(不区分不存在与无权限)
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### `tb_agent_recharge_record` 新增字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `payment_config_id` | bigint | 是 | 关联支付配置 ID(线下充值为 NULL,在线充值记录实际使用的配置)|
|
||||||
|
|
||||||
|
### 充值订单状态枚举
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| 1 | 待支付 |
|
||||||
|
| 2 | 已完成 |
|
||||||
|
| 3 | 已取消 |
|
||||||
|
|
||||||
|
### 支付方式与通道
|
||||||
|
|
||||||
|
| payment_method | payment_channel | 说明 |
|
||||||
|
|---------------|----------------|------|
|
||||||
|
| wechat | wechat_direct | 微信直连通道(provider_type=wechat)|
|
||||||
|
| wechat | fuyou | 富友通道(provider_type=fuiou)|
|
||||||
|
| offline | offline | 线下转账 |
|
||||||
|
|
||||||
|
> 前端统一显示"微信支付",后端根据生效配置的 `provider_type` 自动路由,前端不感知具体通道。
|
||||||
|
|
||||||
|
### 充值单号规则
|
||||||
|
|
||||||
|
前缀 `ARCH`,全局唯一,用于回调时识别订单类型。
|
||||||
|
|
||||||
|
## 幂等性设计
|
||||||
|
|
||||||
|
- 回调处理使用状态条件更新:`WHERE status = 1`
|
||||||
|
- `RowsAffected == 0` 时说明已被处理,直接返回成功,不重复入账
|
||||||
|
- 钱包余额更新使用乐观锁(`version` 字段),并发冲突时最多重试 3 次
|
||||||
|
|
||||||
|
## 审计日志
|
||||||
|
|
||||||
|
线下充值确认(`OfflinePay`)操作记录审计日志,字段包括:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| `operator_id` | 当前操作人 ID |
|
||||||
|
| `operation_type` | `offline_recharge` |
|
||||||
|
| `operation_desc` | `确认代理充值到账:充值单号 {recharge_no},金额 {amount} 分` |
|
||||||
|
| `before_data` | 操作前余额和充值记录状态 |
|
||||||
|
| `after_data` | 操作后余额和充值记录状态 |
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 层级 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| DTO | `internal/model/dto/agent_recharge_dto.go` | 请求/响应 DTO |
|
||||||
|
| Service | `internal/service/agent_recharge/service.go` | 充值业务逻辑 |
|
||||||
|
| Handler | `internal/handler/admin/agent_recharge.go` | 4 个 Handler 方法 |
|
||||||
|
| 路由 | `internal/routes/agent_recharge.go` | 路由注册 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `internal/model/agent_wallet.go` | 新增 `PaymentConfigID *uint` 字段 |
|
||||||
|
| `internal/handler/callback/payment.go` | 新增 "ARCH" 前缀分发 → agentRechargeService.HandlePaymentCallback() |
|
||||||
|
| `internal/bootstrap/` 系列 | 注册 AgentRechargeService、AgentRechargeHandler |
|
||||||
|
| `cmd/api/docs.go` / `cmd/gendocs/main.go` | 注册 AgentRechargeHandler |
|
||||||
|
| `migrations/000081_add_payment_config_id_to_agent_recharge.up.sql` | tb_agent_recharge_record 新增 payment_config_id 列 |
|
||||||
|
|
||||||
|
## 常量定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/constants/wallet.go
|
||||||
|
AgentRechargeOrderPrefix = "ARCH" // 充值单号前缀
|
||||||
|
AgentRechargeMinAmount = 10000 // 最小充值:100 元(单位:分)
|
||||||
|
AgentRechargeMaxAmount = 100000000 // 最大充值:100 万元(单位:分)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知限制(留桩)
|
||||||
|
|
||||||
|
**客户端支付发起未实现**:在线充值(`payment_method=wechat`)创建订单成功后,前端获取支付参数的接口本次未实现。充值回调处理已完整实现——等支付发起改造完成后,完整的充值支付闭环即可联通。
|
||||||
239
docs/wechat-config-management/功能总结.md
Normal file
239
docs/wechat-config-management/功能总结.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 微信参数配置管理功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
在管理后台支持多套微信支付配置的 CRUD 管理,每套配置代表一套完整的"微信身份"(公众号 OAuth + 小程序 OAuth + 支付凭证),支持全局唯一激活约束和秒级切换。同时集成富友支付 SDK,作为微信直连的备选渠道。
|
||||||
|
|
||||||
|
### 背景与动机
|
||||||
|
|
||||||
|
原有微信相关参数(公众号 OAuth、小程序、支付凭证)硬编码在环境变量中,只有一套配置,无法动态切换。业务上微信公众号/小程序随时可能被封禁,需要在管理后台**秒级切换**到备用配置恢复 OAuth 登录和支付能力。同时需要接入富友支付作为备选通道,降低对微信直连的单一依赖。
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
### 配置切换流程
|
||||||
|
|
||||||
|
```
|
||||||
|
管理员激活新配置 POST /api/admin/wechat-configs/:id/activate
|
||||||
|
│
|
||||||
|
├─ ① BEGIN 事务
|
||||||
|
│ ├─ UPDATE tb_wechat_config SET is_active=false WHERE is_active=true
|
||||||
|
│ └─ UPDATE tb_wechat_config SET is_active=true WHERE id=:id
|
||||||
|
├─ ② COMMIT
|
||||||
|
├─ ③ DEL Redis "wechat:config:active"(即时生效)
|
||||||
|
└─ ④ 记录审计日志
|
||||||
|
│
|
||||||
|
├─ 新订单 → 使用新配置(记录新的 payment_config_id)
|
||||||
|
└─ 旧订单(待支付)→ 回调时按 payment_config_id 加载旧配置验签
|
||||||
|
└─ 30 分钟超时自动取消
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生效配置缓存策略
|
||||||
|
|
||||||
|
- **Redis Key**:`wechat:config:active`(见 `pkg/constants/redis.go`)
|
||||||
|
- **TTL**:5 分钟(兜底,防 Redis 缓存与 DB 长期不一致)
|
||||||
|
- **主动失效**:激活、停用、更新生效配置、删除配置时主动 DEL 缓存
|
||||||
|
- **空标记**:无生效配置时缓存 `"none"`,TTL 1 分钟,防止缓存穿透
|
||||||
|
- **读取流程**:Redis GET → 命中返回 → MISS → 查 DB → SET 缓存
|
||||||
|
|
||||||
|
### 配置切换时在途订单处理
|
||||||
|
|
||||||
|
- `tb_order`、`tb_asset_recharge_record`、`tb_agent_recharge_record` 均新增 `payment_config_id` 字段(nullable)
|
||||||
|
- 下单时记录当前使用的配置 ID,配置切换后旧订单仍按 `payment_config_id` 加载旧配置验签
|
||||||
|
- 旧待支付订单由现有 30 分钟超时自动取消机制清理
|
||||||
|
- **有待支付订单引用的配置不允许删除**(软删除后仍可用于验签)
|
||||||
|
|
||||||
|
### 支付回调统一分发
|
||||||
|
|
||||||
|
```
|
||||||
|
回调到达
|
||||||
|
│
|
||||||
|
├─ 微信回调 POST /api/callback/wechat-pay
|
||||||
|
│ └─ PowerWeChat SDK 解析 → 取 out_trade_no
|
||||||
|
│
|
||||||
|
└─ 富友回调 POST /api/callback/fuiou-pay
|
||||||
|
└─ GBK→UTF-8 → XML 解析 → 取 mchnt_order_no
|
||||||
|
│
|
||||||
|
└─ 按订单号前缀分发
|
||||||
|
├─ "ORD" → 套餐订单 → orderService.HandlePaymentCallback()
|
||||||
|
├─ "CRCH" → 资产充值 → rechargeService.HandlePaymentCallback()
|
||||||
|
└─ "ARCH" → 代理充值 → agentRechargeService.HandlePaymentCallback()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口说明
|
||||||
|
|
||||||
|
### 基础路径
|
||||||
|
|
||||||
|
`/api/admin/wechat-configs`
|
||||||
|
|
||||||
|
**权限要求**:仅超级管理员(`user_type=1`)和平台用户(`user_type=2`)可访问,其他类型返回 `1005`。
|
||||||
|
|
||||||
|
### 接口列表
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/admin/wechat-configs` | 创建配置 |
|
||||||
|
| GET | `/api/admin/wechat-configs` | 查询配置列表(分页+筛选) |
|
||||||
|
| GET | `/api/admin/wechat-configs/active` | 查询当前生效配置 |
|
||||||
|
| GET | `/api/admin/wechat-configs/:id` | 查询配置详情 |
|
||||||
|
| PUT | `/api/admin/wechat-configs/:id` | 更新配置 |
|
||||||
|
| DELETE | `/api/admin/wechat-configs/:id` | 软删除配置 |
|
||||||
|
| POST | `/api/admin/wechat-configs/:id/activate` | 激活配置 |
|
||||||
|
| POST | `/api/admin/wechat-configs/:id/deactivate` | 停用配置 |
|
||||||
|
| POST | `/api/callback/fuiou-pay` | 富友支付回调(无需认证) |
|
||||||
|
|
||||||
|
### 渠道类型(provider_type)
|
||||||
|
|
||||||
|
| 值 | 说明 | 必填支付字段 |
|
||||||
|
|----|------|-------------|
|
||||||
|
| `wechat` | 微信直连 | `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url` |
|
||||||
|
| `fuiou` | 富友聚合支付 | `fy_ins_cd`、`fy_mchnt_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url` |
|
||||||
|
|
||||||
|
### 敏感字段脱敏规则
|
||||||
|
|
||||||
|
接口响应中所有敏感字段均脱敏,数据库明文存储:
|
||||||
|
|
||||||
|
| 字段类型 | 脱敏规则 | 示例 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| Secret/Key(短) | 前4位 + `***` + 后4位 | `abcd***7890` |
|
||||||
|
| 证书/私钥(长) | 仅显示状态 | `[已配置]` / `[未配置]` |
|
||||||
|
|
||||||
|
**更新脱敏字段**:不传或传空字符串 = 保留原值;传新明文值 = 替换。
|
||||||
|
|
||||||
|
### 删除保护规则
|
||||||
|
|
||||||
|
| 条件 | 错误码 | 错误消息 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| 配置 `is_active=true` | `1171` | 不能删除当前生效的支付配置,请先停用 |
|
||||||
|
| 存在待支付订单引用 | `1172` | 该配置存在未完成的支付订单,暂时无法删除 |
|
||||||
|
|
||||||
|
## 富友支付 SDK
|
||||||
|
|
||||||
|
**位置**:`pkg/fuiou/`
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `types.go` | WxPreCreateRequest/Response、NotifyRequest 等 XML 结构体 |
|
||||||
|
| `client.go` | Client 结构体、NewClient、RSA 签名/验签、HTTP 请求(XML+GBK)|
|
||||||
|
| `wxprecreate.go` | WxPreCreate 方法(公众号 JSAPI + 小程序支付下单)|
|
||||||
|
| `notify.go` | VerifyNotify(GBK→UTF-8 + XML 解析 + RSA 验签)、BuildNotifyResponse |
|
||||||
|
|
||||||
|
**签名算法**:字典序排列参数 → GBK 编码 → MD5 哈希 → RSA 签名 → Base64
|
||||||
|
|
||||||
|
**新增依赖**:`golang.org/x/text`(GBK 编解码)
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
### 新建表 `tb_wechat_config`(迁移 000078)
|
||||||
|
|
||||||
|
| 字段组 | 字段 | 说明 |
|
||||||
|
|-------|------|------|
|
||||||
|
| 基础信息 | `id`, `name`, `description`, `provider_type`, `is_active` | 配置基础字段 |
|
||||||
|
| 公众号 OAuth | `oa_app_id`, `oa_app_secret`, `oa_token`, `oa_aes_key`, `oa_oauth_redirect_url` | 公众号相关 |
|
||||||
|
| 小程序 OAuth | `miniapp_app_id`, `miniapp_app_secret` | 小程序相关 |
|
||||||
|
| 微信直连 | `wx_mch_id`, `wx_api_v3_key`, `wx_api_v2_key`, `wx_cert_content`, `wx_key_content`, `wx_serial_no`, `wx_notify_url` | provider_type=wechat 时使用 |
|
||||||
|
| 富友 | `fy_ins_cd`, `fy_mchnt_cd`, `fy_term_id`, `fy_private_key`, `fy_public_key`, `fy_api_url`, `fy_notify_url` | provider_type=fuiou 时使用 |
|
||||||
|
| 审计 | `creator`, `updater`, `created_at`, `updated_at`, `deleted_at` | 标准审计字段 |
|
||||||
|
|
||||||
|
### 新增字段
|
||||||
|
|
||||||
|
| 表 | 字段 | 类型 | 迁移文件 |
|
||||||
|
|----|------|------|---------|
|
||||||
|
| `tb_order` | `payment_config_id` | bigint, nullable | 000079 |
|
||||||
|
| `tb_asset_recharge_record` | `payment_config_id` | bigint, nullable | 000080 |
|
||||||
|
| `tb_agent_recharge_record` | `payment_config_id` | bigint, nullable | 000081 |
|
||||||
|
|
||||||
|
## 新增错误码
|
||||||
|
|
||||||
|
| 错误码 | 常量 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 1170 | `CodeWechatConfigNotFound` | 微信支付配置不存在 |
|
||||||
|
| 1171 | `CodeWechatConfigActive` | 不能删除/操作当前生效的支付配置 |
|
||||||
|
| 1172 | `CodeWechatConfigHasPendingOrders` | 该配置存在未完成的支付订单 |
|
||||||
|
| 1173 | `CodeFuiouPayFailed` | 富友支付失败 |
|
||||||
|
| 1174 | `CodeFuiouCallbackInvalid` | 富友回调验签失败 |
|
||||||
|
| 1175 | `CodeNoPaymentConfig` | 当前无可用的支付配置 |
|
||||||
|
|
||||||
|
## 审计日志
|
||||||
|
|
||||||
|
以下操作均记录审计日志(异步写入,失败不影响业务):
|
||||||
|
|
||||||
|
| 操作 | operation_type | 说明 |
|
||||||
|
|------|---------------|------|
|
||||||
|
| 创建配置 | `create` | after_data 存脱敏后配置 |
|
||||||
|
| 更新配置 | `update` | before/after_data 均脱敏 |
|
||||||
|
| 删除配置 | `delete` | before_data 存脱敏后配置 |
|
||||||
|
| 激活配置 | `activate` | before_data=旧配置,after_data=新配置 |
|
||||||
|
| 停用配置 | `deactivate` | before/after_data 存状态变更 |
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 层级 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 模型 | `internal/model/wechat_config.go` | WechatConfig 模型、渠道类型常量 |
|
||||||
|
| DTO | `internal/model/dto/wechat_config_dto.go` | CRUD 请求/响应 DTO、脱敏方法 |
|
||||||
|
| Store | `internal/store/postgres/wechat_config_store.go` | CRUD + 激活/停用 + 统计 |
|
||||||
|
| Service | `internal/service/wechat_config/service.go` | 业务逻辑、缓存管理、删除保护 |
|
||||||
|
| Handler | `internal/handler/admin/wechat_config.go` | 8 个 Handler 方法 |
|
||||||
|
| 路由 | `internal/routes/wechat_config.go` | 路由注册(含平台权限中间件) |
|
||||||
|
| SDK | `pkg/fuiou/types.go` | 富友 XML 结构体 |
|
||||||
|
| SDK | `pkg/fuiou/client.go` | 富友 HTTP 客户端、签名/验签 |
|
||||||
|
| SDK | `pkg/fuiou/wxprecreate.go` | 富友支付下单 |
|
||||||
|
| SDK | `pkg/fuiou/notify.go` | 富友回调验签 |
|
||||||
|
| 迁移 | `migrations/000078_create_wechat_config_table.up.sql` | 创建 tb_wechat_config 表 |
|
||||||
|
| 迁移 | `migrations/000079_add_payment_config_id_to_order.up.sql` | tb_order 新增字段 |
|
||||||
|
| 迁移 | `migrations/000080_add_payment_config_id_to_asset_recharge.up.sql` | tb_asset_recharge_record 新增字段 |
|
||||||
|
| 迁移 | `migrations/000081_add_payment_config_id_to_agent_recharge.up.sql` | tb_agent_recharge_record 新增字段 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `internal/model/order.go` | 新增 `PaymentConfigID *uint` 字段 |
|
||||||
|
| `internal/model/asset_wallet.go` | 新增 `PaymentConfigID *uint` 字段 |
|
||||||
|
| `internal/handler/callback/payment.go` | 支持富友回调 + 按订单前缀分发 + 按 payment_config_id 验签 |
|
||||||
|
| `internal/routes/order.go` | 新增 `/api/callback/fuiou-pay` 路由 |
|
||||||
|
| `internal/service/order/service.go` | 注入 wechatConfigService、下单时记录 payment_config_id |
|
||||||
|
| `internal/bootstrap/` 系列 | 注册 WechatConfigStore/Service/Handler |
|
||||||
|
| `cmd/api/docs.go` / `cmd/gendocs/main.go` | 注册 WechatConfigHandler |
|
||||||
|
|
||||||
|
### 删除/精简文件(YAML 支付方案遗留清理)
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `pkg/config/config.go` | 删除 `PaymentConfig` 结构体 + `WechatConfig.Payment` 字段 |
|
||||||
|
| `pkg/config/defaults/config.yaml` | 删除 `wechat.payment:` 整个配置节 |
|
||||||
|
| `pkg/wechat/config.go` | 删除 `NewPaymentApp()` 函数(YAML/CertPath 方式已被 DB Base64 方案替代) |
|
||||||
|
| `cmd/api/main.go` | 删除 `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关校验代码 |
|
||||||
|
|
||||||
|
## 常量定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/constants/wallet.go(Card* 重命名为 Asset*,旧名保留为废弃别名)
|
||||||
|
AssetWalletResourceTypeIotCard // 原 CardWalletResourceTypeIotCard
|
||||||
|
AssetWalletResourceTypeDevice // 原 CardWalletResourceTypeDevice
|
||||||
|
AssetRechargeOrderPrefix // "CRCH"(原 CardRechargeOrderPrefix)
|
||||||
|
AssetRechargeMinAmount // 最小充值金额(分)
|
||||||
|
AssetRechargeMaxAmount // 最大充值金额(分)
|
||||||
|
|
||||||
|
// pkg/constants/redis.go
|
||||||
|
RedisWechatConfigActiveKey() // "wechat:config:active"
|
||||||
|
|
||||||
|
// internal/model/wechat_config.go
|
||||||
|
ProviderTypeWechat = "wechat" // 微信直连
|
||||||
|
ProviderTypeFuiou = "fuiou" // 富友
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知限制(留桩)
|
||||||
|
|
||||||
|
以下功能本次**未实现**,待后续会话补全:
|
||||||
|
|
||||||
|
- **客户端支付发起**:`WechatPayJSAPI`、`WechatPayH5`、`FuiouPayJSAPI`、`FuiouPayMiniApp` 均为留桩(返回"暂未实现"错误或 TODO 注释),当前仍保留 `wechatPayment` 单例注入
|
||||||
|
- **OAuth 配置动态加载**:`OfficialAccountService` 仍从环境变量读取,`tb_wechat_config` 中的 `oa_*` 字段仅存储,待 H5/小程序重构时切换
|
||||||
|
|
||||||
|
## 部署注意事项
|
||||||
|
|
||||||
|
1. 执行数据库迁移(000078~000081)后,现有数据不受影响(新字段均为 nullable)
|
||||||
|
2. 原环境变量 `JUNHONG_WECHAT_PAYMENT_*` 系列已不再读取,可清理
|
||||||
|
3. 首次上线后,需要在管理后台手动创建并激活一个微信配置,否则第三方支付功能处于禁用状态(系统自动降级为仅支持钱包/线下支付)
|
||||||
6
go.mod
6
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/break/junhong_cmp_fiber
|
module github.com/break/junhong_cmp_fiber
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
||||||
@@ -20,6 +20,7 @@ require (
|
|||||||
github.com/xuri/excelize/v2 v2.8.1
|
github.com/xuri/excelize/v2 v2.8.1
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
|
golang.org/x/text v0.35.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
@@ -88,9 +89,8 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.40.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
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -298,15 +298,15 @@ golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
|||||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
||||||
H5Order: h5.NewOrderHandler(svc.Order),
|
H5Order: h5.NewOrderHandler(svc.Order),
|
||||||
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
|
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
|
||||||
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
|
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
|
||||||
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
||||||
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
|
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
|
||||||
@@ -57,5 +57,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||||
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
||||||
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
||||||
|
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||||
|
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ import (
|
|||||||
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
||||||
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||||
|
|
||||||
|
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||||
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||||
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
||||||
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
|
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
|
||||||
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
|
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
|
||||||
|
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type services struct {
|
type services struct {
|
||||||
@@ -82,6 +84,8 @@ type services struct {
|
|||||||
Asset *assetSvc.Service
|
Asset *assetSvc.Service
|
||||||
AssetWallet *assetWalletSvc.Service
|
AssetWallet *assetWalletSvc.Service
|
||||||
StopResumeService *iotCardSvc.StopResumeService
|
StopResumeService *iotCardSvc.StopResumeService
|
||||||
|
WechatConfig *wechatConfigSvc.Service
|
||||||
|
AgentRecharge *agentRechargeSvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func initServices(s *stores, deps *Dependencies) *services {
|
func initServices(s *stores, deps *Dependencies) *services {
|
||||||
@@ -93,6 +97,9 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
|
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
|
||||||
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
|
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
|
||||||
|
|
||||||
|
// 创建支付配置服务(Order 和 Recharge 依赖)
|
||||||
|
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger)
|
||||||
|
|
||||||
return &services{
|
return &services{
|
||||||
Account: account,
|
Account: account,
|
||||||
AccountAudit: accountAudit,
|
AccountAudit: accountAudit,
|
||||||
@@ -142,8 +149,8 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
|
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
|
||||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||||
PurchaseValidation: purchaseValidation,
|
PurchaseValidation: purchaseValidation,
|
||||||
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
||||||
Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
|
Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, deps.Logger),
|
||||||
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
||||||
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
||||||
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
|
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
|
||||||
@@ -153,5 +160,18 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||||
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||||
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
||||||
|
WechatConfig: wechatConfig,
|
||||||
|
AgentRecharge: agentRechargeSvc.New(
|
||||||
|
deps.DB,
|
||||||
|
s.AgentRecharge,
|
||||||
|
s.AgentWallet,
|
||||||
|
s.AgentWalletTransaction,
|
||||||
|
s.Shop,
|
||||||
|
s.Account,
|
||||||
|
wechatConfig,
|
||||||
|
accountAudit,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type stores struct {
|
|||||||
AssetWallet *postgres.AssetWalletStore
|
AssetWallet *postgres.AssetWalletStore
|
||||||
AssetWalletTransaction *postgres.AssetWalletTransactionStore
|
AssetWalletTransaction *postgres.AssetWalletTransactionStore
|
||||||
AssetRecharge *postgres.AssetRechargeStore
|
AssetRecharge *postgres.AssetRechargeStore
|
||||||
|
// 微信参数配置
|
||||||
|
WechatConfig *postgres.WechatConfigStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStores(deps *Dependencies) *stores {
|
func initStores(deps *Dependencies) *stores {
|
||||||
@@ -105,5 +107,6 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||||
AssetRecharge: postgres.NewAssetRechargeStore(deps.DB, deps.Redis),
|
AssetRecharge: postgres.NewAssetRechargeStore(deps.DB, deps.Redis),
|
||||||
|
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ type Handlers struct {
|
|||||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||||
Asset *admin.AssetHandler
|
Asset *admin.AssetHandler
|
||||||
AssetWallet *admin.AssetWalletHandler
|
AssetWallet *admin.AssetWalletHandler
|
||||||
|
WechatConfig *admin.WechatConfigHandler
|
||||||
|
AgentRecharge *admin.AgentRechargeHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middlewares 封装所有中间件
|
// Middlewares 封装所有中间件
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
|||||||
stores.PackageSeries,
|
stores.PackageSeries,
|
||||||
stores.PackageUsage,
|
stores.PackageUsage,
|
||||||
stores.Package,
|
stores.Package,
|
||||||
|
nil, // wechatConfigService: 超时取消不需要
|
||||||
nil, // wechatPayment: 超时取消不需要
|
nil, // wechatPayment: 超时取消不需要
|
||||||
nil, // queueClient: 超时取消不触发分佣
|
nil, // queueClient: 超时取消不触发分佣
|
||||||
deps.Logger,
|
deps.Logger,
|
||||||
|
|||||||
91
internal/handler/admin/agent_recharge.go
Normal file
91
internal/handler/admin/agent_recharge.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentRechargeHandler 代理预充值 Handler
|
||||||
|
type AgentRechargeHandler struct {
|
||||||
|
service *agentRechargeSvc.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentRechargeHandler 创建代理预充值 Handler
|
||||||
|
func NewAgentRechargeHandler(service *agentRechargeSvc.Service) *AgentRechargeHandler {
|
||||||
|
return &AgentRechargeHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建代理充值订单
|
||||||
|
// POST /api/admin/agent-recharges
|
||||||
|
func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateAgentRechargeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 查询代理充值订单列表
|
||||||
|
// GET /api/admin/agent-recharges
|
||||||
|
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.AgentRechargeListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, total, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 查询代理充值订单详情
|
||||||
|
// GET /api/admin/agent-recharges/:id
|
||||||
|
func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.GetByID(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflinePay 确认线下充值
|
||||||
|
// POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
func (h *AgentRechargeHandler) OfflinePay(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.AgentOfflinePayRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.OfflinePay(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
153
internal/handler/admin/wechat_config.go
Normal file
153
internal/handler/admin/wechat_config.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
wechatConfigService "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WechatConfigHandler 微信参数配置 HTTP 处理器
|
||||||
|
type WechatConfigHandler struct {
|
||||||
|
service *wechatConfigService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWechatConfigHandler 创建微信参数配置处理器实例
|
||||||
|
func NewWechatConfigHandler(service *wechatConfigService.Service) *WechatConfigHandler {
|
||||||
|
return &WechatConfigHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs
|
||||||
|
func (h *WechatConfigHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateWechatConfigRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 获取微信参数配置列表
|
||||||
|
// GET /api/admin/wechat-configs
|
||||||
|
func (h *WechatConfigHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.WechatConfigListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, total, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, configs, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取微信参数配置详情
|
||||||
|
// GET /api/admin/wechat-configs/:id
|
||||||
|
func (h *WechatConfigHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新微信参数配置
|
||||||
|
// PUT /api/admin/wechat-configs/:id
|
||||||
|
func (h *WechatConfigHandler) Update(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.UpdateWechatConfigRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Update(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除微信参数配置
|
||||||
|
// DELETE /api/admin/wechat-configs/:id
|
||||||
|
func (h *WechatConfigHandler) Delete(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate 激活微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs/:id/activate
|
||||||
|
func (h *WechatConfigHandler) Activate(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Activate(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 停用微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs/:id/deactivate
|
||||||
|
func (h *WechatConfigHandler) Deactivate(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Deactivate(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActive 获取当前生效的微信参数配置
|
||||||
|
// GET /api/admin/wechat-configs/active
|
||||||
|
func (h *WechatConfigHandler) GetActive(c *fiber.Ctx) error {
|
||||||
|
result, err := h.service.GetActiveConfigForAPI(c.UserContext())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return response.SuccessWithMessage(c, nil, "当前无生效的支付配置,仅支持钱包支付")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package callback
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -13,21 +16,34 @@ import (
|
|||||||
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PaymentHandler struct {
|
// AgentRechargeServiceInterface 代理充值服务接口
|
||||||
orderService *orderService.Service
|
type AgentRechargeServiceInterface interface {
|
||||||
rechargeService *rechargeService.Service
|
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error
|
||||||
wechatPayment wechat.PaymentServiceInterface
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPaymentHandler(orderService *orderService.Service, rechargeService *rechargeService.Service, wechatPayment wechat.PaymentServiceInterface) *PaymentHandler {
|
type PaymentHandler struct {
|
||||||
|
orderService *orderService.Service
|
||||||
|
rechargeService *rechargeService.Service
|
||||||
|
agentRechargeService AgentRechargeServiceInterface
|
||||||
|
wechatPayment wechat.PaymentServiceInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPaymentHandler(
|
||||||
|
orderService *orderService.Service,
|
||||||
|
rechargeService *rechargeService.Service,
|
||||||
|
agentRechargeService AgentRechargeServiceInterface,
|
||||||
|
wechatPayment wechat.PaymentServiceInterface,
|
||||||
|
) *PaymentHandler {
|
||||||
return &PaymentHandler{
|
return &PaymentHandler{
|
||||||
orderService: orderService,
|
orderService: orderService,
|
||||||
rechargeService: rechargeService,
|
rechargeService: rechargeService,
|
||||||
wechatPayment: wechatPayment,
|
agentRechargeService: agentRechargeService,
|
||||||
|
wechatPayment: wechatPayment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +63,23 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据订单号前缀判断订单类型
|
// TODO: 按 payment_config_id 加载配置验签(当前留桩,仍用 wechatPayment 单例验签)
|
||||||
if strings.HasPrefix(result.OutTradeNo, constants.RechargeOrderPrefix) {
|
|
||||||
// 充值订单回调
|
|
||||||
return h.rechargeService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat, result.TransactionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 套餐订单回调
|
// 按订单号前缀分发
|
||||||
return h.orderService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat)
|
outTradeNo := result.OutTradeNo
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(outTradeNo, "ORD"):
|
||||||
|
return h.orderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat)
|
||||||
|
case strings.HasPrefix(outTradeNo, constants.AssetRechargeOrderPrefix):
|
||||||
|
return h.rechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID)
|
||||||
|
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
|
||||||
|
if h.agentRechargeService != nil {
|
||||||
|
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("未知订单号前缀: %s", outTradeNo)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,6 +93,8 @@ type AlipayCallbackRequest struct {
|
|||||||
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
|
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlipayCallback 支付宝回调
|
||||||
|
// POST /api/callback/alipay
|
||||||
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
||||||
var req AlipayCallbackRequest
|
var req AlipayCallbackRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
@@ -80,18 +107,95 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
ctx := c.UserContext()
|
ctx := c.UserContext()
|
||||||
|
|
||||||
// 根据订单号前缀判断订单类型
|
// 按订单号前缀分发
|
||||||
if strings.HasPrefix(req.OrderNo, constants.RechargeOrderPrefix) {
|
switch {
|
||||||
// 充值订单回调
|
case strings.HasPrefix(req.OrderNo, "ORD"):
|
||||||
if err := h.rechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 套餐订单回调
|
|
||||||
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay); err != nil {
|
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(req.OrderNo, constants.AssetRechargeOrderPrefix):
|
||||||
|
if err := h.rechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(req.OrderNo, constants.AgentRechargeOrderPrefix):
|
||||||
|
if h.agentRechargeService != nil {
|
||||||
|
if err := h.agentRechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeInvalidParam, "未知订单号前缀")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendString("success")
|
return c.SendString("success")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FuiouPayCallback 富友支付回调
|
||||||
|
// POST /api/callback/fuiou-pay
|
||||||
|
func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 {
|
||||||
|
return errors.New(errors.CodeFuiouCallbackInvalid, "回调请求体为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// TODO: 按 payment_config_id 加载配置创建 fuiou.Client 验签
|
||||||
|
// 当前留桩:解析但不验签
|
||||||
|
|
||||||
|
// 解析 req= 参数
|
||||||
|
formValue := string(body)
|
||||||
|
if strings.HasPrefix(formValue, "req=") {
|
||||||
|
formValue = formValue[4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := url.QueryUnescape(formValue)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeFuiouCallbackInvalid, "回调数据解码失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
utf8Data, err := fuiou.GBKToUTF8([]byte(decoded))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeFuiouCallbackInvalid, "GBK 转 UTF-8 失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlStr := strings.Replace(string(utf8Data), `encoding="GBK"`, `encoding="UTF-8"`, 1)
|
||||||
|
|
||||||
|
var notify fuiou.NotifyRequest
|
||||||
|
if err := xml.Unmarshal([]byte(xmlStr), ¬ify); err != nil {
|
||||||
|
return errors.New(errors.CodeFuiouCallbackInvalid, "解析回调 XML 失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if notify.ResultCode != "000000" {
|
||||||
|
c.Set("Content-Type", "text/xml; charset=gbk")
|
||||||
|
return c.Send(fuiou.BuildNotifySuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按订单号前缀分发
|
||||||
|
orderNo := notify.MchntOrderNo
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(orderNo, "ORD"):
|
||||||
|
if err := h.orderService.HandlePaymentCallback(ctx, orderNo, "fuiou"); err != nil {
|
||||||
|
c.Set("Content-Type", "text/xml; charset=gbk")
|
||||||
|
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(orderNo, constants.AssetRechargeOrderPrefix):
|
||||||
|
if err := h.rechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
|
||||||
|
c.Set("Content-Type", "text/xml; charset=gbk")
|
||||||
|
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
|
||||||
|
if h.agentRechargeService != nil {
|
||||||
|
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
|
||||||
|
c.Set("Content-Type", "text/xml; charset=gbk")
|
||||||
|
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.Set("Content-Type", "text/xml; charset=gbk")
|
||||||
|
return c.Send(fuiou.BuildNotifyFailResponse("unknown order prefix"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Content-Type", "text/xml; charset=gbk")
|
||||||
|
return c.Send(fuiou.BuildNotifySuccessResponse())
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type AgentRechargeRecord struct {
|
|||||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
|
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
|
||||||
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
|
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
|
||||||
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
||||||
|
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
||||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type AssetRechargeRecord struct {
|
|||||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信)" json:"payment_method"`
|
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信)" json:"payment_method"`
|
||||||
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
|
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
|
||||||
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
||||||
|
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
||||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
||||||
|
|||||||
50
internal/model/dto/agent_recharge_dto.go
Normal file
50
internal/model/dto/agent_recharge_dto.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreateAgentRechargeRequest 创建代理充值请求
|
||||||
|
type CreateAgentRechargeRequest struct {
|
||||||
|
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"`
|
||||||
|
Amount int64 `json:"amount" validate:"required,min=10000,max=100000000" required:"true" minimum:"10000" maximum:"100000000" description:"充值金额(分),范围100元~100万元"`
|
||||||
|
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentOfflinePayRequest 代理线下充值确认请求
|
||||||
|
type AgentOfflinePayRequest struct {
|
||||||
|
OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRechargeResponse 代理充值记录响应
|
||||||
|
type AgentRechargeResponse struct {
|
||||||
|
ID uint `json:"id" description:"充值记录ID"`
|
||||||
|
RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"`
|
||||||
|
ShopID uint `json:"shop_id" description:"店铺ID"`
|
||||||
|
ShopName string `json:"shop_name" description:"店铺名称"`
|
||||||
|
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
|
||||||
|
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||||
|
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, offline:线下转账)"`
|
||||||
|
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
|
||||||
|
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"`
|
||||||
|
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
|
||||||
|
Status int `json:"status" description:"状态 (1:待支付, 2:已完成, 3:已取消)"`
|
||||||
|
PaidAt *string `json:"paid_at" description:"支付时间"`
|
||||||
|
CompletedAt *string `json:"completed_at" description:"完成时间"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRechargeListRequest 代理充值记录列表请求
|
||||||
|
type AgentRechargeListRequest struct {
|
||||||
|
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||||
|
ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"`
|
||||||
|
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已完成, 3:已取消)"`
|
||||||
|
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
|
||||||
|
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRechargeListResponse 代理充值记录列表响应
|
||||||
|
type AgentRechargeListResponse struct {
|
||||||
|
Total int64 `json:"total" description:"总记录数"`
|
||||||
|
Page int `json:"page" description:"当前页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页条数"`
|
||||||
|
List []*AgentRechargeResponse `json:"list" description:"充值记录列表"`
|
||||||
|
}
|
||||||
186
internal/model/dto/wechat_config_dto.go
Normal file
186
internal/model/dto/wechat_config_dto.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateWechatConfigRequest 创建微信参数配置请求
|
||||||
|
type CreateWechatConfigRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"`
|
||||||
|
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"`
|
||||||
|
ProviderType string `json:"provider_type" validate:"required,oneof=wechat fuiou" required:"true" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||||
|
|
||||||
|
OaAppID string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"`
|
||||||
|
OaAppSecret string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"`
|
||||||
|
OaToken string `json:"oa_token" validate:"omitempty,max=200" maxLength:"200" description:"公众号Token"`
|
||||||
|
OaAesKey string `json:"oa_aes_key" validate:"omitempty,max=200" maxLength:"200" description:"公众号AES加密Key"`
|
||||||
|
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" validate:"omitempty,max=500" maxLength:"500" description:"OAuth回调地址"`
|
||||||
|
|
||||||
|
MiniappAppID string `json:"miniapp_app_id" validate:"omitempty,max=100" maxLength:"100" description:"小程序AppID"`
|
||||||
|
MiniappAppSecret string `json:"miniapp_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"小程序AppSecret"`
|
||||||
|
|
||||||
|
WxMchID string `json:"wx_mch_id" validate:"omitempty,max=100" maxLength:"100" description:"微信商户号"`
|
||||||
|
WxAPIV3Key string `json:"wx_api_v3_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv3密钥"`
|
||||||
|
WxAPIV2Key string `json:"wx_api_v2_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv2密钥"`
|
||||||
|
WxCertContent string `json:"wx_cert_content" validate:"omitempty" description:"微信支付证书内容(PEM格式)"`
|
||||||
|
WxKeyContent string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"`
|
||||||
|
WxSerialNo string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"`
|
||||||
|
WxNotifyURL string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"`
|
||||||
|
|
||||||
|
FyInsCd string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"`
|
||||||
|
FyMchntCd string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"`
|
||||||
|
FyTermID string `json:"fy_term_id" validate:"omitempty,max=50" maxLength:"50" description:"富友终端号"`
|
||||||
|
FyPrivateKey string `json:"fy_private_key" validate:"omitempty" description:"富友私钥(PEM格式)"`
|
||||||
|
FyPublicKey string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"`
|
||||||
|
FyAPIURL string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"`
|
||||||
|
FyNotifyURL string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWechatConfigRequest 更新微信参数配置请求
|
||||||
|
type UpdateWechatConfigRequest struct {
|
||||||
|
Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"`
|
||||||
|
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"`
|
||||||
|
ProviderType *string `json:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||||
|
|
||||||
|
OaAppID *string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"`
|
||||||
|
OaAppSecret *string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"`
|
||||||
|
OaToken *string `json:"oa_token" validate:"omitempty,max=200" maxLength:"200" description:"公众号Token"`
|
||||||
|
OaAesKey *string `json:"oa_aes_key" validate:"omitempty,max=200" maxLength:"200" description:"公众号AES加密Key"`
|
||||||
|
OaOAuthRedirectURL *string `json:"oa_oauth_redirect_url" validate:"omitempty,max=500" maxLength:"500" description:"OAuth回调地址"`
|
||||||
|
|
||||||
|
MiniappAppID *string `json:"miniapp_app_id" validate:"omitempty,max=100" maxLength:"100" description:"小程序AppID"`
|
||||||
|
MiniappAppSecret *string `json:"miniapp_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"小程序AppSecret"`
|
||||||
|
|
||||||
|
WxMchID *string `json:"wx_mch_id" validate:"omitempty,max=100" maxLength:"100" description:"微信商户号"`
|
||||||
|
WxAPIV3Key *string `json:"wx_api_v3_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv3密钥"`
|
||||||
|
WxAPIV2Key *string `json:"wx_api_v2_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv2密钥"`
|
||||||
|
WxCertContent *string `json:"wx_cert_content" validate:"omitempty" description:"微信支付证书内容(PEM格式)"`
|
||||||
|
WxKeyContent *string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"`
|
||||||
|
WxSerialNo *string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"`
|
||||||
|
WxNotifyURL *string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"`
|
||||||
|
|
||||||
|
FyInsCd *string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"`
|
||||||
|
FyMchntCd *string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"`
|
||||||
|
FyTermID *string `json:"fy_term_id" validate:"omitempty,max=50" maxLength:"50" description:"富友终端号"`
|
||||||
|
FyPrivateKey *string `json:"fy_private_key" validate:"omitempty" description:"富友私钥(PEM格式)"`
|
||||||
|
FyPublicKey *string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"`
|
||||||
|
FyAPIURL *string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"`
|
||||||
|
FyNotifyURL *string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatConfigListRequest 微信参数配置列表查询请求
|
||||||
|
type WechatConfigListRequest struct {
|
||||||
|
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
|
ProviderType *string `json:"provider_type" query:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||||
|
IsActive *bool `json:"is_active" query:"is_active" description:"是否激活 (true:已激活, false:未激活)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatConfigResponse 微信参数配置响应
|
||||||
|
type WechatConfigResponse struct {
|
||||||
|
ID uint `json:"id" description:"配置ID"`
|
||||||
|
Name string `json:"name" description:"配置名称"`
|
||||||
|
Description string `json:"description" description:"配置描述"`
|
||||||
|
ProviderType string `json:"provider_type" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||||
|
IsActive bool `json:"is_active" description:"是否激活"`
|
||||||
|
|
||||||
|
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
|
||||||
|
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret(已脱敏)"`
|
||||||
|
OaToken string `json:"oa_token" description:"公众号Token(已脱敏)"`
|
||||||
|
OaAesKey string `json:"oa_aes_key" description:"公众号AES加密Key(已脱敏)"`
|
||||||
|
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"OAuth回调地址"`
|
||||||
|
|
||||||
|
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
|
||||||
|
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret(已脱敏)"`
|
||||||
|
|
||||||
|
WxMchID string `json:"wx_mch_id" description:"微信商户号"`
|
||||||
|
WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"`
|
||||||
|
WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"`
|
||||||
|
WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"`
|
||||||
|
WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"`
|
||||||
|
WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"`
|
||||||
|
WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"`
|
||||||
|
|
||||||
|
FyInsCd string `json:"fy_ins_cd" description:"富友机构号"`
|
||||||
|
FyMchntCd string `json:"fy_mchnt_cd" description:"富友商户号"`
|
||||||
|
FyTermID string `json:"fy_term_id" description:"富友终端号"`
|
||||||
|
FyPrivateKey string `json:"fy_private_key" description:"富友私钥(配置状态)"`
|
||||||
|
FyPublicKey string `json:"fy_public_key" description:"富友公钥(配置状态)"`
|
||||||
|
FyAPIURL string `json:"fy_api_url" description:"富友API地址"`
|
||||||
|
FyNotifyURL string `json:"fy_notify_url" description:"富友支付回调地址"`
|
||||||
|
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatConfigListResponse 微信参数配置列表响应
|
||||||
|
type WechatConfigListResponse struct {
|
||||||
|
List []*WechatConfigResponse `json:"list" description:"配置列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"当前页"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskShortSecret 对短密钥进行脱敏处理
|
||||||
|
// 长度小于 8 返回 "***",否则保留前4位和后4位
|
||||||
|
func MaskShortSecret(val string) string {
|
||||||
|
if len(val) < 8 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s***%s", val[:4], val[len(val)-4:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskLongSecret 对长密钥/证书进行脱敏处理
|
||||||
|
// 空值返回 "[未配置]",否则返回 "[已配置]"
|
||||||
|
func MaskLongSecret(val string) string {
|
||||||
|
if val == "" {
|
||||||
|
return "[未配置]"
|
||||||
|
}
|
||||||
|
return "[已配置]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromWechatConfigModel 从模型转换为响应 DTO,敏感字段自动脱敏
|
||||||
|
func FromWechatConfigModel(m *model.WechatConfig) *WechatConfigResponse {
|
||||||
|
resp := &WechatConfigResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
ProviderType: m.ProviderType,
|
||||||
|
IsActive: m.IsActive,
|
||||||
|
|
||||||
|
OaAppID: m.OaAppID,
|
||||||
|
OaAppSecret: MaskShortSecret(m.OaAppSecret),
|
||||||
|
OaToken: MaskShortSecret(m.OaToken),
|
||||||
|
OaAesKey: MaskShortSecret(m.OaAesKey),
|
||||||
|
OaOAuthRedirectURL: m.OaOAuthRedirectURL,
|
||||||
|
|
||||||
|
MiniappAppID: m.MiniappAppID,
|
||||||
|
MiniappAppSecret: MaskShortSecret(m.MiniappAppSecret),
|
||||||
|
|
||||||
|
WxMchID: m.WxMchID,
|
||||||
|
WxAPIV3Key: MaskShortSecret(m.WxAPIV3Key),
|
||||||
|
WxAPIV2Key: MaskShortSecret(m.WxAPIV2Key),
|
||||||
|
WxCertContent: MaskLongSecret(m.WxCertContent),
|
||||||
|
WxKeyContent: MaskLongSecret(m.WxKeyContent),
|
||||||
|
WxSerialNo: m.WxSerialNo,
|
||||||
|
WxNotifyURL: m.WxNotifyURL,
|
||||||
|
|
||||||
|
FyInsCd: m.FyInsCd,
|
||||||
|
FyMchntCd: m.FyMchntCd,
|
||||||
|
FyTermID: m.FyTermID,
|
||||||
|
FyPrivateKey: MaskLongSecret(m.FyPrivateKey),
|
||||||
|
FyPublicKey: MaskLongSecret(m.FyPublicKey),
|
||||||
|
FyAPIURL: m.FyAPIURL,
|
||||||
|
FyNotifyURL: m.FyNotifyURL,
|
||||||
|
|
||||||
|
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
UpdatedAt: m.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Description != nil {
|
||||||
|
resp.Description = *m.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ type Order struct {
|
|||||||
|
|
||||||
// 订单超时信息
|
// 订单超时信息
|
||||||
ExpiresAt *time.Time `gorm:"column:expires_at;comment:订单过期时间(NULL表示不过期)" json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `gorm:"column:expires_at;comment:订单过期时间(NULL表示不过期)" json:"expires_at,omitempty"`
|
||||||
|
|
||||||
|
// 支付配置
|
||||||
|
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
56
internal/model/wechat_config.go
Normal file
56
internal/model/wechat_config.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
// 支付渠道类型常量
|
||||||
|
const (
|
||||||
|
ProviderTypeWechat = "wechat" // 微信直连
|
||||||
|
ProviderTypeFuiou = "fuiou" // 富友
|
||||||
|
)
|
||||||
|
|
||||||
|
// WechatConfig 微信参数配置模型
|
||||||
|
// 管理微信公众号 OAuth、小程序、微信直连支付、富友支付等多套配置
|
||||||
|
// 同一时间只有一条记录处于 is_active=true(全局唯一生效配置)
|
||||||
|
type WechatConfig struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel `gorm:"embedded"`
|
||||||
|
|
||||||
|
Name string `gorm:"column:name;type:varchar(100);not null;comment:配置名称" json:"name"`
|
||||||
|
Description *string `gorm:"column:description;type:text;comment:配置描述" json:"description,omitempty"`
|
||||||
|
ProviderType string `gorm:"column:provider_type;type:varchar(20);not null;comment:支付渠道类型(wechat-微信直连,fuiou-富友)" json:"provider_type"`
|
||||||
|
IsActive bool `gorm:"column:is_active;type:boolean;not null;default:false;comment:是否激活(全局唯一)" json:"is_active"`
|
||||||
|
|
||||||
|
// OAuth 公众号
|
||||||
|
OaAppID string `gorm:"column:oa_app_id;type:varchar(100);not null;default:'';comment:公众号AppID" json:"oa_app_id"`
|
||||||
|
OaAppSecret string `gorm:"column:oa_app_secret;type:varchar(200);not null;default:'';comment:公众号AppSecret" json:"oa_app_secret"`
|
||||||
|
OaToken string `gorm:"column:oa_token;type:varchar(200);default:'';comment:公众号Token" json:"oa_token"`
|
||||||
|
OaAesKey string `gorm:"column:oa_aes_key;type:varchar(200);default:'';comment:公众号AES加密Key" json:"oa_aes_key"`
|
||||||
|
OaOAuthRedirectURL string `gorm:"column:oa_oauth_redirect_url;type:varchar(500);default:'';comment:OAuth回调地址" json:"oa_oauth_redirect_url"`
|
||||||
|
|
||||||
|
// OAuth 小程序
|
||||||
|
MiniappAppID string `gorm:"column:miniapp_app_id;type:varchar(100);default:'';comment:小程序AppID" json:"miniapp_app_id"`
|
||||||
|
MiniappAppSecret string `gorm:"column:miniapp_app_secret;type:varchar(200);default:'';comment:小程序AppSecret" json:"miniapp_app_secret"`
|
||||||
|
|
||||||
|
// 支付-微信直连
|
||||||
|
WxMchID string `gorm:"column:wx_mch_id;type:varchar(100);default:'';comment:微信商户号" json:"wx_mch_id"`
|
||||||
|
WxAPIV3Key string `gorm:"column:wx_api_v3_key;type:varchar(200);default:'';comment:微信APIv3密钥" json:"wx_api_v3_key"`
|
||||||
|
WxAPIV2Key string `gorm:"column:wx_api_v2_key;type:varchar(200);default:'';comment:微信APIv2密钥" json:"wx_api_v2_key"`
|
||||||
|
WxCertContent string `gorm:"column:wx_cert_content;type:text;default:'';comment:微信支付证书内容" json:"wx_cert_content"`
|
||||||
|
WxKeyContent string `gorm:"column:wx_key_content;type:text;default:'';comment:微信支付密钥内容" json:"wx_key_content"`
|
||||||
|
WxSerialNo string `gorm:"column:wx_serial_no;type:varchar(200);default:'';comment:微信证书序列号" json:"wx_serial_no"`
|
||||||
|
WxNotifyURL string `gorm:"column:wx_notify_url;type:varchar(500);default:'';comment:微信支付回调地址" json:"wx_notify_url"`
|
||||||
|
|
||||||
|
// 支付-富友
|
||||||
|
FyInsCd string `gorm:"column:fy_ins_cd;type:varchar(50);default:'';comment:富友机构号" json:"fy_ins_cd"`
|
||||||
|
FyMchntCd string `gorm:"column:fy_mchnt_cd;type:varchar(50);default:'';comment:富友商户号" json:"fy_mchnt_cd"`
|
||||||
|
FyTermID string `gorm:"column:fy_term_id;type:varchar(50);default:'';comment:富友终端号" json:"fy_term_id"`
|
||||||
|
FyPrivateKey string `gorm:"column:fy_private_key;type:text;default:'';comment:富友私钥" json:"fy_private_key"`
|
||||||
|
FyPublicKey string `gorm:"column:fy_public_key;type:text;default:'';comment:富友公钥" json:"fy_public_key"`
|
||||||
|
FyAPIURL string `gorm:"column:fy_api_url;type:varchar(500);default:'';comment:富友API地址" json:"fy_api_url"`
|
||||||
|
FyNotifyURL string `gorm:"column:fy_notify_url;type:varchar(500);default:'';comment:富友支付回调地址" json:"fy_notify_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (WechatConfig) TableName() string {
|
||||||
|
return "tb_wechat_config"
|
||||||
|
}
|
||||||
@@ -110,4 +110,10 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.Asset != nil {
|
if handlers.Asset != nil {
|
||||||
registerAssetRoutes(authGroup, handlers.Asset, handlers.AssetWallet, doc, basePath)
|
registerAssetRoutes(authGroup, handlers.Asset, handlers.AssetWallet, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.WechatConfig != nil {
|
||||||
|
registerWechatConfigRoutes(authGroup, handlers.WechatConfig, doc, basePath)
|
||||||
|
}
|
||||||
|
if handlers.AgentRecharge != nil {
|
||||||
|
registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
internal/routes/agent_recharge.go
Normal file
55
internal/routes/agent_recharge.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechargeHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
group := router.Group("/agent-recharges", func(c *fiber.Ctx) error {
|
||||||
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
|
if userType == constants.UserTypeEnterprise {
|
||||||
|
return errors.New(errors.CodeForbidden, "企业账号无权访问代理充值功能")
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
groupPath := basePath + "/agent-recharges"
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建代理充值订单",
|
||||||
|
Tags: []string{"代理预充值"},
|
||||||
|
Input: new(dto.CreateAgentRechargeRequest),
|
||||||
|
Output: new(dto.AgentRechargeResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||||
|
Summary: "查询代理充值订单列表",
|
||||||
|
Tags: []string{"代理预充值"},
|
||||||
|
Input: new(dto.AgentRechargeListRequest),
|
||||||
|
Output: new(dto.AgentRechargeListResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "查询代理充值订单详情",
|
||||||
|
Tags: []string{"代理预充值"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: new(dto.AgentRechargeResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "POST", "/:id/offline-pay", handler.OfflinePay, RouteSpec{
|
||||||
|
Summary: "确认线下充值",
|
||||||
|
Tags: []string{"代理预充值"},
|
||||||
|
Input: new(dto.AgentOfflinePayRequest),
|
||||||
|
Output: new(dto.AgentRechargeResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -121,4 +121,12 @@ func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.Paymen
|
|||||||
Output: nil,
|
Output: nil,
|
||||||
Auth: false,
|
Auth: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/fuiou-pay", handler.FuiouPayCallback, RouteSpec{
|
||||||
|
Summary: "富友支付回调",
|
||||||
|
Tags: []string{"支付回调"},
|
||||||
|
Input: nil,
|
||||||
|
Output: nil,
|
||||||
|
Auth: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
89
internal/routes/wechat_config.go
Normal file
89
internal/routes/wechat_config.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerWechatConfigRoutes(router fiber.Router, handler *admin.WechatConfigHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
// 平台用户权限中间件:仅超级管理员和平台用户可访问支付配置管理
|
||||||
|
group := router.Group("/wechat-configs", func(c *fiber.Ctx) error {
|
||||||
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
|
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限访问支付配置管理功能")
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
groupPath := basePath + "/wechat-configs"
|
||||||
|
|
||||||
|
// active 路由必须在 /:id 之前注册,否则 "active" 会被当作 id 解析
|
||||||
|
Register(group, doc, groupPath, "GET", "/active", handler.GetActive, RouteSpec{
|
||||||
|
Summary: "获取当前生效的支付配置",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: nil,
|
||||||
|
Output: new(dto.WechatConfigResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||||
|
Summary: "获取支付配置列表",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.WechatConfigListRequest),
|
||||||
|
Output: new(dto.WechatConfigListResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建支付配置",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.CreateWechatConfigRequest),
|
||||||
|
Output: new(dto.WechatConfigResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "获取支付配置详情",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: new(dto.WechatConfigResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
|
||||||
|
Summary: "更新支付配置",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.UpdateWechatConfigRequest),
|
||||||
|
Output: new(dto.WechatConfigResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||||
|
Summary: "删除支付配置",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "POST", "/:id/activate", handler.Activate, RouteSpec{
|
||||||
|
Summary: "激活支付配置",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: new(dto.WechatConfigResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(group, doc, groupPath, "POST", "/:id/deactivate", handler.Deactivate, RouteSpec{
|
||||||
|
Summary: "停用支付配置",
|
||||||
|
Tags: []string{"微信支付配置管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: new(dto.WechatConfigResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
506
internal/service/agent_recharge/service.go
Normal file
506
internal/service/agent_recharge/service.go
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
// Package agent_recharge 提供代理预充值的业务逻辑服务
|
||||||
|
// 包含充值订单创建、线下确认、支付回调处理、列表查询等功能
|
||||||
|
package agent_recharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditServiceInterface 审计日志服务接口
|
||||||
|
type AuditServiceInterface interface {
|
||||||
|
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatConfigServiceInterface 支付配置服务接口
|
||||||
|
type WechatConfigServiceInterface interface {
|
||||||
|
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 代理预充值业务服务
|
||||||
|
// 负责代理钱包充值订单的创建、线下确认、回调处理等业务逻辑
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
agentRechargeStore *postgres.AgentRechargeStore
|
||||||
|
agentWalletStore *postgres.AgentWalletStore
|
||||||
|
agentWalletTxStore *postgres.AgentWalletTransactionStore
|
||||||
|
shopStore *postgres.ShopStore
|
||||||
|
accountStore *postgres.AccountStore
|
||||||
|
wechatConfigService WechatConfigServiceInterface
|
||||||
|
auditService AuditServiceInterface
|
||||||
|
redis *redis.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建代理预充值服务实例
|
||||||
|
func New(
|
||||||
|
db *gorm.DB,
|
||||||
|
agentRechargeStore *postgres.AgentRechargeStore,
|
||||||
|
agentWalletStore *postgres.AgentWalletStore,
|
||||||
|
agentWalletTxStore *postgres.AgentWalletTransactionStore,
|
||||||
|
shopStore *postgres.ShopStore,
|
||||||
|
accountStore *postgres.AccountStore,
|
||||||
|
wechatConfigService WechatConfigServiceInterface,
|
||||||
|
auditService AuditServiceInterface,
|
||||||
|
rdb *redis.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
agentRechargeStore: agentRechargeStore,
|
||||||
|
agentWalletStore: agentWalletStore,
|
||||||
|
agentWalletTxStore: agentWalletTxStore,
|
||||||
|
shopStore: shopStore,
|
||||||
|
accountStore: accountStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
|
auditService: auditService,
|
||||||
|
redis: rdb,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建代理充值订单
|
||||||
|
// POST /api/admin/agent-recharges
|
||||||
|
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
|
||||||
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
userShopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
|
// 代理只能充自己店铺
|
||||||
|
if userType == constants.UserTypeAgent && req.ShopID != userShopID {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "代理只能为自己的店铺充值")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线下充值仅平台可用
|
||||||
|
if req.PaymentMethod == "offline" && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Amount < constants.AgentRechargeMinAmount || req.Amount > constants.AgentRechargeMaxAmount {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找目标店铺的主钱包
|
||||||
|
wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "目标店铺主钱包不存在")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询店铺名称
|
||||||
|
shop, err := s.shopStore.GetByID(ctx, req.ShopID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线支付需要查询生效的支付配置
|
||||||
|
var paymentConfigID *uint
|
||||||
|
var paymentChannel string
|
||||||
|
if req.PaymentMethod == "wechat" {
|
||||||
|
activeConfig, cfgErr := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if cfgErr != nil || activeConfig == nil {
|
||||||
|
return nil, errors.New(errors.CodeNoPaymentConfig, "当前无可用的支付配置,请联系管理员")
|
||||||
|
}
|
||||||
|
paymentConfigID = &activeConfig.ID
|
||||||
|
paymentChannel = activeConfig.ProviderType
|
||||||
|
} else {
|
||||||
|
paymentChannel = "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
rechargeNo := s.generateRechargeNo()
|
||||||
|
|
||||||
|
record := &model.AgentRechargeRecord{
|
||||||
|
UserID: userID,
|
||||||
|
AgentWalletID: wallet.ID,
|
||||||
|
ShopID: req.ShopID,
|
||||||
|
RechargeNo: rechargeNo,
|
||||||
|
Amount: req.Amount,
|
||||||
|
PaymentMethod: req.PaymentMethod,
|
||||||
|
PaymentChannel: &paymentChannel,
|
||||||
|
PaymentConfigID: paymentConfigID,
|
||||||
|
Status: constants.RechargeStatusPending,
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.agentRechargeStore.Create(ctx, record); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("创建代理充值订单成功",
|
||||||
|
zap.Uint("recharge_id", record.ID),
|
||||||
|
zap.String("recharge_no", rechargeNo),
|
||||||
|
zap.Int64("amount", req.Amount),
|
||||||
|
zap.Uint("shop_id", req.ShopID),
|
||||||
|
zap.Uint("user_id", userID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return toResponse(record, shop.ShopName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflinePay 线下充值确认
|
||||||
|
// POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOfflinePayRequest) (*dto.AgentRechargeResponse, error) {
|
||||||
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
|
||||||
|
// 仅平台账号可操作
|
||||||
|
if userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "仅平台管理员可确认线下充值")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证操作密码
|
||||||
|
account, err := s.accountStore.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询操作人账号失败")
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.OperationPassword)); err != nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "操作密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := s.agentRechargeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.PaymentMethod != "offline" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "该订单非线下充值,不支持此操作")
|
||||||
|
}
|
||||||
|
if record.Status != constants.RechargeStatusPending {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询钱包(事务内需要用到 version)
|
||||||
|
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 条件更新充值记录状态
|
||||||
|
result := tx.Model(&model.AgentRechargeRecord{}).
|
||||||
|
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": constants.RechargeStatusCompleted,
|
||||||
|
"paid_at": now,
|
||||||
|
"completed_at": now,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "充值记录状态已变更")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加钱包余额(乐观锁)
|
||||||
|
balanceResult := tx.Model(&model.AgentWallet{}).
|
||||||
|
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"balance": gorm.Expr("balance + ?", record.Amount),
|
||||||
|
"version": gorm.Expr("version + 1"),
|
||||||
|
})
|
||||||
|
if balanceResult.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
|
||||||
|
}
|
||||||
|
if balanceResult.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建钱包交易记录
|
||||||
|
remark := "线下充值确认"
|
||||||
|
refType := "topup"
|
||||||
|
txRecord := &model.AgentWalletTransaction{
|
||||||
|
AgentWalletID: wallet.ID,
|
||||||
|
ShopID: record.ShopID,
|
||||||
|
UserID: userID,
|
||||||
|
TransactionType: constants.AgentTransactionTypeRecharge,
|
||||||
|
Amount: record.Amount,
|
||||||
|
BalanceBefore: wallet.Balance,
|
||||||
|
BalanceAfter: wallet.Balance + record.Amount,
|
||||||
|
Status: constants.TransactionStatusSuccess,
|
||||||
|
ReferenceType: &refType,
|
||||||
|
ReferenceID: &record.ID,
|
||||||
|
Remark: &remark,
|
||||||
|
Creator: userID,
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
}
|
||||||
|
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步记录审计日志
|
||||||
|
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||||
|
OperatorID: userID,
|
||||||
|
OperatorType: userType,
|
||||||
|
OperationType: "offline_recharge_confirm",
|
||||||
|
OperationDesc: fmt.Sprintf("确认线下充值,充值单号: %s,金额: %d分", record.RechargeNo, record.Amount),
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
|
||||||
|
shopName := ""
|
||||||
|
if shop != nil {
|
||||||
|
shopName = shop.ShopName
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新本地对象以反映最新状态
|
||||||
|
record.Status = constants.RechargeStatusCompleted
|
||||||
|
record.PaidAt = &now
|
||||||
|
record.CompletedAt = &now
|
||||||
|
|
||||||
|
return toResponse(record, shopName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePaymentCallback 处理支付回调
|
||||||
|
// 幂等处理:status != 待支付则直接返回成功
|
||||||
|
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
|
||||||
|
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, rechargeNo)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "充值订单不存在")
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等检查
|
||||||
|
if record.Status != constants.RechargeStatusPending {
|
||||||
|
s.logger.Info("代理充值订单已处理,跳过",
|
||||||
|
zap.String("recharge_no", rechargeNo),
|
||||||
|
zap.Int("status", record.Status),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 条件更新(WHERE status = 1)
|
||||||
|
result := tx.Model(&model.AgentRechargeRecord{}).
|
||||||
|
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": constants.RechargeStatusCompleted,
|
||||||
|
"payment_transaction_id": paymentTransactionID,
|
||||||
|
"paid_at": now,
|
||||||
|
"completed_at": now,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加钱包余额(乐观锁)
|
||||||
|
balanceResult := tx.Model(&model.AgentWallet{}).
|
||||||
|
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"balance": gorm.Expr("balance + ?", record.Amount),
|
||||||
|
"version": gorm.Expr("version + 1"),
|
||||||
|
})
|
||||||
|
if balanceResult.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
|
||||||
|
}
|
||||||
|
if balanceResult.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易记录
|
||||||
|
remark := "在线支付充值"
|
||||||
|
refType := "topup"
|
||||||
|
txRecord := &model.AgentWalletTransaction{
|
||||||
|
AgentWalletID: wallet.ID,
|
||||||
|
ShopID: record.ShopID,
|
||||||
|
UserID: record.UserID,
|
||||||
|
TransactionType: constants.AgentTransactionTypeRecharge,
|
||||||
|
Amount: record.Amount,
|
||||||
|
BalanceBefore: wallet.Balance,
|
||||||
|
BalanceAfter: wallet.Balance + record.Amount,
|
||||||
|
Status: constants.TransactionStatusSuccess,
|
||||||
|
ReferenceType: &refType,
|
||||||
|
ReferenceID: &record.ID,
|
||||||
|
Remark: &remark,
|
||||||
|
Creator: record.UserID,
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
}
|
||||||
|
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("代理充值支付回调处理成功",
|
||||||
|
zap.String("recharge_no", rechargeNo),
|
||||||
|
zap.Int64("amount", record.Amount),
|
||||||
|
zap.Uint("shop_id", record.ShopID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据ID查询充值订单详情
|
||||||
|
// GET /api/admin/agent-recharges/:id
|
||||||
|
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
|
||||||
|
record, err := s.agentRechargeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
|
||||||
|
shopName := ""
|
||||||
|
if shop != nil {
|
||||||
|
shopName = shop.ShopName
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResponse(record, shopName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询充值订单列表
|
||||||
|
// GET /api/admin/agent-recharges
|
||||||
|
func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) ([]*dto.AgentRechargeResponse, int64, error) {
|
||||||
|
page := req.Page
|
||||||
|
pageSize := req.PageSize
|
||||||
|
if page == 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize == 0 {
|
||||||
|
pageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.AgentRechargeRecord{})
|
||||||
|
|
||||||
|
if req.ShopID != nil {
|
||||||
|
query = query.Where("shop_id = ?", *req.ShopID)
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
if req.StartDate != "" {
|
||||||
|
query = query.Where("created_at >= ?", req.StartDate+" 00:00:00")
|
||||||
|
}
|
||||||
|
if req.EndDate != "" {
|
||||||
|
query = query.Where("created_at <= ?", req.EndDate+" 23:59:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []*model.AgentRechargeRecord
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
|
||||||
|
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询店铺名称
|
||||||
|
shopIDs := make([]uint, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
shopIDs = append(shopIDs, r.ShopID)
|
||||||
|
}
|
||||||
|
shopMap := make(map[uint]string)
|
||||||
|
if len(shopIDs) > 0 {
|
||||||
|
shops, err := s.shopStore.GetByIDs(ctx, shopIDs)
|
||||||
|
if err == nil {
|
||||||
|
for _, sh := range shops {
|
||||||
|
shopMap[sh.ID] = sh.ShopName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]*dto.AgentRechargeResponse, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
list = append(list, toResponse(r, shopMap[r.ShopID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRechargeNo 生成代理充值订单号
|
||||||
|
// 格式: ARCH + 14位时间戳 + 6位随机数
|
||||||
|
func (s *Service) generateRechargeNo() string {
|
||||||
|
timestamp := time.Now().Format("20060102150405")
|
||||||
|
randomNum := rand.Intn(1000000)
|
||||||
|
return fmt.Sprintf("%s%s%06d", constants.AgentRechargeOrderPrefix, timestamp, randomNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toResponse 将模型转换为响应 DTO
|
||||||
|
func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse {
|
||||||
|
resp := &dto.AgentRechargeResponse{
|
||||||
|
ID: record.ID,
|
||||||
|
RechargeNo: record.RechargeNo,
|
||||||
|
ShopID: record.ShopID,
|
||||||
|
ShopName: shopName,
|
||||||
|
AgentWalletID: record.AgentWalletID,
|
||||||
|
Amount: record.Amount,
|
||||||
|
PaymentMethod: record.PaymentMethod,
|
||||||
|
Status: record.Status,
|
||||||
|
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.PaymentChannel != nil {
|
||||||
|
resp.PaymentChannel = *record.PaymentChannel
|
||||||
|
}
|
||||||
|
if record.PaymentConfigID != nil {
|
||||||
|
resp.PaymentConfigID = record.PaymentConfigID
|
||||||
|
}
|
||||||
|
if record.PaymentTransactionID != nil {
|
||||||
|
resp.PaymentTransactionID = *record.PaymentTransactionID
|
||||||
|
}
|
||||||
|
if record.PaidAt != nil {
|
||||||
|
t := record.PaidAt.Format("2006-01-02 15:04:05")
|
||||||
|
resp.PaidAt = &t
|
||||||
|
}
|
||||||
|
if record.CompletedAt != nil {
|
||||||
|
t := record.CompletedAt.Format("2006-01-02 15:04:05")
|
||||||
|
resp.CompletedAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WechatConfigServiceInterface 支付配置服务接口
|
||||||
|
// 用于查询当前生效的支付配置
|
||||||
|
type WechatConfigServiceInterface interface {
|
||||||
|
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
@@ -40,6 +46,7 @@ type Service struct {
|
|||||||
packageSeriesStore *postgres.PackageSeriesStore
|
packageSeriesStore *postgres.PackageSeriesStore
|
||||||
packageUsageStore *postgres.PackageUsageStore
|
packageUsageStore *postgres.PackageUsageStore
|
||||||
packageStore *postgres.PackageStore
|
packageStore *postgres.PackageStore
|
||||||
|
wechatConfigService WechatConfigServiceInterface
|
||||||
wechatPayment wechat.PaymentServiceInterface
|
wechatPayment wechat.PaymentServiceInterface
|
||||||
queueClient *queue.Client
|
queueClient *queue.Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
@@ -60,6 +67,7 @@ func New(
|
|||||||
packageSeriesStore *postgres.PackageSeriesStore,
|
packageSeriesStore *postgres.PackageSeriesStore,
|
||||||
packageUsageStore *postgres.PackageUsageStore,
|
packageUsageStore *postgres.PackageUsageStore,
|
||||||
packageStore *postgres.PackageStore,
|
packageStore *postgres.PackageStore,
|
||||||
|
wechatConfigService WechatConfigServiceInterface,
|
||||||
wechatPayment wechat.PaymentServiceInterface,
|
wechatPayment wechat.PaymentServiceInterface,
|
||||||
queueClient *queue.Client,
|
queueClient *queue.Client,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
@@ -79,6 +87,7 @@ func New(
|
|||||||
packageSeriesStore: packageSeriesStore,
|
packageSeriesStore: packageSeriesStore,
|
||||||
packageUsageStore: packageUsageStore,
|
packageUsageStore: packageUsageStore,
|
||||||
packageStore: packageStore,
|
packageStore: packageStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
wechatPayment: wechatPayment,
|
wechatPayment: wechatPayment,
|
||||||
queueClient: queueClient,
|
queueClient: queueClient,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -543,6 +552,18 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
|||||||
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
|
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询当前生效的支付配置
|
||||||
|
var paymentConfigID *uint
|
||||||
|
if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
|
||||||
|
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
if activeConfig != nil {
|
||||||
|
paymentConfigID = &activeConfig.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
order := &model.Order{
|
order := &model.Order{
|
||||||
BaseModel: model.BaseModel{
|
BaseModel: model.BaseModel{
|
||||||
Creator: userID,
|
Creator: userID,
|
||||||
@@ -568,6 +589,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
|||||||
OperatorType: operatorType,
|
OperatorType: operatorType,
|
||||||
ActualPaidAmount: actualPaidAmount,
|
ActualPaidAmount: actualPaidAmount,
|
||||||
PurchaseRole: purchaseRole,
|
PurchaseRole: purchaseRole,
|
||||||
|
PaymentConfigID: paymentConfigID,
|
||||||
}
|
}
|
||||||
|
|
||||||
items := s.buildOrderItems(userID, validationResult.Packages)
|
items := s.buildOrderItems(userID, validationResult.Packages)
|
||||||
@@ -775,6 +797,18 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询当前生效的支付配置
|
||||||
|
var h5PaymentConfigID *uint
|
||||||
|
if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
|
||||||
|
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
if activeConfig != nil {
|
||||||
|
h5PaymentConfigID = &activeConfig.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
order := &model.Order{
|
order := &model.Order{
|
||||||
BaseModel: model.BaseModel{
|
BaseModel: model.BaseModel{
|
||||||
Creator: userID,
|
Creator: userID,
|
||||||
@@ -801,6 +835,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
|||||||
ActualPaidAmount: actualPaidAmount,
|
ActualPaidAmount: actualPaidAmount,
|
||||||
PurchaseRole: purchaseRole,
|
PurchaseRole: purchaseRole,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
|
PaymentConfigID: h5PaymentConfigID,
|
||||||
}
|
}
|
||||||
|
|
||||||
items := s.buildOrderItems(userID, validationResult.Packages)
|
items := s.buildOrderItems(userID, validationResult.Packages)
|
||||||
@@ -2056,6 +2091,7 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WechatPayJSAPI 发起微信 JSAPI 支付
|
// WechatPayJSAPI 发起微信 JSAPI 支付
|
||||||
|
// TODO: 从 payment_config_id 加载配置动态创建 Payment 实例,替代 s.wechatPayment 单例
|
||||||
func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) {
|
func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) {
|
||||||
if s.wechatPayment == nil {
|
if s.wechatPayment == nil {
|
||||||
s.logger.Error("微信支付服务未配置")
|
s.logger.Error("微信支付服务未配置")
|
||||||
@@ -2111,6 +2147,7 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WechatPayH5 发起微信 H5 支付
|
// WechatPayH5 发起微信 H5 支付
|
||||||
|
// TODO: 从 payment_config_id 加载配置动态创建 Payment 实例,替代 s.wechatPayment 单例
|
||||||
func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.WechatH5SceneInfo, buyerType string, buyerID uint) (*dto.WechatPayH5Response, error) {
|
func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.WechatH5SceneInfo, buyerType string, buyerID uint) (*dto.WechatPayH5Response, error) {
|
||||||
if s.wechatPayment == nil {
|
if s.wechatPayment == nil {
|
||||||
s.logger.Error("微信支付服务未配置")
|
s.logger.Error("微信支付服务未配置")
|
||||||
@@ -2301,3 +2338,15 @@ func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRe
|
|||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FuiouPayJSAPI 富友公众号 JSAPI 支付发起(留桩)
|
||||||
|
// TODO: 实现富友支付发起逻辑
|
||||||
|
func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) error {
|
||||||
|
return errors.New(errors.CodeFuiouPayFailed, "富友支付发起暂未实现")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuiouPayMiniApp 富友小程序支付发起(留桩)
|
||||||
|
// TODO: 实现富友小程序支付发起逻辑
|
||||||
|
func (s *Service) FuiouPayMiniApp(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) error {
|
||||||
|
return errors.New(errors.CodeFuiouPayFailed, "富友小程序支付发起暂未实现")
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ type ForceRechargeRequirement struct {
|
|||||||
FirstCommissionPaid bool `json:"first_commission_paid"` // 一次性佣金是否已发放
|
FirstCommissionPaid bool `json:"first_commission_paid"` // 一次性佣金是否已发放
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WechatConfigServiceInterface 支付配置服务接口
|
||||||
|
type WechatConfigServiceInterface interface {
|
||||||
|
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Service 充值服务
|
// Service 充值服务
|
||||||
// 负责资产钱包(IoT卡/设备)的充值订单创建、预检、支付回调处理等业务逻辑
|
// 负责资产钱包(IoT卡/设备)的充值订单创建、预检、支付回调处理等业务逻辑
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -40,6 +45,7 @@ type Service struct {
|
|||||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||||
packageSeriesStore *postgres.PackageSeriesStore
|
packageSeriesStore *postgres.PackageSeriesStore
|
||||||
commissionRecordStore *postgres.CommissionRecordStore
|
commissionRecordStore *postgres.CommissionRecordStore
|
||||||
|
wechatConfigService WechatConfigServiceInterface
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +60,7 @@ func New(
|
|||||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||||
packageSeriesStore *postgres.PackageSeriesStore,
|
packageSeriesStore *postgres.PackageSeriesStore,
|
||||||
commissionRecordStore *postgres.CommissionRecordStore,
|
commissionRecordStore *postgres.CommissionRecordStore,
|
||||||
|
wechatConfigService WechatConfigServiceInterface,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
@@ -66,6 +73,7 @@ func New(
|
|||||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||||
packageSeriesStore: packageSeriesStore,
|
packageSeriesStore: packageSeriesStore,
|
||||||
commissionRecordStore: commissionRecordStore,
|
commissionRecordStore: commissionRecordStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +122,19 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, us
|
|||||||
// 4. 生成充值订单号
|
// 4. 生成充值订单号
|
||||||
rechargeNo := s.generateRechargeNo()
|
rechargeNo := s.generateRechargeNo()
|
||||||
|
|
||||||
// 5. 创建充值订单
|
// 5. 查询当前生效的支付配置
|
||||||
|
var paymentConfigID *uint
|
||||||
|
if req.PaymentMethod == "wechat" || req.PaymentMethod == "alipay" {
|
||||||
|
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
if activeConfig != nil {
|
||||||
|
paymentConfigID = &activeConfig.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 创建充值订单
|
||||||
recharge := &model.AssetRechargeRecord{
|
recharge := &model.AssetRechargeRecord{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AssetWalletID: wallet.ID,
|
AssetWalletID: wallet.ID,
|
||||||
@@ -123,6 +143,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, us
|
|||||||
RechargeNo: rechargeNo,
|
RechargeNo: rechargeNo,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
PaymentMethod: req.PaymentMethod,
|
PaymentMethod: req.PaymentMethod,
|
||||||
|
PaymentConfigID: paymentConfigID,
|
||||||
Status: constants.RechargeStatusPending,
|
Status: constants.RechargeStatusPending,
|
||||||
ShopIDTag: wallet.ShopIDTag,
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
@@ -247,6 +268,7 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID
|
|||||||
|
|
||||||
// HandlePaymentCallback 支付回调处理
|
// HandlePaymentCallback 支付回调处理
|
||||||
// 支持幂等性检查、事务处理、更新余额、触发佣金
|
// 支持幂等性检查、事务处理、更新余额、触发佣金
|
||||||
|
// TODO: 按 payment_config_id 加载配置验签(当前留桩,验签由外层处理)
|
||||||
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
|
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
|
||||||
// 1. 查询充值订单
|
// 1. 查询充值订单
|
||||||
recharge, err := s.assetRechargeStore.GetByRechargeNo(ctx, rechargeNo)
|
recharge, err := s.assetRechargeStore.GetByRechargeNo(ctx, rechargeNo)
|
||||||
|
|||||||
473
internal/service/wechat_config/service.go
Normal file
473
internal/service/wechat_config/service.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
// Package wechat_config 提供微信参数配置管理的业务逻辑服务
|
||||||
|
// 包含配置的 CRUD、激活/停用、Redis 缓存等功能
|
||||||
|
package wechat_config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Redis 缓存键
|
||||||
|
const redisActiveConfigKey = "wechat:config:active"
|
||||||
|
|
||||||
|
// AuditServiceInterface 审计日志服务接口
|
||||||
|
type AuditServiceInterface interface {
|
||||||
|
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 微信参数配置业务服务
|
||||||
|
type Service struct {
|
||||||
|
store *postgres.WechatConfigStore
|
||||||
|
orderStore *postgres.OrderStore
|
||||||
|
auditService AuditServiceInterface
|
||||||
|
redis *redis.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建微信参数配置服务实例
|
||||||
|
func New(store *postgres.WechatConfigStore, orderStore *postgres.OrderStore, auditService AuditServiceInterface, rdb *redis.Client, logger *zap.Logger) *Service {
|
||||||
|
return &Service{
|
||||||
|
store: store,
|
||||||
|
orderStore: orderStore,
|
||||||
|
auditService: auditService,
|
||||||
|
redis: rdb,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs
|
||||||
|
func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest) (*dto.WechatConfigResponse, error) {
|
||||||
|
// 根据 provider_type 校验必填字段
|
||||||
|
if err := s.validateProviderFields(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var desc *string
|
||||||
|
if req.Description != "" {
|
||||||
|
desc = &req.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &model.WechatConfig{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: desc,
|
||||||
|
ProviderType: req.ProviderType,
|
||||||
|
IsActive: false,
|
||||||
|
OaAppID: req.OaAppID,
|
||||||
|
OaAppSecret: req.OaAppSecret,
|
||||||
|
OaToken: req.OaToken,
|
||||||
|
OaAesKey: req.OaAesKey,
|
||||||
|
OaOAuthRedirectURL: req.OaOAuthRedirectURL,
|
||||||
|
MiniappAppID: req.MiniappAppID,
|
||||||
|
MiniappAppSecret: req.MiniappAppSecret,
|
||||||
|
WxMchID: req.WxMchID,
|
||||||
|
WxAPIV3Key: req.WxAPIV3Key,
|
||||||
|
WxAPIV2Key: req.WxAPIV2Key,
|
||||||
|
WxCertContent: req.WxCertContent,
|
||||||
|
WxKeyContent: req.WxKeyContent,
|
||||||
|
WxSerialNo: req.WxSerialNo,
|
||||||
|
WxNotifyURL: req.WxNotifyURL,
|
||||||
|
FyInsCd: req.FyInsCd,
|
||||||
|
FyMchntCd: req.FyMchntCd,
|
||||||
|
FyTermID: req.FyTermID,
|
||||||
|
FyPrivateKey: req.FyPrivateKey,
|
||||||
|
FyPublicKey: req.FyPublicKey,
|
||||||
|
FyAPIURL: req.FyAPIURL,
|
||||||
|
FyNotifyURL: req.FyNotifyURL,
|
||||||
|
}
|
||||||
|
config.Creator = middleware.GetUserIDFromContext(ctx)
|
||||||
|
|
||||||
|
if err := s.store.Create(ctx, config); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "创建微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
afterData := model.JSONB{
|
||||||
|
"id": config.ID,
|
||||||
|
"name": config.Name,
|
||||||
|
"provider_type": config.ProviderType,
|
||||||
|
}
|
||||||
|
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||||
|
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||||
|
OperatorName: "",
|
||||||
|
OperationType: "create",
|
||||||
|
OperationDesc: fmt.Sprintf("创建微信支付配置:%s", config.Name),
|
||||||
|
AfterData: afterData,
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dto.FromWechatConfigModel(config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 获取配置列表
|
||||||
|
// GET /api/admin/wechat-configs
|
||||||
|
func (s *Service) List(ctx context.Context, req *dto.WechatConfigListRequest) ([]*dto.WechatConfigResponse, int64, error) {
|
||||||
|
opts := &store.QueryOptions{
|
||||||
|
Page: req.Page,
|
||||||
|
PageSize: req.PageSize,
|
||||||
|
OrderBy: "id DESC",
|
||||||
|
}
|
||||||
|
if opts.Page == 0 {
|
||||||
|
opts.Page = 1
|
||||||
|
}
|
||||||
|
if opts.PageSize == 0 {
|
||||||
|
opts.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := make(map[string]interface{})
|
||||||
|
if req.ProviderType != nil {
|
||||||
|
filters["provider_type"] = *req.ProviderType
|
||||||
|
}
|
||||||
|
filters["is_active"] = req.IsActive
|
||||||
|
|
||||||
|
configs, total, err := s.store.List(ctx, opts, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询微信支付配置列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]*dto.WechatConfigResponse, len(configs))
|
||||||
|
for i, c := range configs {
|
||||||
|
responses[i] = dto.FromWechatConfigModel(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取配置详情
|
||||||
|
// GET /api/admin/wechat-configs/:id
|
||||||
|
func (s *Service) Get(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) {
|
||||||
|
config, err := s.store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeWechatConfigNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||||
|
}
|
||||||
|
return dto.FromWechatConfigModel(config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新微信参数配置
|
||||||
|
// PUT /api/admin/wechat-configs/:id
|
||||||
|
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConfigRequest) (*dto.WechatConfigResponse, error) {
|
||||||
|
config, err := s.store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeWechatConfigNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并字段:指针非 nil 时更新,敏感字段空字符串表示保持原值
|
||||||
|
if req.Name != nil {
|
||||||
|
config.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
config.Description = req.Description
|
||||||
|
}
|
||||||
|
if req.ProviderType != nil {
|
||||||
|
config.ProviderType = *req.ProviderType
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth 公众号
|
||||||
|
s.mergeStringField(&config.OaAppID, req.OaAppID)
|
||||||
|
s.mergeSensitiveField(&config.OaAppSecret, req.OaAppSecret)
|
||||||
|
s.mergeSensitiveField(&config.OaToken, req.OaToken)
|
||||||
|
s.mergeSensitiveField(&config.OaAesKey, req.OaAesKey)
|
||||||
|
s.mergeStringField(&config.OaOAuthRedirectURL, req.OaOAuthRedirectURL)
|
||||||
|
|
||||||
|
// OAuth 小程序
|
||||||
|
s.mergeStringField(&config.MiniappAppID, req.MiniappAppID)
|
||||||
|
s.mergeSensitiveField(&config.MiniappAppSecret, req.MiniappAppSecret)
|
||||||
|
|
||||||
|
// 微信直连支付
|
||||||
|
s.mergeStringField(&config.WxMchID, req.WxMchID)
|
||||||
|
s.mergeSensitiveField(&config.WxAPIV3Key, req.WxAPIV3Key)
|
||||||
|
s.mergeSensitiveField(&config.WxAPIV2Key, req.WxAPIV2Key)
|
||||||
|
s.mergeSensitiveField(&config.WxCertContent, req.WxCertContent)
|
||||||
|
s.mergeSensitiveField(&config.WxKeyContent, req.WxKeyContent)
|
||||||
|
s.mergeStringField(&config.WxSerialNo, req.WxSerialNo)
|
||||||
|
s.mergeStringField(&config.WxNotifyURL, req.WxNotifyURL)
|
||||||
|
|
||||||
|
// 富友支付
|
||||||
|
s.mergeStringField(&config.FyInsCd, req.FyInsCd)
|
||||||
|
s.mergeStringField(&config.FyMchntCd, req.FyMchntCd)
|
||||||
|
s.mergeStringField(&config.FyTermID, req.FyTermID)
|
||||||
|
s.mergeSensitiveField(&config.FyPrivateKey, req.FyPrivateKey)
|
||||||
|
s.mergeSensitiveField(&config.FyPublicKey, req.FyPublicKey)
|
||||||
|
s.mergeStringField(&config.FyAPIURL, req.FyAPIURL)
|
||||||
|
s.mergeStringField(&config.FyNotifyURL, req.FyNotifyURL)
|
||||||
|
|
||||||
|
config.Updater = middleware.GetUserIDFromContext(ctx)
|
||||||
|
|
||||||
|
if err := s.store.Update(ctx, config); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "更新微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前配置处于激活状态,清除缓存
|
||||||
|
if config.IsActive {
|
||||||
|
s.clearActiveConfigCache(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterData := model.JSONB{
|
||||||
|
"id": config.ID,
|
||||||
|
"name": config.Name,
|
||||||
|
"provider_type": config.ProviderType,
|
||||||
|
"is_active": config.IsActive,
|
||||||
|
}
|
||||||
|
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||||
|
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||||
|
OperatorName: "",
|
||||||
|
OperationType: "update",
|
||||||
|
OperationDesc: fmt.Sprintf("更新微信支付配置:%s", config.Name),
|
||||||
|
AfterData: afterData,
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dto.FromWechatConfigModel(config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除微信参数配置
|
||||||
|
// DELETE /api/admin/wechat-configs/:id
|
||||||
|
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||||
|
config, err := s.store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeWechatConfigNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不允许删除正在激活的配置
|
||||||
|
if config.IsActive {
|
||||||
|
return errors.New(errors.CodeWechatConfigActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在待支付订单
|
||||||
|
pendingOrders, err := s.store.CountPendingOrdersByConfigID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "检查在途订单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRecharges, err := s.store.CountPendingRechargesByConfigID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "检查在途充值失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pendingOrders > 0 || pendingRecharges > 0 {
|
||||||
|
return errors.New(errors.CodeWechatConfigHasPendingOrders)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.SoftDelete(ctx, id); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "删除微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clearActiveConfigCache(ctx)
|
||||||
|
|
||||||
|
beforeData := model.JSONB{
|
||||||
|
"id": config.ID,
|
||||||
|
"name": config.Name,
|
||||||
|
"provider_type": config.ProviderType,
|
||||||
|
}
|
||||||
|
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||||
|
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||||
|
OperatorName: "",
|
||||||
|
OperationType: "delete",
|
||||||
|
OperationDesc: fmt.Sprintf("删除微信支付配置:%s", config.Name),
|
||||||
|
BeforeData: beforeData,
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate 激活指定配置(同一时间只有一个激活配置)
|
||||||
|
// POST /api/admin/wechat-configs/:id/activate
|
||||||
|
func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) {
|
||||||
|
config, err := s.store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeWechatConfigNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录旧的激活配置名称
|
||||||
|
oldActiveName := ""
|
||||||
|
oldActive, oldErr := s.store.GetActive(ctx)
|
||||||
|
if oldErr == nil && oldActive != nil {
|
||||||
|
oldActiveName = oldActive.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事务内激活
|
||||||
|
db := s.store.DB()
|
||||||
|
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
return s.store.ActivateInTx(ctx, tx, id)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "激活微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clearActiveConfigCache(ctx)
|
||||||
|
|
||||||
|
// 重新查询最新状态
|
||||||
|
config, _ = s.store.GetByID(ctx, id)
|
||||||
|
|
||||||
|
desc := fmt.Sprintf("激活微信支付配置:%s", config.Name)
|
||||||
|
if oldActiveName != "" && oldActiveName != config.Name {
|
||||||
|
desc = fmt.Sprintf("激活微信支付配置:%s(原激活配置:%s)", config.Name, oldActiveName)
|
||||||
|
}
|
||||||
|
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||||
|
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||||
|
OperatorName: "",
|
||||||
|
OperationType: "activate",
|
||||||
|
OperationDesc: desc,
|
||||||
|
AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": true},
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dto.FromWechatConfigModel(config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 停用指定配置
|
||||||
|
// POST /api/admin/wechat-configs/:id/deactivate
|
||||||
|
func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) {
|
||||||
|
config, err := s.store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeWechatConfigNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.Deactivate(ctx, id); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "停用微信支付配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clearActiveConfigCache(ctx)
|
||||||
|
|
||||||
|
// 重新查询最新状态
|
||||||
|
config, _ = s.store.GetByID(ctx, id)
|
||||||
|
|
||||||
|
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||||
|
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||||
|
OperatorName: "",
|
||||||
|
OperationType: "deactivate",
|
||||||
|
OperationDesc: fmt.Sprintf("停用微信支付配置:%s", config.Name),
|
||||||
|
AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": false},
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dto.FromWechatConfigModel(config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveConfig 获取当前生效的支付配置(带 Redis 缓存)
|
||||||
|
// 缓存策略:命中直接返回,未命中查 DB 后缓存 5 分钟,无记录缓存 "none" 1 分钟
|
||||||
|
func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) {
|
||||||
|
// 尝试从 Redis 获取
|
||||||
|
val, err := s.redis.Get(ctx, redisActiveConfigKey).Result()
|
||||||
|
if err == nil {
|
||||||
|
if val == "none" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var config model.WechatConfig
|
||||||
|
if err := sonic.UnmarshalString(val, &config); err == nil {
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis 未命中,查询数据库
|
||||||
|
config, err := s.store.GetActive(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// 无激活配置,缓存空标记 1 分钟
|
||||||
|
s.redis.Set(ctx, redisActiveConfigKey, "none", 1*time.Minute)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询激活配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存配置 5 分钟
|
||||||
|
if data, err := sonic.MarshalString(config); err == nil {
|
||||||
|
s.redis.Set(ctx, redisActiveConfigKey, data, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveConfigForAPI 获取当前生效的支付配置(API 响应,已脱敏)
|
||||||
|
func (s *Service) GetActiveConfigForAPI(ctx context.Context) (*dto.WechatConfigResponse, error) {
|
||||||
|
config, err := s.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return dto.FromWechatConfigModel(config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearActiveConfigCache 清除激活配置的 Redis 缓存
|
||||||
|
func (s *Service) clearActiveConfigCache(ctx context.Context) {
|
||||||
|
if err := s.redis.Del(ctx, redisActiveConfigKey).Err(); err != nil {
|
||||||
|
s.logger.Warn("清除微信支付配置缓存失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateProviderFields 根据支付渠道类型校验必填字段
|
||||||
|
func (s *Service) validateProviderFields(req *dto.CreateWechatConfigRequest) error {
|
||||||
|
switch req.ProviderType {
|
||||||
|
case model.ProviderTypeWechat:
|
||||||
|
if req.WxMchID == "" || req.WxAPIV3Key == "" || req.WxCertContent == "" ||
|
||||||
|
req.WxKeyContent == "" || req.WxSerialNo == "" || req.WxNotifyURL == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "微信直连支付必填字段不完整:wx_mch_id, wx_api_v3_key, wx_cert_content, wx_key_content, wx_serial_no, wx_notify_url")
|
||||||
|
}
|
||||||
|
case model.ProviderTypeFuiou:
|
||||||
|
if req.FyInsCd == "" || req.FyMchntCd == "" || req.FyTermID == "" ||
|
||||||
|
req.FyPrivateKey == "" || req.FyPublicKey == "" || req.FyAPIURL == "" || req.FyNotifyURL == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "富友支付必填字段不完整:fy_ins_cd, fy_mchnt_cd, fy_term_id, fy_private_key, fy_public_key, fy_api_url, fy_notify_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeStringField 合并普通字符串字段:指针非 nil 时用新值覆盖
|
||||||
|
func (s *Service) mergeStringField(target *string, newVal *string) {
|
||||||
|
if newVal != nil {
|
||||||
|
*target = *newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeSensitiveField 合并敏感字段:指针非 nil 且非空字符串时覆盖,空字符串保持原值
|
||||||
|
func (s *Service) mergeSensitiveField(target *string, newVal *string) {
|
||||||
|
if newVal != nil && *newVal != "" {
|
||||||
|
*target = *newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
145
internal/store/postgres/wechat_config_store.go
Normal file
145
internal/store/postgres/wechat_config_store.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WechatConfigStore 微信参数配置数据访问层
|
||||||
|
type WechatConfigStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWechatConfigStore 创建微信参数配置 Store 实例
|
||||||
|
func NewWechatConfigStore(db *gorm.DB, rdb *redis.Client) *WechatConfigStore {
|
||||||
|
return &WechatConfigStore{db: db, rdb: rdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建微信参数配置
|
||||||
|
func (s *WechatConfigStore) Create(ctx context.Context, config *model.WechatConfig) error {
|
||||||
|
return s.db.WithContext(ctx).Create(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取配置(遵循软删除)
|
||||||
|
func (s *WechatConfigStore) GetByID(ctx context.Context, id uint) (*model.WechatConfig, error) {
|
||||||
|
var config model.WechatConfig
|
||||||
|
if err := s.db.WithContext(ctx).First(&config, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByIDUnscoped 根据 ID 获取配置(包含已软删除的记录,用于回调处理)
|
||||||
|
func (s *WechatConfigStore) GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error) {
|
||||||
|
var config model.WechatConfig
|
||||||
|
if err := s.db.WithContext(ctx).Unscoped().First(&config, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 查询配置列表,支持按 provider_type 和 is_active 过滤
|
||||||
|
func (s *WechatConfigStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.WechatConfig, int64, error) {
|
||||||
|
var configs []*model.WechatConfig
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.WechatConfig{})
|
||||||
|
|
||||||
|
if providerType, ok := filters["provider_type"].(string); ok && providerType != "" {
|
||||||
|
query = query.Where("provider_type = ?", providerType)
|
||||||
|
}
|
||||||
|
if isActive, ok := filters["is_active"].(*bool); ok && isActive != nil {
|
||||||
|
query = query.Where("is_active = ?", *isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts == nil {
|
||||||
|
opts = store.DefaultQueryOptions()
|
||||||
|
}
|
||||||
|
offset := (opts.Page - 1) * opts.PageSize
|
||||||
|
query = query.Offset(offset).Limit(opts.PageSize)
|
||||||
|
|
||||||
|
if opts.OrderBy != "" {
|
||||||
|
query = query.Order(opts.OrderBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&configs).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新微信参数配置
|
||||||
|
func (s *WechatConfigStore) Update(ctx context.Context, config *model.WechatConfig) error {
|
||||||
|
return s.db.WithContext(ctx).Save(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete 软删除微信参数配置
|
||||||
|
func (s *WechatConfigStore) SoftDelete(ctx context.Context, id uint) error {
|
||||||
|
return s.db.WithContext(ctx).Delete(&model.WechatConfig{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActive 获取当前激活的配置
|
||||||
|
// 返回 gorm.ErrRecordNotFound 如果没有激活的配置
|
||||||
|
func (s *WechatConfigStore) GetActive(ctx context.Context) (*model.WechatConfig, error) {
|
||||||
|
var config model.WechatConfig
|
||||||
|
if err := s.db.WithContext(ctx).Where("is_active = ? AND deleted_at IS NULL", true).First(&config).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateInTx 在事务内激活指定配置(先停用所有,再激活指定记录)
|
||||||
|
func (s *WechatConfigStore) ActivateInTx(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||||
|
// 先停用所有激活的配置
|
||||||
|
if err := tx.WithContext(ctx).Model(&model.WechatConfig{}).
|
||||||
|
Where("is_active = ?", true).
|
||||||
|
Update("is_active", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 再激活指定配置
|
||||||
|
return tx.WithContext(ctx).Model(&model.WechatConfig{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("is_active", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 返回底层数据库连接,用于事务操作
|
||||||
|
func (s *WechatConfigStore) DB() *gorm.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 停用指定配置
|
||||||
|
func (s *WechatConfigStore) Deactivate(ctx context.Context, id uint) error {
|
||||||
|
return s.db.WithContext(ctx).Model(&model.WechatConfig{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("is_active", false).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPendingOrdersByConfigID 统计指定配置的待支付订单数
|
||||||
|
func (s *WechatConfigStore) CountPendingOrdersByConfigID(ctx context.Context, configID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("tb_order").
|
||||||
|
Where("payment_config_id = ? AND payment_status = ? AND deleted_at IS NULL", configID, 1).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPendingRechargesByConfigID 统计指定配置的待支付充值记录数
|
||||||
|
func (s *WechatConfigStore) CountPendingRechargesByConfigID(ctx context.Context, configID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("tb_asset_recharge_record").
|
||||||
|
Where("payment_config_id = ? AND status = ? AND deleted_at IS NULL", configID, 1).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
1
migrations/000078_create_wechat_config_table.down.sql
Normal file
1
migrations/000078_create_wechat_config_table.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS tb_wechat_config;
|
||||||
53
migrations/000078_create_wechat_config_table.up.sql
Normal file
53
migrations/000078_create_wechat_config_table.up.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- 创建微信参数配置表
|
||||||
|
CREATE TABLE tb_wechat_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
provider_type VARCHAR(20) NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- OAuth 公众号
|
||||||
|
oa_app_id VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
oa_app_secret VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
oa_token VARCHAR(200) DEFAULT '',
|
||||||
|
oa_aes_key VARCHAR(200) DEFAULT '',
|
||||||
|
oa_oauth_redirect_url VARCHAR(500) DEFAULT '',
|
||||||
|
|
||||||
|
-- OAuth 小程序
|
||||||
|
miniapp_app_id VARCHAR(100) DEFAULT '',
|
||||||
|
miniapp_app_secret VARCHAR(200) DEFAULT '',
|
||||||
|
|
||||||
|
-- 支付-微信直连
|
||||||
|
wx_mch_id VARCHAR(100) DEFAULT '',
|
||||||
|
wx_api_v3_key VARCHAR(200) DEFAULT '',
|
||||||
|
wx_api_v2_key VARCHAR(200) DEFAULT '',
|
||||||
|
wx_cert_content TEXT DEFAULT '',
|
||||||
|
wx_key_content TEXT DEFAULT '',
|
||||||
|
wx_serial_no VARCHAR(200) DEFAULT '',
|
||||||
|
wx_notify_url VARCHAR(500) DEFAULT '',
|
||||||
|
|
||||||
|
-- 支付-富友
|
||||||
|
fy_ins_cd VARCHAR(50) DEFAULT '',
|
||||||
|
fy_mchnt_cd VARCHAR(50) DEFAULT '',
|
||||||
|
fy_term_id VARCHAR(50) DEFAULT '',
|
||||||
|
fy_private_key TEXT DEFAULT '',
|
||||||
|
fy_public_key TEXT DEFAULT '',
|
||||||
|
fy_api_url VARCHAR(500) DEFAULT '',
|
||||||
|
fy_notify_url VARCHAR(500) DEFAULT '',
|
||||||
|
|
||||||
|
-- 审计字段
|
||||||
|
creator BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updater BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wechat_config_is_active ON tb_wechat_config(is_active) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_wechat_config_provider_type ON tb_wechat_config(provider_type) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_wechat_config_deleted_at ON tb_wechat_config(deleted_at);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tb_wechat_config IS '微信参数配置表';
|
||||||
|
COMMENT ON COLUMN tb_wechat_config.name IS '配置名称';
|
||||||
|
COMMENT ON COLUMN tb_wechat_config.provider_type IS '支付渠道类型: wechat-微信直连, fuiou-富友';
|
||||||
|
COMMENT ON COLUMN tb_wechat_config.is_active IS '是否激活(全局唯一)';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_order_payment_config_id;
|
||||||
|
ALTER TABLE tb_order DROP COLUMN IF EXISTS payment_config_id;
|
||||||
4
migrations/000079_add_payment_config_id_to_order.up.sql
Normal file
4
migrations/000079_add_payment_config_id_to_order.up.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- tb_order 新增 payment_config_id 列
|
||||||
|
ALTER TABLE tb_order ADD COLUMN payment_config_id BIGINT;
|
||||||
|
COMMENT ON COLUMN tb_order.payment_config_id IS '支付配置ID(关联tb_wechat_config.id)';
|
||||||
|
CREATE INDEX idx_order_payment_config_id ON tb_order(payment_config_id) WHERE payment_config_id IS NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_asset_recharge_payment_config_id;
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS payment_config_id;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- tb_asset_recharge_record 新增 payment_config_id 列
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN payment_config_id BIGINT;
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.payment_config_id IS '支付配置ID(关联tb_wechat_config.id)';
|
||||||
|
CREATE INDEX idx_asset_recharge_payment_config_id ON tb_asset_recharge_record(payment_config_id) WHERE payment_config_id IS NOT NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 回滚:删除代理充值记录的支付配置ID字段
|
||||||
|
DROP INDEX IF EXISTS idx_agent_recharge_payment_config_id;
|
||||||
|
ALTER TABLE tb_agent_recharge_record DROP COLUMN IF EXISTS payment_config_id;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 为代理充值记录添加支付配置ID字段
|
||||||
|
ALTER TABLE tb_agent_recharge_record ADD COLUMN payment_config_id BIGINT;
|
||||||
|
COMMENT ON COLUMN tb_agent_recharge_record.payment_config_id IS '支付配置ID(关联tb_wechat_config.id)';
|
||||||
|
CREATE INDEX idx_agent_recharge_payment_config_id ON tb_agent_recharge_record(payment_config_id) WHERE payment_config_id IS NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-16
|
||||||
364
openspec/changes/add-payment-config-management/design.md
Normal file
364
openspec/changes/add-payment-config-management/design.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
当前系统通过环境变量配置微信参数(`pkg/config/config.go` 中的 `WechatConfig` 结构体,包含 `OfficialAccountConfig` 和 `PaymentConfig`),在启动时创建全局单例 `PaymentService`,注入到 `order.Service` 中使用。这种模式无法动态切换支付凭证,也不支持富友支付渠道。
|
||||||
|
|
||||||
|
公众号 OAuth 配置同样硬编码在环境变量中,与支付配置相互独立。业务上,一套"微信身份"包含公众号 AppID、小程序 AppID 和支付凭证,三者必须原子性切换,否则会出现 OpenID 与 AppID 不匹配的问题。
|
||||||
|
|
||||||
|
代理充值模块目前只有 Store 层(`internal/store/postgres/agent_recharge_store.go`),缺少 Service 层和 Handler 层,无法通过 API 发起充值。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 在管理后台 CRUD 管理多套微信配置(OAuth + 支付),支持微信直连和富友两种渠道
|
||||||
|
- 全局唯一激活约束:任意时刻最多一个配置生效
|
||||||
|
- 配置切换秒级生效,不影响在途支付
|
||||||
|
- 无生效配置时系统降级为仅支持钱包/线下支付
|
||||||
|
- 接入富友支付的公众号 JSAPI + 小程序支付能力
|
||||||
|
- 支付回调按订单关联的 `payment_config_id` 验签,解决切换期间的竞态问题
|
||||||
|
- 代理充值完整实现 Service/Handler/回调
|
||||||
|
- 所有配置操作记录审计日志
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不支持同时激活多个配置进行负载均衡或路由
|
||||||
|
- 不支持支付宝直连(现有支付宝代码保留不改造)
|
||||||
|
- 不加密存储敏感字段(明文存储,接口返回时脱敏)
|
||||||
|
- 不自动检测配置有效性(如证书过期、商户号封禁)
|
||||||
|
- 不实现富友退款功能(后续按需扩展)
|
||||||
|
- 不动态加载 OAuth 配置(本次只存不用,`OfficialAccountService` 暂时继续从环境变量读取)
|
||||||
|
- 不实现客户端支付发起(留桩,另一个会话讨论)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: 表名和提案名称
|
||||||
|
|
||||||
|
**选择:`tb_wechat_config`(非 `tb_payment_config`)**
|
||||||
|
|
||||||
|
OAuth 配置纳入管理后,每套配置代表一套完整的微信身份(公众号 + 小程序 + 支付),切换配置等于原子性切换一切。用 `payment_config` 命名会遗漏 OAuth 语义,用 `wechat_config` 更准确地反映其职责范围。
|
||||||
|
|
||||||
|
### Decision 2: 扁平字段按前缀分组
|
||||||
|
|
||||||
|
**选择:扁平字段,按前缀分组(`oa_` / `miniapp_` / `wx_` / `fy_`)**
|
||||||
|
|
||||||
|
替代方案为 JSONB 字段存储不同渠道的参数。选择扁平字段的理由:系统使用者为非技术人员,前端表单可直接映射字段,无需 JSON 编辑器;字段级别的 NOT NULL 约束和验证更明确;不适用的字段留空即可(`provider_type=wechat` 时 `fy_*` 字段全部为空)。
|
||||||
|
|
||||||
|
### Decision 3: Payment 实例生命周期管理
|
||||||
|
|
||||||
|
**选择:按需创建 + Service 层缓存,配置变更时清除**
|
||||||
|
|
||||||
|
- 替代方案 A:启动时创建单例(当前方案)—— 无法动态切换
|
||||||
|
- 替代方案 B:每次支付请求都创建新实例 —— 性能浪费
|
||||||
|
- 选择方案:`PaymentConfigService` 维护当前生效配置的 Payment 实例内存缓存,配置切换时清除缓存,下次请求时按新配置重建实例
|
||||||
|
|
||||||
|
**「留桩」期间的过渡说明:**
|
||||||
|
本次实现后,`WechatPayJSAPI`/`WechatPayH5` 方法加 TODO 注释但保留对 `s.wechatPayment` 单例的调用。这意味着在「留桩」期间:
|
||||||
|
- `internal/bootstrap/dependencies.go` 的 `WechatPayment` 字段**暂时保留**
|
||||||
|
- `internal/bootstrap/services.go` 和 `handlers.go` 的 `deps.WechatPayment` 注入**暂时保留**
|
||||||
|
- 等 WechatPayJSAPI/WechatPayH5 完成动态加载改造后,再统一删除上述字段和注入点
|
||||||
|
|
||||||
|
**回调(callback)不属于留桩范围**:任务 1.6.1 的「留桩」仅指验签逻辑,回调的订单分发(按 payment_config_id 路由)必须完整实现,详见 Decision 5。
|
||||||
|
|
||||||
|
### Decision 4: 生效配置缓存策略
|
||||||
|
|
||||||
|
**选择:Redis 缓存 + 主动失效**
|
||||||
|
|
||||||
|
- 缓存 Key:**`wechat:config:active`**(全项目统一使用此命名,spec 文档中任何地方写 `payment:config:active` 均为笔误,以本文档为准),存储完整配置 JSON,TTL 5 分钟
|
||||||
|
- 主动失效:激活/停用/更新/删除配置时主动 DEL 缓存
|
||||||
|
- 读取流程:Redis GET → 命中返回 → MISS 则查 DB → SET 缓存
|
||||||
|
- 空标记:无配置时缓存 `"none"` TTL 1 分钟,防止缓存穿透
|
||||||
|
- Redis Key 定义在 `pkg/constants/redis.go`:`RedisWechatConfigActiveKey()`
|
||||||
|
|
||||||
|
### Decision 5: 配置切换时在途订单处理
|
||||||
|
|
||||||
|
**选择:不取消在途订单,自然完成或过期**
|
||||||
|
|
||||||
|
替代方案为切换时批量取消所有待支付的第三方订单,但用户可能已拉起支付正在输密码,取消订单会导致钱扣了但订单已取消,需要人工退款。
|
||||||
|
|
||||||
|
实现方式:
|
||||||
|
1. `tb_order` 新增 `payment_config_id` 字段(nullable,钱包/线下支付不需要)
|
||||||
|
2. `tb_asset_recharge_record` 新增 `payment_config_id` 字段
|
||||||
|
3. `tb_agent_recharge_record` 新增 `payment_config_id` 字段
|
||||||
|
4. 下单时记录当前使用的配置 ID
|
||||||
|
5. 回调处理时,按 `payment_config_id` 加载对应配置进行验签
|
||||||
|
6. 未支付的旧订单靠现有的 30 分钟超时自动取消机制清理
|
||||||
|
7. 有待支付订单引用的配置不允许删除(软删除后仍可用于回调验签)
|
||||||
|
|
||||||
|
### Decision 6: 富友支付接入方案
|
||||||
|
|
||||||
|
**选择:基于 `wxPreCreate` 接口,支持公众号 JSAPI 和小程序支付**
|
||||||
|
|
||||||
|
- 接口地址:`POST /wxPreCreate`(生产地址从配置读取)
|
||||||
|
- 公众号 JSAPI:`trade_type=JSAPI`,`sub_appid=公众号AppID`,`sub_openid=用户公众号OpenID`
|
||||||
|
- 小程序支付:`trade_type=LETPAY`,`sub_appid=小程序AppID`,`sub_openid=用户小程序OpenID`
|
||||||
|
- 签名算法:RSA + MD5(字典序排列参数 → GBK 编码 → MD5 哈希 → RSA 签名 → Base64)
|
||||||
|
- 通信协议:XML + GBK 编码 + 双重 URL 编码
|
||||||
|
- 回调处理:GBK → UTF-8 转换 → XML 解析 → RSA 验签
|
||||||
|
- 代码组织:`pkg/fuiou/` 包,从 cc-coding 项目移植核心逻辑,适配本项目的日志和错误处理规范
|
||||||
|
|
||||||
|
### Decision 7: 模块分层设计
|
||||||
|
|
||||||
|
**遵循 Handler → Service → Store → Model 分层:**
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
├── model/
|
||||||
|
│ ├── wechat_config.go # WechatConfig 模型 + 常量
|
||||||
|
│ └── dto/
|
||||||
|
│ ├── wechat_config_dto.go # 配置管理 DTO
|
||||||
|
│ └── agent_recharge_dto.go # 代理充值 DTO
|
||||||
|
├── store/postgres/
|
||||||
|
│ ├── wechat_config_store.go # CRUD + 激活/停用
|
||||||
|
│ └── agent_recharge_store.go # 已有,需扩展
|
||||||
|
├── service/
|
||||||
|
│ ├── wechat_config/service.go # 配置管理业务逻辑
|
||||||
|
│ └── agent_recharge/service.go # 代理充值业务逻辑(新建)
|
||||||
|
├── handler/
|
||||||
|
│ ├── admin/wechat_config.go # 配置管理 Handler
|
||||||
|
│ ├── admin/agent_recharge.go # 代理充值 Handler(新建)
|
||||||
|
│ └── callback/payment.go # 改造:支持富友回调 + 按配置验签
|
||||||
|
├── routes/
|
||||||
|
│ ├── wechat_config.go # 配置管理路由
|
||||||
|
│ └── agent_recharge.go # 代理充值路由(新建)
|
||||||
|
└── bootstrap/ # 注册新模块
|
||||||
|
|
||||||
|
pkg/
|
||||||
|
└── fuiou/
|
||||||
|
├── client.go # 富友 HTTP 客户端
|
||||||
|
└── types.go # 请求/响应结构体
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision 8: 接口脱敏策略
|
||||||
|
|
||||||
|
**敏感字段在 API 响应中脱敏,数据库明文存储:**
|
||||||
|
|
||||||
|
| 字段类型 | 脱敏规则 | 示例 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| Secret/Key(短) | 显示前4后4,中间 `***` | `secr****7890` |
|
||||||
|
| 证书/私钥(长) | 仅显示状态 | `[已配置]` / `[未配置]` |
|
||||||
|
|
||||||
|
更新时:不传或传空字符串 = 不修改,传新值 = 替换。
|
||||||
|
|
||||||
|
### Decision 9: 前端支付方式展示
|
||||||
|
|
||||||
|
富友支付对用户透明,前端统一显示"微信支付"。后端根据生效配置的 `provider_type` 自动路由到微信直连或富友,前端不感知具体支付渠道。
|
||||||
|
|
||||||
|
### Decision 10: OAuth 配置管理策略
|
||||||
|
|
||||||
|
OAuth 字段(公众号 AppID/AppSecret、小程序 AppID/AppSecret)存入 `tb_wechat_config`。本次只存不用——`OfficialAccountService` 暂时继续从环境变量读取。H5/小程序重构时切换为从数据库动态加载。保证切换配置时 OAuth 和支付 AppID 原子性同步。
|
||||||
|
|
||||||
|
**因此,以下代码本次不删除:**
|
||||||
|
- `pkg/config/config.go` 中的 `OfficialAccountConfig` 结构体(`WechatConfig.OfficialAccount` 字段仍需使用)
|
||||||
|
- `pkg/config/defaults/config.yaml` 中的 `wechat.official_account` 配置节(`OfficialAccountService` 仍从中读取)
|
||||||
|
- `cmd/api/main.go` 中 `validateWechatConfig` 里对公众号配置的校验逻辑(仅删除支付相关校验)
|
||||||
|
|
||||||
|
**以下代码本次必须删除(Payment 相关的 YAML 方案完全废弃):**
|
||||||
|
- `pkg/config/config.go` 的 `PaymentConfig` 结构体 + `WechatConfig.Payment` 字段
|
||||||
|
- `pkg/config/defaults/config.yaml` 的 `wechat.payment` 配置节
|
||||||
|
- `pkg/wechat/config.go` 的 `NewPaymentApp(cfg *config.Config, ...)` 函数(从 YAML/CertPath 创建 Payment 实例,被 DB 方案彻底取代)
|
||||||
|
- `cmd/api/main.go` `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关校验
|
||||||
|
|
||||||
|
### Decision 11: 代理充值设计
|
||||||
|
|
||||||
|
- 仅充到余额钱包(`wallet_type=main`)
|
||||||
|
- 代理只能充自己店铺,平台可指定任意店铺
|
||||||
|
- 支付方式:`wechat`(在线)/ `offline`(线下,仅平台,需操作密码)
|
||||||
|
- 回调处理完整实现,客户端支付发起留桩
|
||||||
|
|
||||||
|
### Decision 12: Card → Asset 常量重命名
|
||||||
|
|
||||||
|
`pkg/constants/wallet.go` 中 `Card*` 前缀统一改为 `Asset*`,原 `Card*` 常量保留为废弃别名(向后兼容)。
|
||||||
|
|
||||||
|
影响范围:`CardWalletResourceType*`、`CardWalletStatus*`、`CardTransactionType*`、`CardRechargeOrderPrefix`、`CardRechargeMinAmount`、`CardRechargeMaxAmount`。
|
||||||
|
|
||||||
|
## tb_wechat_config 表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tb_wechat_config (
|
||||||
|
-- 基础字段
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL, -- 配置名称
|
||||||
|
description TEXT, -- 配置描述
|
||||||
|
provider_type VARCHAR(20) NOT NULL, -- 支付渠道: wechat / fuiou
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 是否激活
|
||||||
|
|
||||||
|
-- OAuth 公众号
|
||||||
|
oa_app_id VARCHAR(100) NOT NULL, -- 公众号 AppID
|
||||||
|
oa_app_secret VARCHAR(200) NOT NULL, -- 公众号 AppSecret
|
||||||
|
oa_token VARCHAR(200), -- 消息验证 Token
|
||||||
|
oa_aes_key VARCHAR(200), -- 消息加密 AESKey
|
||||||
|
oa_oauth_redirect_url VARCHAR(500), -- OAuth 回调地址
|
||||||
|
|
||||||
|
-- OAuth 小程序
|
||||||
|
miniapp_app_id VARCHAR(100), -- 小程序 AppID
|
||||||
|
miniapp_app_secret VARCHAR(200), -- 小程序 AppSecret
|
||||||
|
|
||||||
|
-- 支付-微信直连 (provider_type=wechat 时使用)
|
||||||
|
wx_mch_id VARCHAR(100), -- 商户号
|
||||||
|
wx_api_v3_key VARCHAR(200), -- API V3 密钥
|
||||||
|
wx_api_v2_key VARCHAR(200), -- API V2 密钥
|
||||||
|
wx_cert_content TEXT, -- 证书内容 (Base64)
|
||||||
|
wx_key_content TEXT, -- 私钥内容 (Base64)
|
||||||
|
wx_serial_no VARCHAR(200), -- 证书序列号
|
||||||
|
wx_notify_url VARCHAR(500), -- 支付回调地址
|
||||||
|
|
||||||
|
-- 支付-富友 (provider_type=fuiou 时使用)
|
||||||
|
fy_ins_cd VARCHAR(50), -- 机构号
|
||||||
|
fy_mchnt_cd VARCHAR(50), -- 商户号
|
||||||
|
fy_term_id VARCHAR(50), -- 终端号
|
||||||
|
fy_private_key TEXT, -- 商户 RSA 私钥 (Base64)
|
||||||
|
fy_public_key TEXT, -- 富友 RSA 公钥 (Base64)
|
||||||
|
fy_api_url VARCHAR(500), -- API 地址
|
||||||
|
fy_notify_url VARCHAR(500), -- 回调地址
|
||||||
|
|
||||||
|
-- 审计字段
|
||||||
|
creator BIGINT NOT NULL DEFAULT 0, -- 创建人 ID
|
||||||
|
updater BIGINT NOT NULL DEFAULT 0, -- 更新人 ID
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ -- 软删除
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wechat_config_is_active ON tb_wechat_config(is_active) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_wechat_config_provider_type ON tb_wechat_config(provider_type) WHERE deleted_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 流程图
|
||||||
|
|
||||||
|
### 配置切换流程
|
||||||
|
|
||||||
|
```
|
||||||
|
管理员调用 PUT /api/admin/wechat-configs/:id/activate
|
||||||
|
|
|
||||||
|
+--① BEGIN 事务
|
||||||
|
| +-- UPDATE tb_wechat_config SET is_active=false WHERE is_active=true
|
||||||
|
| +-- UPDATE tb_wechat_config SET is_active=true WHERE id=:id
|
||||||
|
+--② COMMIT
|
||||||
|
+--③ DEL Redis "wechat:config:active"
|
||||||
|
+--④ 清除内存中的 Payment 实例缓存
|
||||||
|
+--⑤ 记录审计日志
|
||||||
|
|
|
||||||
|
+-- 新订单 --> 使用新配置
|
||||||
|
+-- 旧订单(待支付) --> 回调时按 payment_config_id 加载旧配置验签
|
||||||
|
+-- 30分钟超时自动取消
|
||||||
|
```
|
||||||
|
|
||||||
|
### 改造后的第三方支付流程(两步走)
|
||||||
|
|
||||||
|
```
|
||||||
|
步骤1: H5 创建订单
|
||||||
|
POST /api/h5/orders
|
||||||
|
+-- payment_method = "wechat"
|
||||||
|
+-- 系统查询 active 配置
|
||||||
|
+-- IF 有配置 --> 记录 payment_config_id 到订单
|
||||||
|
+-- 返回 order (payment_status=1 待支付)
|
||||||
|
|
||||||
|
步骤2: 发起微信支付(留桩)
|
||||||
|
POST /api/h5/orders/:id/wechat-pay/jsapi
|
||||||
|
+-- 加载 order.payment_config_id 对应配置
|
||||||
|
+-- 按 provider_type 分发:
|
||||||
|
| +-- wechat --> PaymentService.CreateJSAPIOrder()
|
||||||
|
| +-- fuiou --> FuiouClient.WxPreCreate()
|
||||||
|
+-- 返回支付参数给前端(本次留桩)
|
||||||
|
|
||||||
|
步骤3: 支付回调
|
||||||
|
POST /api/callback/wechat-pay 或 /api/callback/fuiou-pay
|
||||||
|
+-- 解析订单号
|
||||||
|
+-- 按订单号前缀分发:
|
||||||
|
| +-- "ORD" --> 套餐订单 --> 查 tb_order
|
||||||
|
| +-- "CRCH" --> 资产充值 --> 查 tb_asset_recharge_record
|
||||||
|
| +-- "ARCH" --> 代理充值 --> 查 tb_agent_recharge_record
|
||||||
|
+-- 按 payment_config_id 加载配置
|
||||||
|
+-- 用对应凭证验签
|
||||||
|
+-- 调用对应 Service.HandlePaymentCallback()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代理预充值流程
|
||||||
|
|
||||||
|
```
|
||||||
|
代理/平台 --> POST /api/admin/agent-recharges
|
||||||
|
|
|
||||||
|
+-- 验证权限: 代理只能充自己店铺,平台可指定店铺
|
||||||
|
+-- 验证金额范围 (100元~100万元)
|
||||||
|
+-- 查找目标店铺的 main 钱包
|
||||||
|
|
|
||||||
|
+-- IF payment_method = "wechat"
|
||||||
|
| +-- 查询 active 配置 --> 无配置则拒绝
|
||||||
|
| +-- 记录 payment_config_id
|
||||||
|
| +-- 创建充值订单 (status=1 待支付)
|
||||||
|
| +-- 返回订单信息(客户端支付发起留桩)
|
||||||
|
|
|
||||||
|
+-- IF payment_method = "offline"
|
||||||
|
+-- 验证是否平台账号 --> 非平台拒绝
|
||||||
|
+-- 返回订单信息(status=1 待支付,等待线下确认)
|
||||||
|
|
||||||
|
平台确认线下充值 --> POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
+-- 验证操作密码
|
||||||
|
+-- 事务内:
|
||||||
|
| +-- 更新充值订单状态 (status=2 已支付)
|
||||||
|
| +-- 增加余额钱包余额(乐观锁)
|
||||||
|
| +-- 创建钱包交易记录
|
||||||
|
+-- 记录审计日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回调统一分发流程
|
||||||
|
|
||||||
|
```
|
||||||
|
回调到达
|
||||||
|
|
|
||||||
|
+-- 微信回调 POST /api/callback/wechat-pay
|
||||||
|
| +-- PowerWeChat SDK 解析 + 取 out_trade_no
|
||||||
|
|
|
||||||
|
+-- 富友回调 POST /api/callback/fuiou-pay
|
||||||
|
| +-- GBK->UTF-8 --> XML解析 --> 取 mchnt_order_no
|
||||||
|
|
|
||||||
|
+-- 按订单号前缀分发
|
||||||
|
|
|
||||||
|
+-- "ORD" --> 套餐订单
|
||||||
|
| +-- 查询 tb_order --> 取 payment_config_id
|
||||||
|
| +-- 加载配置验签
|
||||||
|
| +-- orderService.HandlePaymentCallback()
|
||||||
|
|
|
||||||
|
+-- "CRCH" --> 资产充值(修复:当前代码用废弃的 "RCH")
|
||||||
|
| +-- 查询 tb_asset_recharge_record --> 取 payment_config_id
|
||||||
|
| +-- 加载配置验签
|
||||||
|
| +-- rechargeService.HandlePaymentCallback()
|
||||||
|
|
|
||||||
|
+-- "ARCH" --> 代理充值(全新)
|
||||||
|
+-- 查询 tb_agent_recharge_record --> 取 payment_config_id
|
||||||
|
+-- 加载配置验签
|
||||||
|
+-- agentRechargeService.HandlePaymentCallback()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 配置切换后旧回调验签失败 | 用户付了钱但订单未处理 | 订单记录 `payment_config_id`,回调按该字段加载配置验签 |
|
||||||
|
| Redis 缓存与 DB 不一致 | 短暂使用旧配置 | TTL 5 分钟兜底 + 切换时主动失效 |
|
||||||
|
| 富友 SDK 是自研非开源 | 维护成本 | 从已验证的 cc-coding 项目移植,核心逻辑已线上运行 |
|
||||||
|
| 证书 Base64 存 DB 体量大 | 单行数据量增大 | 证书文件通常 2-4KB,Base64 后约 3-6KB,可接受 |
|
||||||
|
| 多实例部署缓存一致性 | 某实例使用旧缓存 | Redis 是共享的,DEL 操作对所有实例生效;内存缓存通过 Redis MISS 时重建 |
|
||||||
|
| Card→Asset 常量重命名 | 引用处需要更新 | 旧常量保留为废弃别名,渐进迁移 |
|
||||||
|
| OAuth 只存不用 | 数据在但不生效 | 明确标注为预留,H5 重构时切换 |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. **数据库迁移**:新建 `tb_wechat_config` 表 + `tb_order`、`tb_asset_recharge_record`、`tb_agent_recharge_record` 各新增 `payment_config_id` 列(nullable,不影响存量数据)
|
||||||
|
2. **常量重命名**:`Card*` → `Asset*`,旧名保留为废弃别名
|
||||||
|
3. **删除 YAML 支付配置基础设施**(以下操作必须作为独立任务明确执行,不可遗漏):
|
||||||
|
- `pkg/config/config.go`:删除 `PaymentConfig` 结构体、删除 `WechatConfig.Payment` 字段
|
||||||
|
- `pkg/config/defaults/config.yaml`:删除 `wechat.payment:` 整个配置节(保留 `wechat.official_account:` 节,OAuth 继续使用)
|
||||||
|
- `pkg/wechat/config.go`:删除 `NewPaymentApp(cfg *config.Config, ...)` 函数(从文件路径创建 Payment 的方式已被 DB Base64 方案取代)
|
||||||
|
- `cmd/api/main.go`:删除 `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关的校验代码(保留公众号校验部分)
|
||||||
|
4. **代码部署**:新代码兼容无配置场景(无生效配置 = 降级为纯钱包支付)
|
||||||
|
5. **配置迁移**:将当前环境变量中的微信参数手动录入为第一个配置并激活
|
||||||
|
6. **回滚策略**:回滚代码后,支付流程回退到读取环境变量的单例模式(若 `deps.WechatPayment` 仍存在),新表数据不影响旧逻辑
|
||||||
|
|
||||||
|
## Closed Questions
|
||||||
|
|
||||||
|
- ~~富友支付是否需要退款?~~ → 暂不包含(Non-Goals 已声明)
|
||||||
|
- ~~是否需要配置变更审计日志?~~ → 必须纳入,复用 `AuditServiceInterface`
|
||||||
|
- ~~充值模块是否需要改造?~~ → 全部纳入,资产充值 + 代理充值都加 `payment_config_id`
|
||||||
|
- ~~支付宝如何处理?~~ → 保留不改造
|
||||||
|
- ~~OpenID 与 AppID 一致性?~~ → OAuth 字段纳入同一配置,切换配置时原子性同步
|
||||||
61
openspec/changes/add-payment-config-management/proposal.md
Normal file
61
openspec/changes/add-payment-config-management/proposal.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
当前微信相关参数(公众号 OAuth、小程序、支付凭证)硬编码在环境变量中,只有一套配置,无法动态切换。业务上,微信公众号/小程序随时可能被封禁,需要在管理后台**秒级切换**到备用配置恢复 OAuth 登录和支付能力。同时需要接入富友支付作为备选通道,降低对微信直连的单一依赖。此外,代理预充值(余额钱包在线充值)当前只有 Store 层骨架,缺少完整的 Service/Handler/回调处理,需要补齐。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
本提案包含**两个目标**,按顺序实施:
|
||||||
|
|
||||||
|
### Goal 1:微信参数配置管理 + 支付流程改造
|
||||||
|
|
||||||
|
- **新增微信参数配置管理模块**:支持在管理后台 CRUD 管理多套微信参数配置,每套配置 = 完整的"微信身份"(OAuth 公众号 + OAuth 小程序 + 支付凭证),支持全局唯一激活
|
||||||
|
- **新建 `tb_wechat_config` 表**:扁平字段存储 OAuth 参数(公众号 AppID/AppSecret、小程序 AppID/AppSecret)、微信直连支付参数、富友支付参数,支持多配置共存
|
||||||
|
- **新增富友支付 SDK**:基于富友 `wxPreCreate` 接口实现公众号 JSAPI 和小程序支付,移植 cc-coding 项目的 RSA 签名、XML 编解码、GBK 转换等核心逻辑
|
||||||
|
- **改造订单支付流程**:订单创建时从数据库/Redis 动态加载当前生效配置记录 `payment_config_id`;支付发起(WechatPayJSAPI/WechatPayH5)本次**留桩**,添加 TODO 标记,保留单例调用——等下一阶段实现动态加载;同时删除启动时基于 YAML 创建 PaymentService 单例的相关代码
|
||||||
|
- **订单关联配置**:`tb_order` 新增 `payment_config_id` 字段,回调按该字段加载对应配置验签,解决配置切换期间在途支付的竞态问题
|
||||||
|
- **资产充值模块适配**:`tb_asset_recharge_record` 新增 `payment_config_id` 字段,充值回调按配置验签
|
||||||
|
- **常量重命名**:`pkg/constants/wallet.go` 中 `Card*` 前缀常量统一重命名为 `Asset*`,与模型层命名一致
|
||||||
|
- **配置切换安全策略**:切换配置时不取消在途订单,旧订单按原配置自然完成或超时过期;无生效配置时只允许钱包/线下支付
|
||||||
|
- **仅平台用户可操作**:通过路由层中间件限制仅平台用户访问
|
||||||
|
- **审计日志**:所有 CRUD 和激活/停用操作记录审计日志,复用现有 AuditServiceInterface
|
||||||
|
|
||||||
|
> **注意**:OAuth 配置字段本次只**存储和管理**(数据库 + 管理界面),OfficialAccountService 的动态加载改造留待 H5/小程序重构时实施。客户端支付发起(调用第三方获取拉起支付参数)本次**留桩不实现**,在另一个 session 讨论。
|
||||||
|
|
||||||
|
### Goal 2:代理预充值系统
|
||||||
|
|
||||||
|
- **新增代理余额钱包在线充值**:完整的 Service + Handler + 回调处理,支持微信在线支付和线下充值
|
||||||
|
- **代理只能选择微信支付**:后端根据当前生效配置自动路由到微信直连或富友,对代理透明
|
||||||
|
- **线下充值仅平台操作**:需要操作密码二次验证
|
||||||
|
- **`tb_agent_recharge_record` 新增 `payment_config_id` 字段**
|
||||||
|
- **充值目标**:仅充到余额钱包(`wallet_type=main`),佣金钱包通过分佣自动入账
|
||||||
|
|
||||||
|
> **注意**:代理充值的客户端支付发起(调用第三方获取拉起参数)同样**留桩不实现**。回调处理完整实现(解析第三方支付成功通知)。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `wechat-config-management`:微信参数配置的 CRUD 管理、激活/停用、全局唯一激活约束、Redis 缓存、接口脱敏、审计日志
|
||||||
|
- `fuiou-payment`:富友支付集成,包括 wxPreCreate 下单(公众号 JSAPI + 小程序)、支付回调验签处理、RSA 签名/XML 编解码
|
||||||
|
- `agent-recharge`:代理余额钱包在线充值,创建充值订单、线下充值确认、回调处理、钱包余额更新
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `wechat-payment`:配置来源从环境变量改为数据库动态加载;Payment 实例从启动时单例改为按需创建(留桩)
|
||||||
|
- `order-payment`:订单新增 `payment_config_id` 字段;下单时无生效配置则拒绝第三方支付;回调处理按 `payment_config_id` 加载对应配置验签
|
||||||
|
- `asset-recharge`:充值表新增 `payment_config_id` 字段;回调处理按配置验签;修复 `RechargeOrderPrefix` 废弃问题(当前用 `"RCH"`,实际前缀是 `"CRCH"`)
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **新增文件**:Model(`wechat_config.go`)、DTO、Store、Service、Handler、迁移文件、富友 SDK 包(`pkg/fuiou/`)、常量、错误码、代理充值 Service/Handler
|
||||||
|
- **修改文件**:`internal/model/order.go`(新增字段)、`internal/model/asset_wallet.go`(新增字段)、`internal/model/agent_wallet.go`(新增字段)、`internal/service/order/service.go`(动态加载配置 + 注入 wechatConfigService)、`internal/handler/callback/payment.go`(支持富友回调 + 按配置验签 + 修复前缀匹配)、`pkg/constants/wallet.go`(Card→Asset 重命名)、`internal/bootstrap/`(注册新模块)、`internal/routes/`(注册新路由)、`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)
|
||||||
|
- **删除/精简文件**(YAML 支付方案遗留代码,必须清理):
|
||||||
|
- `pkg/config/config.go`:删除 `PaymentConfig` 结构体 + `WechatConfig.Payment` 字段(约 15 行),保留 `OfficialAccountConfig` 结构体
|
||||||
|
- `pkg/config/defaults/config.yaml`:删除 `wechat.payment:` 配置节(约 10 行),保留 `wechat.official_account:` 节
|
||||||
|
- `pkg/wechat/config.go`:删除 `NewPaymentApp(cfg *config.Config, ...)` 函数(整个函数,约 30 行),保留 `NewOfficialAccountApp`
|
||||||
|
- `cmd/api/main.go`:从 `validateWechatConfig` 中删除所有 `wechatCfg.Payment.*` 校验代码(约 40 行),保留公众号校验部分
|
||||||
|
- **新增 API 路由**:`/api/admin/wechat-configs/*`(8 个端点)、`/api/admin/agent-recharges/*`(4 个端点)、`/api/callback/fuiou-pay`(富友回调)
|
||||||
|
- **数据库变更**:新建 `tb_wechat_config` 表、`tb_order` 新增 `payment_config_id` 列、`tb_asset_recharge_record` 新增 `payment_config_id` 列、`tb_agent_recharge_record` 新增 `payment_config_id` 列
|
||||||
|
- **新增依赖**:`golang.org/x/text`(GBK 编解码,富友支付需要)
|
||||||
|
- **保留不改造**:支付宝支付(AlipayCallback、PaymentMethodAlipay 常量保持原样不动);`OfficialAccountService` 及其 YAML 配置(OAuth 本次只存不用);`WechatPayment` 单例注入(留桩期间保留,等支付发起动态化后再移除)
|
||||||
|
- **性能考虑**:生效配置使用 Redis 缓存(TTL 5 分钟,Key:`wechat:config:active`),避免每次支付查 DB;配置切换时主动清缓存保证即时生效
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
# 代理充值管理 API 规范
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 创建代理充值订单
|
||||||
|
|
||||||
|
**接口描述**:代理或平台账号发起代理余额钱包充值,创建充值订单。
|
||||||
|
|
||||||
|
**HTTP 方法与路径**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/agent-recharges
|
||||||
|
```
|
||||||
|
|
||||||
|
**鉴权**
|
||||||
|
|
||||||
|
- 需要登录态(Bearer Token)
|
||||||
|
- 代理账号:只能为自己所属店铺的主钱包(wallet_type=main)充值
|
||||||
|
- 平台账号:可指定任意店铺
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**请求体示例(在线充值 - 微信)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体示例(线下充值 - 仅平台)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 200000,
|
||||||
|
"payment_method": "offline"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求字段说明**
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| shop_id | integer | 是 | 目标店铺 ID。代理账号只能填写自己所属店铺 ID |
|
||||||
|
| amount | integer | 是 | 充值金额(单位:分)。范围:10000~100000000(即 100 元~100 万元) |
|
||||||
|
| payment_method | string | 是 | 支付方式。可选值:`wechat`(在线微信支付)、`offline`(线下转账,仅平台可用) |
|
||||||
|
|
||||||
|
**业务规则**
|
||||||
|
|
||||||
|
- `amount` 最小值为 `AgentRechargeMinAmount`(10000 分 = 100 元),最大值为 `AgentRechargeMaxAmount`(100000000 分 = 100 万元)
|
||||||
|
- `payment_method=wechat` 时,系统根据当前激活的支付配置自动路由至微信直连或富友通道,并记录 `payment_config_id`;客户端发起支付的具体流程本期暂不实现(Stub)
|
||||||
|
- `payment_method=offline` 仅平台账号可使用,代理账号调用此方式将返回 `1005 CodeForbidden`
|
||||||
|
- 订单创建后状态为 `1`(待支付)
|
||||||
|
- 充值单号前缀为 `ARCH`,全局唯一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**成功响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 88,
|
||||||
|
"recharge_no": "ARCH20260316100001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat",
|
||||||
|
"payment_channel": "wechat_direct",
|
||||||
|
"payment_config_id": 3,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明**
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| id | integer | 充值记录 ID |
|
||||||
|
| recharge_no | string | 充值单号(ARCH 前缀) |
|
||||||
|
| shop_id | integer | 店铺 ID |
|
||||||
|
| amount | integer | 充值金额(分) |
|
||||||
|
| payment_method | string | 支付方式 |
|
||||||
|
| payment_channel | string | 实际支付通道(wechat_direct / fuyou / offline) |
|
||||||
|
| payment_config_id | integer\|null | 关联的支付配置 ID(线下充值为 null) |
|
||||||
|
| status | integer | 订单状态:1=待支付,2=已完成,3=已取消 |
|
||||||
|
| created_at | string | 创建时间(RFC3339) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**错误响应示例**
|
||||||
|
|
||||||
|
金额超出范围:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"msg": "充值金额超出允许范围(100元~100万元)",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
代理账号使用线下充值:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1005,
|
||||||
|
"msg": "只有平台账号可以使用线下充值",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
钱包不存在:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1053,
|
||||||
|
"msg": "钱包不存在",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
无可用支付配置:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1175,
|
||||||
|
"msg": "当前无可用的支付配置,请联系管理员",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
越权访问(代理操作他人店铺):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1005,
|
||||||
|
"msg": "无权限操作该资源或资源不存在",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 线下充值确认
|
||||||
|
|
||||||
|
**接口描述**:平台账号确认线下转账已到账,完成充值并为代理钱包增加余额。
|
||||||
|
|
||||||
|
**HTTP 方法与路径**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
```
|
||||||
|
|
||||||
|
**鉴权**
|
||||||
|
|
||||||
|
- 需要登录态(Bearer Token)
|
||||||
|
- 仅平台账号可调用,其他账号类型返回 `1005 CodeForbidden`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**请求体示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation_password": "Abc123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求字段说明**
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| operation_password | string | 是 | 操作密码,用于二次身份验证 |
|
||||||
|
|
||||||
|
**路径参数说明**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| id | integer | 充值记录 ID |
|
||||||
|
|
||||||
|
**业务规则**
|
||||||
|
|
||||||
|
- 操作密码验证失败返回 `1043 CodeInvalidOldPassword`
|
||||||
|
- 充值记录必须存在且 `payment_method=offline`,否则返回 `1121 CodeRechargeNotFound`
|
||||||
|
- 充值记录状态必须为 `1`(待支付),否则返回 `1050 CodeInvalidStatus`
|
||||||
|
- 确认成功后:
|
||||||
|
1. 充值记录状态更新为 `2`(已完成),记录 `paid_at` 和 `completed_at`
|
||||||
|
2. 代理主钱包余额增加对应金额(使用乐观锁 version 字段防并发)
|
||||||
|
3. 创建钱包流水记录
|
||||||
|
4. 记录审计日志(操作人、操作前后数据)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**成功响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 88,
|
||||||
|
"recharge_no": "ARCH20260316100001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 200000,
|
||||||
|
"payment_method": "offline",
|
||||||
|
"payment_channel": "offline",
|
||||||
|
"payment_config_id": null,
|
||||||
|
"status": 2,
|
||||||
|
"paid_at": "2026-03-16T11:00:00+08:00",
|
||||||
|
"completed_at": "2026-03-16T11:00:00+08:00",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**错误响应示例**
|
||||||
|
|
||||||
|
操作密码错误:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1043,
|
||||||
|
"msg": "操作密码错误",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
充值记录不存在:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1121,
|
||||||
|
"msg": "充值记录不存在",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
充值记录状态不允许操作:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1050,
|
||||||
|
"msg": "当前充值记录状态不允许此操作",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
非平台账号调用:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1005,
|
||||||
|
"msg": "只有平台账号可以使用线下充值",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 代理充值查询
|
||||||
|
|
||||||
|
#### 接口一:充值记录列表
|
||||||
|
|
||||||
|
**接口描述**:分页查询代理充值记录,支持按店铺、状态、日期范围过滤。
|
||||||
|
|
||||||
|
**HTTP 方法与路径**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/agent-recharges
|
||||||
|
```
|
||||||
|
|
||||||
|
**鉴权**
|
||||||
|
|
||||||
|
- 需要登录态(Bearer Token)
|
||||||
|
- 代理账号:只能查看自己所属店铺的充值记录
|
||||||
|
- 平台账号:可查看所有店铺的充值记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**请求参数(Query String)**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=2&start_date=2026-03-01&end_date=2026-03-31
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数说明**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| page | integer | 否 | 页码,默认 1 |
|
||||||
|
| page_size | integer | 否 | 每页条数,默认 20,最大 100 |
|
||||||
|
| shop_id | integer | 否 | 按店铺 ID 过滤(平台账号可用) |
|
||||||
|
| status | integer | 否 | 按状态过滤:1=待支付,2=已完成,3=已取消 |
|
||||||
|
| start_date | string | 否 | 创建时间起始日期,格式 `YYYY-MM-DD` |
|
||||||
|
| end_date | string | 否 | 创建时间截止日期,格式 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**成功响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": 56,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 88,
|
||||||
|
"recharge_no": "ARCH20260316100001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"shop_name": "测试店铺A",
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat",
|
||||||
|
"payment_channel": "wechat_direct",
|
||||||
|
"payment_config_id": 3,
|
||||||
|
"status": 2,
|
||||||
|
"paid_at": "2026-03-16T10:05:00+08:00",
|
||||||
|
"completed_at": "2026-03-16T10:05:00+08:00",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 87,
|
||||||
|
"recharge_no": "ARCH20260315090001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"shop_name": "测试店铺A",
|
||||||
|
"amount": 200000,
|
||||||
|
"payment_method": "offline",
|
||||||
|
"payment_channel": "offline",
|
||||||
|
"payment_config_id": null,
|
||||||
|
"status": 2,
|
||||||
|
"paid_at": "2026-03-15T11:00:00+08:00",
|
||||||
|
"completed_at": "2026-03-15T11:00:00+08:00",
|
||||||
|
"created_at": "2026-03-15T09:00:00+08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**列表项字段说明**
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| id | integer | 充值记录 ID |
|
||||||
|
| recharge_no | string | 充值单号 |
|
||||||
|
| shop_id | integer | 店铺 ID |
|
||||||
|
| shop_name | string | 店铺名称 |
|
||||||
|
| amount | integer | 充值金额(分) |
|
||||||
|
| payment_method | string | 支付方式 |
|
||||||
|
| payment_channel | string | 实际支付通道 |
|
||||||
|
| payment_config_id | integer\|null | 关联支付配置 ID |
|
||||||
|
| status | integer | 状态:1=待支付,2=已完成,3=已取消 |
|
||||||
|
| paid_at | string\|null | 支付时间 |
|
||||||
|
| completed_at | string\|null | 完成时间 |
|
||||||
|
| created_at | string | 创建时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**错误响应示例**
|
||||||
|
|
||||||
|
参数错误:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"msg": "参数验证失败",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 接口二:充值记录详情
|
||||||
|
|
||||||
|
**接口描述**:查询单条充值记录的完整详情。
|
||||||
|
|
||||||
|
**HTTP 方法与路径**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/agent-recharges/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**鉴权**
|
||||||
|
|
||||||
|
- 需要登录态(Bearer Token)
|
||||||
|
- 代理账号:只能查看自己所属店铺的充值记录,否则返回 `1121 CodeRechargeNotFound`
|
||||||
|
- 平台账号:可查看任意充值记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**路径参数说明**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| id | integer | 充值记录 ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**成功响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 88,
|
||||||
|
"recharge_no": "ARCH20260316100001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"shop_name": "测试店铺A",
|
||||||
|
"agent_wallet_id": 55,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat",
|
||||||
|
"payment_channel": "wechat_direct",
|
||||||
|
"payment_config_id": 3,
|
||||||
|
"payment_transaction_id": "wx_txn_20260316_abc123",
|
||||||
|
"status": 2,
|
||||||
|
"paid_at": "2026-03-16T10:05:00+08:00",
|
||||||
|
"completed_at": "2026-03-16T10:05:00+08:00",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:05:00+08:00"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**详情字段说明**
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| id | integer | 充值记录 ID |
|
||||||
|
| recharge_no | string | 充值单号 |
|
||||||
|
| shop_id | integer | 店铺 ID |
|
||||||
|
| shop_name | string | 店铺名称 |
|
||||||
|
| agent_wallet_id | integer | 代理钱包 ID |
|
||||||
|
| amount | integer | 充值金额(分) |
|
||||||
|
| payment_method | string | 支付方式 |
|
||||||
|
| payment_channel | string | 实际支付通道 |
|
||||||
|
| payment_config_id | integer\|null | 关联支付配置 ID |
|
||||||
|
| payment_transaction_id | string\|null | 第三方支付流水号 |
|
||||||
|
| status | integer | 状态:1=待支付,2=已完成,3=已取消 |
|
||||||
|
| paid_at | string\|null | 支付时间 |
|
||||||
|
| completed_at | string\|null | 完成时间 |
|
||||||
|
| created_at | string | 创建时间 |
|
||||||
|
| updated_at | string | 最后更新时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**错误响应示例**
|
||||||
|
|
||||||
|
充值记录不存在或无权限:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1121,
|
||||||
|
"msg": "充值记录不存在",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 代理充值回调处理
|
||||||
|
|
||||||
|
**接口描述**:接收第三方支付平台(微信直连 / 富友)的异步支付结果通知,完成充值订单状态更新和钱包余额增加。
|
||||||
|
|
||||||
|
**HTTP 方法与路径**
|
||||||
|
|
||||||
|
回调地址由支付配置中的 `notify_url` 字段决定,格式示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/payment/callback/agent-recharge/{payment_channel}
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `payment_channel` 为 `wechat_direct` 或 `fuyou`。
|
||||||
|
|
||||||
|
**鉴权**
|
||||||
|
|
||||||
|
- 无需登录态
|
||||||
|
- 通过签名验证确认请求来源合法性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**处理流程**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 接收回调请求
|
||||||
|
2. 根据 payment_channel 确定验签方式
|
||||||
|
3. 通过 recharge_no(充值单号)查找充值记录
|
||||||
|
4. 幂等性检查:若记录状态已为 2(已完成),直接返回成功
|
||||||
|
5. 使用充值记录中的 payment_config_id 查找对应支付配置
|
||||||
|
6. 使用支付配置的密钥验证签名
|
||||||
|
7. 验签通过后,在事务中执行:
|
||||||
|
a. 更新充值记录状态为 2(已完成),记录 payment_transaction_id、paid_at、completed_at
|
||||||
|
b. 代理主钱包余额增加充值金额(乐观锁 version 字段防并发)
|
||||||
|
c. 创建钱包流水记录(类型:充值入账)
|
||||||
|
8. 返回支付平台要求的成功响应格式
|
||||||
|
```
|
||||||
|
|
||||||
|
**幂等性保障**
|
||||||
|
|
||||||
|
- 使用充值记录状态作为幂等判断依据(状态条件更新:`WHERE status = 1`)
|
||||||
|
- `RowsAffected == 0` 时说明已被处理,直接返回成功,不重复入账
|
||||||
|
|
||||||
|
**签名验证**
|
||||||
|
|
||||||
|
- 根据充值记录的 `payment_config_id` 查找对应支付配置
|
||||||
|
- 使用该配置的密钥(`api_key` / `app_secret`)按对应通道规则验签
|
||||||
|
- 验签失败时记录错误日志,返回失败响应(不更新订单状态)
|
||||||
|
|
||||||
|
**回调响应**
|
||||||
|
|
||||||
|
- 微信直连:返回 `{"code": "SUCCESS", "message": "成功"}`
|
||||||
|
- 富友:按富友协议返回对应成功标识
|
||||||
|
- 处理失败时返回对应通道的失败标识,触发第三方平台重试
|
||||||
|
|
||||||
|
**异常处理**
|
||||||
|
|
||||||
|
- 充值记录不存在:记录警告日志,返回失败(触发重试,等待数据一致)
|
||||||
|
- 签名验证失败:记录错误日志(含完整请求体),返回失败
|
||||||
|
- 钱包余额更新失败(乐观锁冲突):最多重试 3 次,仍失败则记录告警日志并返回失败
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 权限控制
|
||||||
|
|
||||||
|
**账号类型与操作权限矩阵**
|
||||||
|
|
||||||
|
| 操作 | 平台账号 | 代理账号 | 企业账号 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| 创建充值订单(在线) | ✅ 任意店铺 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
| 创建充值订单(线下) | ✅ 任意店铺 | ❌ | ❌ |
|
||||||
|
| 线下充值确认 | ✅ | ❌ | ❌ |
|
||||||
|
| 查询充值列表 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
| 查询充值详情 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
|
||||||
|
**越权防护规则**
|
||||||
|
|
||||||
|
1. **路由层**:企业账号访问代理充值相关接口,统一返回 `1005 CodeForbidden`
|
||||||
|
2. **Service 层**:
|
||||||
|
- 代理账号创建充值时,验证 `shop_id` 必须属于自己所属店铺
|
||||||
|
- 代理账号查询详情时,验证充值记录的 `shop_id` 必须属于自己所属店铺
|
||||||
|
3. **越权统一响应**:不区分"不存在"和"无权限",统一返回 `1005` 或对应资源不存在错误,防止信息泄露
|
||||||
|
|
||||||
|
**线下充值操作密码**
|
||||||
|
|
||||||
|
- 平台账号执行线下充值确认时,必须提供操作密码
|
||||||
|
- 操作密码验证失败返回 `1043 CodeInvalidOldPassword`
|
||||||
|
- 操作密码不在响应中返回,不记录到日志明文中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据模型补充说明
|
||||||
|
|
||||||
|
**tb_agent_recharge_record 新增字段**
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 可空 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| payment_config_id | bigint | 是 | 关联支付配置 ID,线下充值为 NULL,在线充值记录实际使用的支付配置 |
|
||||||
|
|
||||||
|
**充值状态枚举**
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| 1 | 待支付(订单已创建,等待支付) |
|
||||||
|
| 2 | 已完成(支付成功,余额已到账) |
|
||||||
|
| 3 | 已取消(超时未支付或主动取消) |
|
||||||
|
|
||||||
|
**支付方式枚举**
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| wechat | 微信在线支付(自动路由至微信直连或富友) |
|
||||||
|
| offline | 线下转账(仅平台账号可用) |
|
||||||
|
|
||||||
|
**支付通道枚举**
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| wechat_direct | 微信直连通道 |
|
||||||
|
| fuyou | 富友通道 |
|
||||||
|
| offline | 线下转账 |
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 资产充值关联支付配置
|
||||||
|
|
||||||
|
系统 SHALL 在创建资产充值订单时记录当前生效的支付配置 ID,用于回调处理时加载正确的配置验签。
|
||||||
|
|
||||||
|
#### Scenario: 创建充值订单时记录支付配置 ID
|
||||||
|
|
||||||
|
- **WHEN** 个人客户创建资产充值订单(IoT 卡钱包或设备钱包充值)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/h5/wallets/recharge
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体(现有接口,字段不变)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resource_type": "iot_card",
|
||||||
|
"resource_id": 101,
|
||||||
|
"amount": 10000,
|
||||||
|
"payment_method": "wechat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `resource_type` | string | ✅ | 资源类型:`iot_card` / `device` |
|
||||||
|
| `resource_id` | uint | ✅ | 资源 ID(卡 ID 或设备 ID) |
|
||||||
|
| `amount` | int64 | ✅ | 充值金额(分),范围 100~10000000(1 元~10 万元) |
|
||||||
|
| `payment_method` | string | ✅ | 支付方式:`wechat` / `alipay`(支付宝保留但本次不改造) |
|
||||||
|
|
||||||
|
- **THEN** 系统查询当前生效的微信参数配置
|
||||||
|
- **THEN** 将 `payment_config_id` 写入充值记录
|
||||||
|
|
||||||
|
**成功响应 `200 OK`(新增 `payment_config_id` 字段)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"recharge_no": "CRCH20260316100000654321",
|
||||||
|
"user_id": 100,
|
||||||
|
"wallet_id": 50,
|
||||||
|
"amount": 10000,
|
||||||
|
"payment_method": "wechat",
|
||||||
|
"payment_config_id": 1,
|
||||||
|
"status": 1,
|
||||||
|
"status_text": "待支付",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 无生效配置时拒绝第三方充值
|
||||||
|
|
||||||
|
- **WHEN** 个人客户创建充值订单(wechat/alipay),但当前无生效的微信参数配置
|
||||||
|
- **THEN** 系统返回错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1175,
|
||||||
|
"data": null,
|
||||||
|
"msg": "暂无可用的第三方支付渠道",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产充值表结构变更
|
||||||
|
|
||||||
|
`tb_asset_recharge_record` 新增字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `payment_config_id` | bigint | ❌ | 创建充值订单时使用的微信参数配置 ID(支付宝支付时为 NULL) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产充值回调按配置验签
|
||||||
|
|
||||||
|
- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH`
|
||||||
|
- **THEN** 系统查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置
|
||||||
|
- **THEN** 使用该配置的凭证验签
|
||||||
|
- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()`
|
||||||
|
|
||||||
|
> **注意**:当前代码中 `callback/payment.go` 使用废弃的 `RechargeOrderPrefix = "RCH"` 进行前缀匹配,需修复为 `AssetRechargeOrderPrefix = "CRCH"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 常量重命名(Card → Asset)
|
||||||
|
|
||||||
|
`pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀:
|
||||||
|
|
||||||
|
| 旧名称 | 新名称 |
|
||||||
|
|--------|--------|
|
||||||
|
| `CardWalletResourceTypeIotCard` | `AssetWalletResourceTypeIotCard` |
|
||||||
|
| `CardWalletResourceTypeDevice` | `AssetWalletResourceTypeDevice` |
|
||||||
|
| `CardWalletStatusNormal` | `AssetWalletStatusNormal` |
|
||||||
|
| `CardWalletStatusFrozen` | `AssetWalletStatusFrozen` |
|
||||||
|
| `CardWalletStatusClosed` | `AssetWalletStatusClosed` |
|
||||||
|
| `CardTransactionTypeRecharge` | `AssetTransactionTypeRecharge` |
|
||||||
|
| `CardTransactionTypeDeduct` | `AssetTransactionTypeDeduct` |
|
||||||
|
| `CardTransactionTypeRefund` | `AssetTransactionTypeRefund` |
|
||||||
|
| `CardRechargeOrderPrefix` | `AssetRechargeOrderPrefix` |
|
||||||
|
| `CardRechargeMinAmount` | `AssetRechargeMinAmount` |
|
||||||
|
| `CardRechargeMaxAmount` | `AssetRechargeMaxAmount` |
|
||||||
|
|
||||||
|
旧 `Card*` 常量保留为废弃别名,添加 `Deprecated` 注释。段落标题 `卡钱包常量` → `资产钱包常量`。
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 富友支付公众号 JSAPI 下单
|
||||||
|
|
||||||
|
系统 SHALL 支持通过富友支付 `wxPreCreate` 接口发起微信公众号 JSAPI 支付。
|
||||||
|
|
||||||
|
> **本次留桩**:`FuiouPayJSAPI` 方法在 Service 层定义,但实际调用第三方获取支付参数的逻辑暂不实现,返回"富友支付发起暂未实现"错误。`pkg/fuiou/` SDK 包完整实现。
|
||||||
|
|
||||||
|
#### Scenario: 公众号 JSAPI 下单成功
|
||||||
|
|
||||||
|
- **WHEN** 系统调用富友 `wxPreCreate` 接口
|
||||||
|
- `trade_type=JSAPI`
|
||||||
|
- `sub_appid=公众号AppID`(从 `tb_wechat_config.oa_app_id` 读取)
|
||||||
|
- `sub_openid=用户公众号OpenID`
|
||||||
|
- 传入订单号、金额(分)、商品描述、终端 IP、回调地址
|
||||||
|
- **THEN** 富友返回 `result_code=000000`,包含支付参数
|
||||||
|
|
||||||
|
**富友返回支付参数结构**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sdk_appid": "wx1234567890abcdef",
|
||||||
|
"sdk_timestamp": "1711411341",
|
||||||
|
"sdk_noncestr": "abc123def456",
|
||||||
|
"sdk_prepayid": "wx26112221580621e9b071c00d9e093b0000",
|
||||||
|
"sdk_package": "Sign=WXPay",
|
||||||
|
"sdk_signtype": "RSA",
|
||||||
|
"sdk_paysign": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **THEN** 系统将支付参数返回给前端,前端调用 `WeixinJSBridge.invoke('getBrandWCPayRequest', ...)` 拉起支付
|
||||||
|
|
||||||
|
#### Scenario: 公众号 JSAPI 下单失败
|
||||||
|
|
||||||
|
- **WHEN** 富友返回 `result_code` 非 `000000`
|
||||||
|
- **THEN** 系统记录 ERROR 日志(订单号、错误码、错误消息)
|
||||||
|
- **THEN** 系统返回错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1173,
|
||||||
|
"data": null,
|
||||||
|
"msg": "支付发起失败,请重试",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 富友支付小程序下单
|
||||||
|
|
||||||
|
系统 SHALL 支持通过富友支付 `wxPreCreate` 接口发起微信小程序支付。
|
||||||
|
|
||||||
|
#### Scenario: 小程序下单成功
|
||||||
|
|
||||||
|
- **WHEN** 系统调用富友 `wxPreCreate` 接口
|
||||||
|
- `trade_type=LETPAY`
|
||||||
|
- `sub_appid=小程序AppID`(从 `tb_wechat_config.miniapp_app_id` 读取)
|
||||||
|
- `sub_openid=用户小程序OpenID`
|
||||||
|
- **THEN** 富友返回 `result_code=000000`,包含支付参数
|
||||||
|
- **THEN** 系统将支付参数返回给前端,前端调用 `wx.requestPayment(...)` 拉起支付
|
||||||
|
|
||||||
|
#### Scenario: 小程序下单缺少 OpenID
|
||||||
|
|
||||||
|
- **WHEN** 系统发起小程序支付但未传入 `sub_openid`
|
||||||
|
- **THEN** 系统返回错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"data": null,
|
||||||
|
"msg": "小程序支付必须提供用户 OpenID",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 富友支付回调处理
|
||||||
|
|
||||||
|
系统 SHALL 接收并处理富友支付成功回调通知,验证签名后更新订单/充值状态。
|
||||||
|
|
||||||
|
#### Scenario: 接收到合法的支付成功回调
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/callback/fuiou-pay
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
无需认证
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体格式**:`req=<双重URL编码的GBK XML>`
|
||||||
|
|
||||||
|
- **THEN** 系统将请求体从 GBK 转换为 UTF-8
|
||||||
|
- **THEN** 系统解析 XML 格式的回调数据
|
||||||
|
- **THEN** 系统根据 `mchnt_order_no` 判断订单类型:
|
||||||
|
- `ORD` 开头 → 套餐订单 → 查询 `tb_order`
|
||||||
|
- `CRCH` 开头 → 资产充值 → 查询 `tb_asset_recharge_record`
|
||||||
|
- `ARCH` 开头 → 代理充值 → 查询 `tb_agent_recharge_record`
|
||||||
|
- **THEN** 通过记录的 `payment_config_id` 加载对应的富友配置
|
||||||
|
- **THEN** 使用该配置的富友公钥验证 RSA 签名
|
||||||
|
- **THEN** 验证 `result_code=000000` 且金额匹配
|
||||||
|
- **THEN** 调用对应 Service 的 HandlePaymentCallback
|
||||||
|
- **THEN** 返回成功 XML 响应(GBK 编码)
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="GBK"?>
|
||||||
|
<xml>
|
||||||
|
<result_code>000000</result_code>
|
||||||
|
<result_msg>success</result_msg>
|
||||||
|
</xml>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 回调签名验证失败
|
||||||
|
|
||||||
|
- **WHEN** 富友回调的 RSA 签名与本地计算不匹配
|
||||||
|
- **THEN** 系统记录 ERROR 日志
|
||||||
|
- **THEN** 返回失败 XML 响应
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="GBK"?>
|
||||||
|
<xml>
|
||||||
|
<result_code>999999</result_code>
|
||||||
|
<result_msg>signature verification failed</result_msg>
|
||||||
|
</xml>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 回调订单号不存在
|
||||||
|
|
||||||
|
- **WHEN** `mchnt_order_no` 在系统中不存在
|
||||||
|
- **THEN** 系统记录 ERROR 日志,返回失败 XML 响应
|
||||||
|
|
||||||
|
#### Scenario: 重复回调幂等处理
|
||||||
|
|
||||||
|
- **WHEN** 富友对同一订单多次发送支付成功回调
|
||||||
|
- **THEN** 系统识别已支付,直接返回成功 XML 响应
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 富友 XML 通信协议
|
||||||
|
|
||||||
|
系统 SHALL 正确处理富友支付的 XML + GBK 编码通信协议。
|
||||||
|
|
||||||
|
#### Scenario: 请求编码
|
||||||
|
|
||||||
|
- **WHEN** 系统向富友发送请求
|
||||||
|
- **THEN** 请求体为 XML 格式,GBK 编码声明
|
||||||
|
- **THEN** XML 内容经 GBK 编码后进行两次 URL 编码
|
||||||
|
- **THEN** 以 `req=<encoded_xml>` 的 form 格式发送
|
||||||
|
|
||||||
|
#### Scenario: 响应解码
|
||||||
|
|
||||||
|
- **WHEN** 系统接收富友响应
|
||||||
|
- **THEN** 先进行 URL 解码
|
||||||
|
- **THEN** 将 GBK 内容转换为 UTF-8
|
||||||
|
- **THEN** 替换 XML 声明中的 `encoding="GBK"` 为 `encoding="UTF-8"`
|
||||||
|
- **THEN** 解析 XML 到结构体
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 富友 RSA 签名算法
|
||||||
|
|
||||||
|
系统 SHALL 实现富友支付的 RSA + MD5 签名验签算法。
|
||||||
|
|
||||||
|
#### Scenario: 生成请求签名
|
||||||
|
|
||||||
|
- **WHEN** 系统需要对富友请求签名
|
||||||
|
- **THEN** 提取所有非空字段(排除 `sign` 和 `reserved_` 开头字段)
|
||||||
|
- **THEN** 按字典序排列为 `key=value&key=value` 格式
|
||||||
|
- **THEN** 将签名原文转换为 GBK 编码
|
||||||
|
- **THEN** 计算 MD5 哈希
|
||||||
|
- **THEN** 使用商户私钥对 MD5 哈希进行 RSA PKCS1v15 签名
|
||||||
|
- **THEN** 对签名结果进行 Base64 编码
|
||||||
|
|
||||||
|
#### Scenario: 验证回调签名
|
||||||
|
|
||||||
|
- **WHEN** 系统需要验证富友回调签名
|
||||||
|
- **THEN** 使用相同算法计算签名原文的 MD5 哈希
|
||||||
|
- **THEN** 使用富友公钥对回调中的 `sign` 字段进行 RSA PKCS1v15 验签
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 订单关联支付配置
|
||||||
|
|
||||||
|
系统 SHALL 在创建订单时记录当前生效的支付配置 ID,用于回调处理时加载正确的配置验签。
|
||||||
|
|
||||||
|
#### Scenario: 创建订单时记录支付配置 ID
|
||||||
|
|
||||||
|
- **WHEN** 用户创建订单(H5 或后台)
|
||||||
|
- **THEN** 系统查询当前生效的微信参数配置(`is_active=true`)
|
||||||
|
- **THEN** 将 `payment_config_id` 写入订单记录
|
||||||
|
|
||||||
|
**订单模型变更**
|
||||||
|
|
||||||
|
`tb_order` 新增字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `payment_config_id` | bigint | ❌ | 下单时使用的微信参数配置 ID(钱包/线下支付时为 NULL) |
|
||||||
|
|
||||||
|
**OrderResponse 新增返回字段**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"order_no": "ORD20260316100000123456",
|
||||||
|
"payment_config_id": 1,
|
||||||
|
"...": "(现有字段不变)"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 钱包/线下支付不记录配置 ID
|
||||||
|
|
||||||
|
- **WHEN** 用户创建订单,支付方式为 `wallet` 或 `offline`
|
||||||
|
- **THEN** 订单的 `payment_config_id` 为 NULL
|
||||||
|
|
||||||
|
#### Scenario: 无生效配置时拒绝第三方支付
|
||||||
|
|
||||||
|
- **WHEN** 用户创建订单时选择第三方支付(wechat/fuiou),但当前无生效的微信参数配置
|
||||||
|
- **THEN** 系统返回错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1175,
|
||||||
|
"data": null,
|
||||||
|
"msg": "暂无可用的第三方支付渠道,请使用钱包支付",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 无生效配置时允许钱包支付
|
||||||
|
|
||||||
|
- **WHEN** 当前无生效支付配置,用户选择钱包支付
|
||||||
|
- **THEN** 系统正常创建订单,`payment_config_id` 为 NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 第三方支付回调
|
||||||
|
|
||||||
|
系统 SHALL 处理微信支付和富友支付的支付回调。回调验签 MUST 使用订单关联的 `payment_config_id` 加载对应配置,而非当前生效配置。系统新增富友支付回调端点和代理充值回调分发。
|
||||||
|
|
||||||
|
#### Scenario: 微信支付成功回调(订单)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/callback/wechat-pay
|
||||||
|
Content-Type: 由微信服务器决定
|
||||||
|
无需认证
|
||||||
|
```
|
||||||
|
|
||||||
|
- **WHEN** 收到微信支付成功回调,订单号格式为 `ORD` 开头
|
||||||
|
- **THEN** 系统查询订单,通过 `order.payment_config_id` 加载对应支付配置
|
||||||
|
- **THEN** 系统使用该配置的凭证验证签名,更新订单状态,激活套餐
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"return_code": "SUCCESS"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 微信支付成功回调(资产充值)
|
||||||
|
|
||||||
|
- **WHEN** 收到微信支付成功回调,订单号格式为 `CRCH` 开头(修复:当前代码误用废弃的 `RCH` 前缀)
|
||||||
|
- **THEN** 系统查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载配置验签
|
||||||
|
- **THEN** 系统更新充值订单状态,增加钱包余额,触发佣金判断
|
||||||
|
|
||||||
|
#### Scenario: 微信支付成功回调(代理充值)
|
||||||
|
|
||||||
|
- **WHEN** 收到微信支付成功回调,订单号格式为 `ARCH` 开头(全新支持)
|
||||||
|
- **THEN** 系统查询 `tb_agent_recharge_record`,通过 `payment_config_id` 加载配置验签
|
||||||
|
- **THEN** 系统更新充值订单状态,增加代理余额钱包余额
|
||||||
|
|
||||||
|
#### Scenario: 富友支付成功回调
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/callback/fuiou-pay
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
无需认证
|
||||||
|
```
|
||||||
|
|
||||||
|
- **WHEN** 收到富友支付回调,`result_code=000000`
|
||||||
|
- **THEN** 系统解析 XML(GBK → UTF-8),通过 `mchnt_order_no` 判断订单类型(ORD/CRCH/ARCH)
|
||||||
|
- **THEN** 查询对应表,通过 `payment_config_id` 加载富友配置,使用富友公钥验签
|
||||||
|
- **THEN** 验证金额匹配后,调用对应 Service 的 HandlePaymentCallback
|
||||||
|
|
||||||
|
**成功响应(XML,GBK 编码)**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="GBK"?>
|
||||||
|
<xml>
|
||||||
|
<result_code>000000</result_code>
|
||||||
|
<result_msg>success</result_msg>
|
||||||
|
</xml>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 重复回调
|
||||||
|
|
||||||
|
- **WHEN** 收到已处理订单/充值的重复回调(微信或富友)
|
||||||
|
- **THEN** 系统返回成功响应,不重复处理
|
||||||
|
|
||||||
|
#### Scenario: 签名验证失败
|
||||||
|
|
||||||
|
- **WHEN** 回调签名验证失败(微信或富友)
|
||||||
|
- **THEN** 系统拒绝处理,记录 ERROR 日志,返回失败响应
|
||||||
|
|
||||||
|
#### Scenario: 订单号不存在
|
||||||
|
|
||||||
|
- **WHEN** 回调中的订单号在系统中不存在
|
||||||
|
- **THEN** 系统记录 ERROR 日志,返回失败响应
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 钱包支付与第三方支付的区别
|
||||||
|
|
||||||
|
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。第三方支付方式对前端统一显示为"微信支付",后端根据生效配置自动路由。
|
||||||
|
|
||||||
|
**后台支付方式限制**(`CreateAdminOrderRequest`):
|
||||||
|
- 允许:`wallet`、`offline`
|
||||||
|
- 拒绝:`wechat`、`alipay`、`fuiou`、其他任何值
|
||||||
|
|
||||||
|
**H5/小程序支付方式**(两步走):
|
||||||
|
- 步骤 1 创建订单:`payment_method` 为 `wallet`
|
||||||
|
- 步骤 2 发起第三方支付:通过独立端点 `/orders/:id/wechat-pay/jsapi` 等
|
||||||
|
|
||||||
|
#### Scenario: 后台参数验证拒绝第三方支付
|
||||||
|
|
||||||
|
- **WHEN** 代理在后台创建订单时 `payment_method` 为 wechat 或 fuiou
|
||||||
|
- **THEN** DTO 验证阶段拒绝请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"data": null,
|
||||||
|
"msg": "请求参数解析失败",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: H5 两步走支付
|
||||||
|
|
||||||
|
- **WHEN** 个人客户在 H5 创建订单(步骤 1)
|
||||||
|
- **THEN** 订单创建为待支付状态,记录 `payment_config_id`
|
||||||
|
- **WHEN** 客户调用 `POST /orders/:id/wechat-pay/jsapi`(步骤 2)
|
||||||
|
- **THEN** 系统按 `payment_config_id` 加载配置,根据 `provider_type` 发起对应渠道支付(本次留桩)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 配置切换不取消在途订单
|
||||||
|
|
||||||
|
- **WHEN** 管理员激活新配置时,系统中存在使用旧配置创建的待支付订单
|
||||||
|
- **THEN** 系统不取消这些订单
|
||||||
|
- **THEN** 旧订单若支付成功,回调按 `payment_config_id` 加载旧配置验签
|
||||||
|
- **THEN** 旧订单若未支付,由 30 分钟超时机制自动取消
|
||||||
@@ -0,0 +1,997 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 微信参数配置 CRUD 管理
|
||||||
|
|
||||||
|
系统 SHALL 支持平台用户对微信支付参数配置的完整生命周期管理,包括创建、列表查询、详情查询、更新、删除。每个配置包含完整的支付身份信息(渠道凭证 + 公众号 OAuth 信息 + 小程序 OAuth 信息)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 创建微信直连支付配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `POST /api/admin/wechat-configs`,`provider_type` 为 `wechat`,提供名称、公众号信息、小程序信息、商户号、API V3 密钥、证书内容(Base64)、私钥内容(Base64)、证书序列号、回调地址
|
||||||
|
|
||||||
|
**THEN** 系统创建配置记录,`is_active` 默认为 `false`,返回完整配置信息(敏感字段脱敏)
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/wechat-configs
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcdef1234567890abcdef1234567890",
|
||||||
|
"oa_token": "mytoken123",
|
||||||
|
"oa_aes_key": "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedcba0987654321fedcba0987654321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your32charv3keyhere1234567890abc",
|
||||||
|
"wx_api_v2_key": "your32charv2keyhere1234567890abc",
|
||||||
|
"wx_cert_content": "BASE64_ENCODED_CERT_CONTENT_HERE",
|
||||||
|
"wx_key_content": "BASE64_ENCODED_KEY_CONTENT_HERE",
|
||||||
|
"wx_serial_no": "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 请求字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| name | string | 是 | 配置名称,最长 100 字符 |
|
||||||
|
| description | string | 否 | 配置描述,最长 500 字符 |
|
||||||
|
| provider_type | string | 是 | 渠道类型,枚举值:`wechat`(微信直连)、`fuiou`(富友) |
|
||||||
|
| oa_app_id | string | 否 | 公众号 AppID |
|
||||||
|
| oa_app_secret | string | 否 | 公众号 AppSecret |
|
||||||
|
| oa_token | string | 否 | 公众号消息校验 Token |
|
||||||
|
| oa_aes_key | string | 否 | 公众号消息加解密 Key |
|
||||||
|
| oa_oauth_redirect_url | string | 否 | 公众号 OAuth 回调地址 |
|
||||||
|
| miniapp_app_id | string | 否 | 小程序 AppID |
|
||||||
|
| miniapp_app_secret | string | 否 | 小程序 AppSecret |
|
||||||
|
| wx_mch_id | string | provider_type=wechat 时必填 | 微信商户号 |
|
||||||
|
| wx_api_v3_key | string | provider_type=wechat 时必填 | API V3 密钥(32字符) |
|
||||||
|
| wx_api_v2_key | string | 否 | API V2 密钥(32字符) |
|
||||||
|
| wx_cert_content | string | provider_type=wechat 时必填 | 商户证书内容(Base64 编码) |
|
||||||
|
| wx_key_content | string | provider_type=wechat 时必填 | 商户私钥内容(Base64 编码) |
|
||||||
|
| wx_serial_no | string | provider_type=wechat 时必填 | 商户证书序列号 |
|
||||||
|
| wx_notify_url | string | provider_type=wechat 时必填 | 微信支付回调地址 |
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": false,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 敏感字段脱敏规则
|
||||||
|
|
||||||
|
| 字段 | 脱敏规则 | 示例原值 | 脱敏后 |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| oa_app_secret | 前4位 + `***` + 后4位 | `abcdef1234567890abcdef1234567890` | `abcd***7890` |
|
||||||
|
| oa_token | 前4位 + `***` + 后4位 | `mytoken123` | `myto***n123` |
|
||||||
|
| oa_aes_key | `[已配置]` / `[未配置]` | 任意值 | `[已配置]` |
|
||||||
|
| miniapp_app_secret | 前4位 + `***` + 后4位 | `fedcba0987654321fedcba0987654321` | `fedc***4321` |
|
||||||
|
| wx_api_v3_key | 前4位 + `***` + 后4位 | `your32charv3keyhere1234567890abc` | `your***0abc` |
|
||||||
|
| wx_api_v2_key | 前4位 + `***` + 后4位 | `your32charv2keyhere1234567890abc` | `your***0abc` |
|
||||||
|
| wx_cert_content | `[已配置]` / `[未配置]` | Base64 内容 | `[已配置]` |
|
||||||
|
| wx_key_content | `[已配置]` / `[未配置]` | Base64 内容 | `[已配置]` |
|
||||||
|
| wx_serial_no | 前4位 + `***` + 后4位 | `ABCDEF1234567890ABCDEF1234567890ABCDEF12` | `ABCD***EF12` |
|
||||||
|
| fy_private_key | `[已配置]` / `[未配置]` | Base64 内容 | `[已配置]` |
|
||||||
|
| fy_public_key | `[已配置]` / `[未配置]` | Base64 内容 | `[已配置]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 创建富友支付配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `POST /api/admin/wechat-configs`,`provider_type` 为 `fuiou`,提供名称、公众号信息、小程序信息、机构号、商户号、终端号、商户私钥(Base64)、富友公钥(Base64)、API 地址、回调地址
|
||||||
|
|
||||||
|
**THEN** 系统创建配置记录,`is_active` 默认为 `false`
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/wechat-configs
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "富友支付配置",
|
||||||
|
"description": "富友聚合支付渠道配置",
|
||||||
|
"provider_type": "fuiou",
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcdef1234567890abcdef1234567890",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedcba0987654321fedcba0987654321",
|
||||||
|
"fy_ins_cd": "0000100",
|
||||||
|
"fy_mchnt_cd": "0000100002000001",
|
||||||
|
"fy_term_id": "00000001",
|
||||||
|
"fy_private_key": "BASE64_ENCODED_MERCHANT_PRIVATE_KEY",
|
||||||
|
"fy_public_key": "BASE64_ENCODED_FUIOU_PUBLIC_KEY",
|
||||||
|
"fy_api_url": "https://spay.fuiou.com",
|
||||||
|
"fy_notify_url": "https://example.com/api/payment/fuiou/notify"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 请求字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| name | string | 是 | 配置名称,最长 100 字符 |
|
||||||
|
| description | string | 否 | 配置描述,最长 500 字符 |
|
||||||
|
| provider_type | string | 是 | 渠道类型,此处为 `fuiou` |
|
||||||
|
| oa_app_id | string | 否 | 公众号 AppID |
|
||||||
|
| oa_app_secret | string | 否 | 公众号 AppSecret |
|
||||||
|
| oa_token | string | 否 | 公众号消息校验 Token |
|
||||||
|
| oa_aes_key | string | 否 | 公众号消息加解密 Key |
|
||||||
|
| oa_oauth_redirect_url | string | 否 | 公众号 OAuth 回调地址 |
|
||||||
|
| miniapp_app_id | string | 否 | 小程序 AppID |
|
||||||
|
| miniapp_app_secret | string | 否 | 小程序 AppSecret |
|
||||||
|
| fy_ins_cd | string | provider_type=fuiou 时必填 | 富友机构号 |
|
||||||
|
| fy_mchnt_cd | string | provider_type=fuiou 时必填 | 富友商户号 |
|
||||||
|
| fy_term_id | string | provider_type=fuiou 时必填 | 富友终端号 |
|
||||||
|
| fy_private_key | string | provider_type=fuiou 时必填 | 商户私钥(Base64 编码) |
|
||||||
|
| fy_public_key | string | provider_type=fuiou 时必填 | 富友公钥(Base64 编码) |
|
||||||
|
| fy_api_url | string | provider_type=fuiou 时必填 | 富友 API 地址 |
|
||||||
|
| fy_notify_url | string | provider_type=fuiou 时必填 | 富友支付回调地址 |
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 2,
|
||||||
|
"name": "富友支付配置",
|
||||||
|
"description": "富友聚合支付渠道配置",
|
||||||
|
"provider_type": "fuiou",
|
||||||
|
"is_active": false,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "",
|
||||||
|
"oa_aes_key": "[未配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "",
|
||||||
|
"wx_api_v3_key": "",
|
||||||
|
"wx_api_v2_key": "",
|
||||||
|
"wx_cert_content": "[未配置]",
|
||||||
|
"wx_key_content": "[未配置]",
|
||||||
|
"wx_serial_no": "",
|
||||||
|
"wx_notify_url": "",
|
||||||
|
"fy_ins_cd": "0000100",
|
||||||
|
"fy_mchnt_cd": "0000100002000001",
|
||||||
|
"fy_term_id": "00000001",
|
||||||
|
"fy_private_key": "[已配置]",
|
||||||
|
"fy_public_key": "[已配置]",
|
||||||
|
"fy_api_url": "https://spay.fuiou.com",
|
||||||
|
"fy_notify_url": "https://example.com/api/payment/fuiou/notify",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 创建配置参数校验失败
|
||||||
|
|
||||||
|
**WHEN** 平台用户创建配置时缺少必填字段(如 `provider_type` 为 `wechat` 但未提供 `wx_mch_id`)
|
||||||
|
|
||||||
|
**THEN** 系统返回错误码 `1001`,拒绝创建
|
||||||
|
|
||||||
|
##### 请求示例(缺少 wx_mch_id)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/wechat-configs
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "微信直连配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"wx_api_v3_key": "your32charv3keyhere1234567890abc"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"data": null,
|
||||||
|
"msg": "参数错误",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 查询配置列表
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `GET /api/admin/wechat-configs`,支持按 `provider_type` 和 `is_active` 筛选,支持分页
|
||||||
|
|
||||||
|
**THEN** 系统返回配置列表,敏感字段脱敏
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/wechat-configs?provider_type=wechat&is_active=false&page=1&page_size=20
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 查询参数说明
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| provider_type | string | 否 | 按渠道类型筛选,枚举值:`wechat`、`fuiou` |
|
||||||
|
| is_active | boolean | 否 | 按激活状态筛选,`true` 或 `false` |
|
||||||
|
| page | integer | 否 | 页码,默认 1 |
|
||||||
|
| page_size | integer | 否 | 每页条数,默认 20,最大 100 |
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": false,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 查询配置详情
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `GET /api/admin/wechat-configs/:id`
|
||||||
|
|
||||||
|
**THEN** 系统返回配置详情,敏感字段脱敏
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/wechat-configs/1
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": false,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 配置不存在时的错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1170,
|
||||||
|
"data": null,
|
||||||
|
"msg": "微信支付配置不存在",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 更新配置(非敏感字段)
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `PUT /api/admin/wechat-configs/:id`,仅更新名称、描述、回调地址等非敏感字段
|
||||||
|
|
||||||
|
**THEN** 系统更新对应字段,敏感字段保持不变
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/admin/wechat-configs/1
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "微信直连主配置(已更新)",
|
||||||
|
"description": "更新后的描述",
|
||||||
|
"wx_notify_url": "https://new.example.com/api/payment/wechat/notify"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 请求字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| name | string | 否 | 配置名称 |
|
||||||
|
| description | string | 否 | 配置描述 |
|
||||||
|
| oa_app_id | string | 否 | 公众号 AppID |
|
||||||
|
| oa_app_secret | string | 否 | 公众号 AppSecret;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| oa_token | string | 否 | 公众号消息校验 Token;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| oa_aes_key | string | 否 | 公众号消息加解密 Key;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| oa_oauth_redirect_url | string | 否 | 公众号 OAuth 回调地址 |
|
||||||
|
| miniapp_app_id | string | 否 | 小程序 AppID |
|
||||||
|
| miniapp_app_secret | string | 否 | 小程序 AppSecret;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| wx_mch_id | string | 否 | 微信商户号 |
|
||||||
|
| wx_api_v3_key | string | 否 | API V3 密钥;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| wx_api_v2_key | string | 否 | API V2 密钥;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| wx_cert_content | string | 否 | 商户证书内容(Base64);空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| wx_key_content | string | 否 | 商户私钥内容(Base64);空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| wx_serial_no | string | 否 | 商户证书序列号;空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| wx_notify_url | string | 否 | 微信支付回调地址 |
|
||||||
|
| fy_ins_cd | string | 否 | 富友机构号 |
|
||||||
|
| fy_mchnt_cd | string | 否 | 富友商户号 |
|
||||||
|
| fy_term_id | string | 否 | 富友终端号 |
|
||||||
|
| fy_private_key | string | 否 | 商户私钥(Base64);空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| fy_public_key | string | 否 | 富友公钥(Base64);空字符串或不传 = 保留原值;传新值 = 替换 |
|
||||||
|
| fy_api_url | string | 否 | 富友 API 地址 |
|
||||||
|
| fy_notify_url | string | 否 | 富友支付回调地址 |
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置(已更新)",
|
||||||
|
"description": "更新后的描述",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": false,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://new.example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:30:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:30:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 更新当前生效配置时清除 Redis 缓存
|
||||||
|
|
||||||
|
**WHEN** 平台用户更新的配置 `is_active=true`(当前生效配置)
|
||||||
|
|
||||||
|
**THEN** 系统更新字段后,主动清除 Redis 缓存 `wechat:config:active`,使变更即时生效
|
||||||
|
|
||||||
|
**THEN** 响应格式与普通更新相同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 更新配置时替换敏感字段
|
||||||
|
|
||||||
|
**WHEN** 平台用户更新配置时,对脱敏字段传入新的明文值(非空字符串、非脱敏格式)
|
||||||
|
|
||||||
|
**THEN** 系统将该字段替换为新值
|
||||||
|
|
||||||
|
##### 请求示例(替换 API V3 密钥)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wx_api_v3_key": "newkey32charsnewkey32charsnewkey"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**THEN** 系统将 `wx_api_v3_key` 更新为新值,响应中该字段显示脱敏后的新值 `newk***ekey`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 删除未激活的配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `DELETE /api/admin/wechat-configs/:id`,且该配置 `is_active=false`
|
||||||
|
|
||||||
|
**THEN** 系统软删除该配置记录,返回成功
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/admin/wechat-configs/1
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": null,
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 禁止删除激活中的配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户尝试删除 `is_active=true` 的配置
|
||||||
|
|
||||||
|
**THEN** 系统返回错误码 `1171`,拒绝删除
|
||||||
|
|
||||||
|
##### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1171,
|
||||||
|
"data": null,
|
||||||
|
"msg": "不能删除当前生效的支付配置,请先停用",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 禁止删除有在途订单的配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户尝试删除某配置,且存在 `payment_config_id` 指向该配置的待支付订单
|
||||||
|
|
||||||
|
**THEN** 系统返回错误码 `1172`,拒绝删除
|
||||||
|
|
||||||
|
##### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1172,
|
||||||
|
"data": null,
|
||||||
|
"msg": "该配置存在未完成的支付订单,暂时无法删除",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 全局唯一激活约束
|
||||||
|
|
||||||
|
系统 SHALL 保证任意时刻最多一个支付配置处于激活状态。激活新配置时 MUST 自动停用旧配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 激活配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `POST /api/admin/wechat-configs/:id/activate`
|
||||||
|
|
||||||
|
**THEN** 系统在事务中执行:将所有 `is_active=true` 的配置设为 `false`,再将目标配置设为 `true`
|
||||||
|
|
||||||
|
**THEN** 系统清除 Redis 缓存 `wechat:config:active`
|
||||||
|
|
||||||
|
**THEN** 系统返回成功,新配置即时生效
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/wechat-configs/1/activate
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": true,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:05:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:05:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 配置不存在时的错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1170,
|
||||||
|
"data": null,
|
||||||
|
"msg": "微信支付配置不存在",
|
||||||
|
"timestamp": "2026-03-16T10:05:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 停用配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `POST /api/admin/wechat-configs/:id/deactivate`
|
||||||
|
|
||||||
|
**THEN** 系统将该配置 `is_active` 设为 `false`
|
||||||
|
|
||||||
|
**THEN** 系统清除 Redis 缓存 `wechat:config:active`
|
||||||
|
|
||||||
|
**THEN** 此后创建订单时仅支持钱包支付或线下支付
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/wechat-configs/1/deactivate
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": false,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:10:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:10:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 配置不存在时的错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1170,
|
||||||
|
"data": null,
|
||||||
|
"msg": "微信支付配置不存在",
|
||||||
|
"timestamp": "2026-03-16T10:10:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 查询当前生效配置
|
||||||
|
|
||||||
|
**WHEN** 平台用户调用 `GET /api/admin/wechat-configs/active`
|
||||||
|
|
||||||
|
**THEN** 若有生效配置,返回该配置详情(脱敏)
|
||||||
|
|
||||||
|
**THEN** 若无生效配置,`data` 返回 `null`
|
||||||
|
|
||||||
|
##### 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/wechat-configs/active
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 有生效配置时的成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "微信直连主配置",
|
||||||
|
"description": "生产环境微信直连支付配置",
|
||||||
|
"provider_type": "wechat",
|
||||||
|
"is_active": true,
|
||||||
|
"oa_app_id": "wx1234567890abcdef",
|
||||||
|
"oa_app_secret": "abcd***7890",
|
||||||
|
"oa_token": "myto***n123",
|
||||||
|
"oa_aes_key": "[已配置]",
|
||||||
|
"oa_oauth_redirect_url": "https://example.com/oauth/callback",
|
||||||
|
"miniapp_app_id": "wx9876543210fedcba",
|
||||||
|
"miniapp_app_secret": "fedc***4321",
|
||||||
|
"wx_mch_id": "1234567890",
|
||||||
|
"wx_api_v3_key": "your***0abc",
|
||||||
|
"wx_api_v2_key": "your***0abc",
|
||||||
|
"wx_cert_content": "[已配置]",
|
||||||
|
"wx_key_content": "[已配置]",
|
||||||
|
"wx_serial_no": "ABCD***EF12",
|
||||||
|
"wx_notify_url": "https://example.com/api/payment/wechat/notify",
|
||||||
|
"fy_ins_cd": "",
|
||||||
|
"fy_mchnt_cd": "",
|
||||||
|
"fy_term_id": "",
|
||||||
|
"fy_private_key": "[未配置]",
|
||||||
|
"fy_public_key": "[未配置]",
|
||||||
|
"fy_api_url": "",
|
||||||
|
"fy_notify_url": "",
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00",
|
||||||
|
"updated_at": "2026-03-16T10:05:00+08:00"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:15:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 无生效配置时的响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": null,
|
||||||
|
"msg": "当前无生效的支付配置,仅支持钱包支付",
|
||||||
|
"timestamp": "2026-03-16T10:15:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 配置切换不取消在途订单
|
||||||
|
|
||||||
|
**WHEN** 管理员激活新配置时,系统中存在使用旧配置创建的待支付订单
|
||||||
|
|
||||||
|
**THEN** 系统不取消这些订单
|
||||||
|
|
||||||
|
**THEN** 旧订单若支付成功,回调按订单关联的 `payment_config_id` 加载旧配置验签处理
|
||||||
|
|
||||||
|
**THEN** 旧订单若未支付,由现有 30 分钟超时机制自动取消
|
||||||
|
|
||||||
|
此场景无独立 API 接口,为激活接口的业务约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 生效配置 Redis 缓存
|
||||||
|
|
||||||
|
系统 SHALL 将当前生效的支付配置缓存在 Redis 中,减少数据库查询。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 缓存命中
|
||||||
|
|
||||||
|
**WHEN** 支付流程查询生效配置,Redis 缓存 `wechat:config:active` 存在
|
||||||
|
|
||||||
|
**THEN** 直接返回缓存数据,不查询数据库
|
||||||
|
|
||||||
|
此场景为内部实现约束,无独立 API 接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 缓存未命中
|
||||||
|
|
||||||
|
**WHEN** 支付流程查询生效配置,Redis 缓存 `wechat:config:active` 不存在
|
||||||
|
|
||||||
|
**THEN** 查询数据库获取 `is_active=true` 的配置
|
||||||
|
|
||||||
|
**THEN** 将结果写入 Redis,TTL 为 5 分钟
|
||||||
|
|
||||||
|
**THEN** 返回配置数据
|
||||||
|
|
||||||
|
此场景为内部实现约束,无独立 API 接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 无生效配置时缓存空标记
|
||||||
|
|
||||||
|
**WHEN** 数据库中无 `is_active=true` 的配置
|
||||||
|
|
||||||
|
**THEN** 在 Redis 写入空标记(值为 `"none"`),TTL 为 1 分钟
|
||||||
|
|
||||||
|
**THEN** 后续请求命中空标记后直接返回无配置,不穿透数据库
|
||||||
|
|
||||||
|
此场景为内部实现约束,无独立 API 接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 配置变更主动清除缓存
|
||||||
|
|
||||||
|
**WHEN** 执行激活、停用、更新生效配置、删除配置操作
|
||||||
|
|
||||||
|
**THEN** 系统主动 DEL Redis 缓存 `wechat:config:active`
|
||||||
|
|
||||||
|
此场景为内部实现约束,体现在激活、停用、更新、删除接口的副作用中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 仅平台用户可操作
|
||||||
|
|
||||||
|
系统 SHALL 限制微信参数配置管理接口仅平台用户(`user_type=1` 超级管理员和 `user_type=2` 平台用户)可访问。路由层中间件统一拦截,无需在 Service 层重复校验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 平台用户访问成功
|
||||||
|
|
||||||
|
**WHEN** 超级管理员(`user_type=1`)或平台用户(`user_type=2`)请求微信参数配置管理接口
|
||||||
|
|
||||||
|
**THEN** 系统正常处理请求
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 非平台用户拒绝访问
|
||||||
|
|
||||||
|
**WHEN** 代理账号(`user_type=3`)或企业账号(`user_type=4`)请求微信参数配置管理接口
|
||||||
|
|
||||||
|
**THEN** 系统返回错误码 `1005`,消息"无权限访问支付配置管理功能"
|
||||||
|
|
||||||
|
##### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1005,
|
||||||
|
"data": null,
|
||||||
|
"msg": "无权限访问支付配置管理功能",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 未登录用户拒绝访问
|
||||||
|
|
||||||
|
**WHEN** 请求未携带有效 Token 或 Token 已过期
|
||||||
|
|
||||||
|
**THEN** 系统返回 HTTP 401,错误码 `1002`
|
||||||
|
|
||||||
|
##### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1002,
|
||||||
|
"data": null,
|
||||||
|
"msg": "无效或已过期的认证令牌",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 审计日志
|
||||||
|
|
||||||
|
系统 SHALL 对所有微信参数配置的写操作(创建、更新、删除、激活、停用)记录操作审计日志,便于追溯配置变更历史。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 创建配置时记录审计日志
|
||||||
|
|
||||||
|
**WHEN** 平台用户成功创建微信参数配置
|
||||||
|
|
||||||
|
**THEN** 系统异步写入审计日志,记录以下信息:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| operator_id | 当前操作人 ID |
|
||||||
|
| operator_type | 操作人用户类型 |
|
||||||
|
| operation_type | `create` |
|
||||||
|
| operation_desc | `创建微信支付配置:{配置名称}` |
|
||||||
|
| after_data | 新建配置的完整数据(敏感字段脱敏后存储) |
|
||||||
|
| request_id | 当前请求 ID |
|
||||||
|
| ip_address | 操作人 IP |
|
||||||
|
| user_agent | 操作人 User-Agent |
|
||||||
|
|
||||||
|
**THEN** 审计日志写入失败不影响业务操作,失败时记录 Error 日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 更新配置时记录审计日志
|
||||||
|
|
||||||
|
**WHEN** 平台用户成功更新微信参数配置
|
||||||
|
|
||||||
|
**THEN** 系统异步写入审计日志,记录以下信息:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| operator_id | 当前操作人 ID |
|
||||||
|
| operation_type | `update` |
|
||||||
|
| operation_desc | `更新微信支付配置:{配置名称}` |
|
||||||
|
| before_data | 更新前的配置数据(敏感字段脱敏后存储) |
|
||||||
|
| after_data | 更新后的配置数据(敏感字段脱敏后存储) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 删除配置时记录审计日志
|
||||||
|
|
||||||
|
**WHEN** 平台用户成功删除微信参数配置
|
||||||
|
|
||||||
|
**THEN** 系统异步写入审计日志,记录以下信息:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| operator_id | 当前操作人 ID |
|
||||||
|
| operation_type | `delete` |
|
||||||
|
| operation_desc | `删除微信支付配置:{配置名称}` |
|
||||||
|
| before_data | 删除前的配置数据(敏感字段脱敏后存储) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 激活配置时记录审计日志
|
||||||
|
|
||||||
|
**WHEN** 平台用户成功激活微信参数配置
|
||||||
|
|
||||||
|
**THEN** 系统异步写入审计日志,记录以下信息:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| operator_id | 当前操作人 ID |
|
||||||
|
| operation_type | `activate` |
|
||||||
|
| operation_desc | `激活微信支付配置:{配置名称},原生效配置:{旧配置名称或"无"}` |
|
||||||
|
| before_data | 激活前的状态(旧生效配置 ID 和名称) |
|
||||||
|
| after_data | 激活后的状态(新生效配置 ID 和名称) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Scenario: 停用配置时记录审计日志
|
||||||
|
|
||||||
|
**WHEN** 平台用户成功停用微信参数配置
|
||||||
|
|
||||||
|
**THEN** 系统异步写入审计日志,记录以下信息:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| operator_id | 当前操作人 ID |
|
||||||
|
| operation_type | `deactivate` |
|
||||||
|
| operation_desc | `停用微信支付配置:{配置名称}` |
|
||||||
|
| before_data | 停用前的配置状态 |
|
||||||
|
| after_data | 停用后的配置状态 |
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 微信支付配置动态加载
|
||||||
|
|
||||||
|
微信支付配置 MUST 从数据库动态加载(通过 `tb_wechat_config` 表),替代原有的环境变量静态配置。Payment 实例按需创建,支持请求级 AppID 覆盖(区分公众号和小程序)。
|
||||||
|
|
||||||
|
#### Scenario: 从数据库加载配置创建 Payment 实例
|
||||||
|
|
||||||
|
- **WHEN** 支付流程需要使用微信支付
|
||||||
|
- **THEN** 系统从 Redis 缓存或数据库加载当前生效的微信参数配置(`is_active=true` 且 `provider_type=wechat`)
|
||||||
|
- **THEN** 系统使用配置中的 `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no` 创建 `payment.Payment` 实例
|
||||||
|
- **THEN** 证书内容从 Base64 解码后写入临时文件供 PowerWeChat SDK 使用
|
||||||
|
|
||||||
|
> **本次留桩**:WechatPayJSAPI 和 WechatPayH5 方法保留现有 wechatPayment 单例调用,添加 TODO 注释标记后续替换点。
|
||||||
|
|
||||||
|
#### Scenario: 无生效微信支付配置时拒绝支付
|
||||||
|
|
||||||
|
- **WHEN** 系统查询不到 `is_active=true` 的微信参数配置,或生效配置的 `provider_type` 非 `wechat`
|
||||||
|
- **THEN** 微信支付相关接口返回错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1175,
|
||||||
|
"data": null,
|
||||||
|
"msg": "当前无可用的支付渠道",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 公众号 JSAPI 支付使用公众号 AppID
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/h5/orders/:id/wechat-pay/jsapi
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `openid` | string | ✅ | 用户在公众号下的 OpenID |
|
||||||
|
|
||||||
|
- **THEN** 系统使用配置中的 `oa_app_id`(公众号 AppID)创建支付订单
|
||||||
|
- **THEN** Payer OpenID 为用户在该公众号下的 OpenID
|
||||||
|
|
||||||
|
**成功响应 `200 OK`**(本次留桩,返回结构不变)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"prepay_id": "wx26112221580621e9b071c00d9e093b0000",
|
||||||
|
"pay_config": {
|
||||||
|
"appId": "wx1234567890abcdef",
|
||||||
|
"timeStamp": "1711411341",
|
||||||
|
"nonceStr": "abc123",
|
||||||
|
"package": "prepay_id=wx26112221580621e9b071c00d9e093b0000",
|
||||||
|
"signType": "RSA",
|
||||||
|
"paySign": "..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 小程序支付使用小程序 AppID
|
||||||
|
|
||||||
|
- **WHEN** 用户在小程序中发起支付
|
||||||
|
- **THEN** 系统在调用 `JSAPITransaction` 时将 AppID 覆盖为配置中的 `miniapp_app_id`
|
||||||
|
- **THEN** Payer OpenID 为用户在该小程序下的 OpenID
|
||||||
|
|
||||||
|
#### Scenario: 微信 H5 支付
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/h5/orders/:id/wechat-pay/h5
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scene_info": {
|
||||||
|
"payer_client_ip": "14.23.150.211",
|
||||||
|
"h5_info": {
|
||||||
|
"type": "Wap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `scene_info.payer_client_ip` | string | ✅ | 用户终端 IP |
|
||||||
|
| `scene_info.h5_info.type` | string | ❌ | 场景类型:`iOS` / `Android` / `Wap` |
|
||||||
|
|
||||||
|
**成功响应 `200 OK`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx..."
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 配置缺失时系统正常启动
|
||||||
|
|
||||||
|
- **WHEN** 系统启动时数据库中无微信参数配置或配置不完整
|
||||||
|
- **THEN** 系统正常启动,支付功能降级为仅支持钱包/线下
|
||||||
|
- **THEN** 系统记录 WARN 日志"无可用微信参数配置,第三方支付功能不可用"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 微信支付回调按配置验签
|
||||||
|
|
||||||
|
系统 SHALL 接收并处理微信支付成功通知。回调验签 MUST 使用订单关联的支付配置(而非当前生效配置)。
|
||||||
|
|
||||||
|
#### Scenario: 接收到合法的支付成功通知
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/callback/wechat-pay
|
||||||
|
Content-Type: 由微信服务器决定
|
||||||
|
无需认证
|
||||||
|
```
|
||||||
|
|
||||||
|
- **WHEN** 微信回调端点收到支付成功通知
|
||||||
|
- **THEN** 系统解析通知中的商户订单号(`out_trade_no`)
|
||||||
|
- **THEN** 按订单号前缀分发(`ORD` → 套餐订单,`CRCH` → 资产充值,`ARCH` → 代理充值)
|
||||||
|
- **THEN** 查询对应表记录,通过 `payment_config_id` 加载对应的微信参数配置
|
||||||
|
- **THEN** 使用该配置的凭证通过 PowerWeChat SDK 验证回调签名
|
||||||
|
- **THEN** 调用对应 Service 的 HandlePaymentCallback
|
||||||
|
- **THEN** 返回成功响应
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"return_code": "SUCCESS"
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 订单关联的配置已被软删除
|
||||||
|
|
||||||
|
- **WHEN** 回调到达,但 `payment_config_id` 对应的配置已被软删除
|
||||||
|
- **THEN** 系统使用 `GetByIDUnscoped` 加载该配置(软删除不影响回调处理)
|
||||||
|
- **THEN** 正常完成验签和订单处理
|
||||||
|
|
||||||
|
#### Scenario: 重复回调幂等处理
|
||||||
|
|
||||||
|
- **WHEN** 微信多次发送同一订单的支付成功通知
|
||||||
|
- **THEN** 系统通过幂等检查识别已支付,直接返回成功响应
|
||||||
|
|
||||||
|
#### Scenario: 回调签名验证失败
|
||||||
|
|
||||||
|
- **WHEN** 签名无效或被篡改
|
||||||
|
- **THEN** PowerWeChat SDK 自动拒绝,系统记录 ERROR 日志,返回 HTTP 400
|
||||||
|
|
||||||
|
#### Scenario: 订单号不存在
|
||||||
|
|
||||||
|
- **WHEN** 回调中的商户订单号在系统中不存在
|
||||||
|
- **THEN** 系统记录 ERROR 日志,返回失败响应
|
||||||
115
openspec/changes/add-payment-config-management/tasks.md
Normal file
115
openspec/changes/add-payment-config-management/tasks.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
## Goal 1:微信参数配置管理 + 支付流程改造
|
||||||
|
|
||||||
|
### 1.1 前置准备:常量重命名
|
||||||
|
|
||||||
|
- [x] 1.1.1 `pkg/constants/wallet.go`:将 `Card*` 前缀常量重命名为 `Asset*`(CardWalletResourceType* → AssetWalletResourceType*、CardWalletStatus* → AssetWalletStatus*、CardTransactionType* → AssetTransactionType*、CardRechargeOrderPrefix → AssetRechargeOrderPrefix、CardRechargeMinAmount → AssetRechargeMinAmount、CardRechargeMaxAmount → AssetRechargeMaxAmount),段落标题 `卡钱包常量` → `资产钱包常量`
|
||||||
|
- [x] 1.1.2 旧 `Card*` 常量保留为废弃别名(`const CardRechargeOrderPrefix = AssetRechargeOrderPrefix`),更新废弃注释中的引用
|
||||||
|
- [x] 1.1.3 全局替换引用:将所有使用 `Card*` 常量的代码替换为 `Asset*`
|
||||||
|
|
||||||
|
### 1.1b 删除 YAML 支付配置遗留代码
|
||||||
|
|
||||||
|
> **必须在 1.3 之前完成**:这些是原有方案的残留,新方案部署后若不删除,配置和行为会产生歧义。
|
||||||
|
|
||||||
|
- [x] 1.1b.1 修改 `pkg/config/config.go`:删除 `PaymentConfig` 结构体(整体删除,含 `AppID`/`MchID`/`APIV3Key`/`APIV2Key`/`CertPath`/`KeyPath`/`SerialNo`/`NotifyURL`/`HttpDebug`/`Timeout` 所有字段);删除 `WechatConfig.Payment PaymentConfig` 字段;删除对应注释
|
||||||
|
- [x] 1.1b.2 修改 `pkg/config/defaults/config.yaml`:删除 `wechat.payment:` 整个配置节(约 10 行),**保留** `wechat.official_account:` 节不变(OAuth 仍使用 YAML 配置)
|
||||||
|
- [x] 1.1b.3 修改 `pkg/wechat/config.go`:删除 `NewPaymentApp(cfg *config.Config, ...)` 函数(从 YAML CertPath/KeyPath 文件路径创建 Payment 实例的方式已被 DB Base64 方案完全取代)
|
||||||
|
- [x] 1.1b.4 修改 `cmd/api/main.go`:从 `validateWechatConfig` 中删除所有 `wechatCfg.Payment.*` 相关校验代码(包括对 `CertPath`/`KeyPath` 的 `os.Stat` 检查、缺失字段的 `appLogger.Fatal`),**保留**对 `wechatCfg.OfficialAccount` 的校验不变
|
||||||
|
- [x] 1.1b.5 确认编译通过:删除后运行 `go build ./...` 确保无编译错误(此时 `wechatPayment` 相关代码仍保留,因为留桩期间仍在用单例)
|
||||||
|
|
||||||
|
### 1.2 数据库与基础模型
|
||||||
|
|
||||||
|
- [x] 1.2.1 创建数据库迁移文件:新建 `tb_wechat_config` 表(基础字段、OAuth 公众号字段、OAuth 小程序字段、微信直连支付字段、富友支付字段),`is_active` 默认 `false`
|
||||||
|
- [x] 1.2.2 创建数据库迁移文件:`tb_order` 新增 `payment_config_id` 列(bigint, nullable, 带索引)
|
||||||
|
- [x] 1.2.3 创建数据库迁移文件:`tb_asset_recharge_record` 新增 `payment_config_id` 列(bigint, nullable, 带索引)
|
||||||
|
- [ ] 1.2.4 执行迁移,确认表结构正确
|
||||||
|
- [x] 1.2.5 创建 `internal/model/wechat_config.go`:WechatConfig 模型(GORM 标签、TableName、渠道类型常量 `ProviderTypeWechat` / `ProviderTypeFuiou`)
|
||||||
|
- [x] 1.2.6 修改 `internal/model/order.go`:Order 模型新增 `PaymentConfigID *uint` 字段
|
||||||
|
- [x] 1.2.7 修改 `internal/model/asset_wallet.go`:AssetRechargeRecord 模型新增 `PaymentConfigID *uint` 字段
|
||||||
|
- [x] 1.2.8 创建 `internal/model/dto/wechat_config_dto.go`:请求 DTO(Create/Update/List)、响应 DTO(含脱敏逻辑方法),详细字段定义见 spec
|
||||||
|
- [x] 1.2.9 在 `pkg/constants/redis.go` 新增 `RedisWechatConfigActiveKey()` 函数
|
||||||
|
- [x] 1.2.10 在 `pkg/errors/codes.go` 新增错误码:`CodeWechatConfigNotFound=1170`、`CodeWechatConfigActive=1171`、`CodeWechatConfigHasPendingOrders=1172`、`CodeFuiouPayFailed=1173`、`CodeFuiouCallbackInvalid=1174`、`CodeNoPaymentConfig=1175`
|
||||||
|
|
||||||
|
### 1.3 微信参数配置 CRUD(Store + Service + Handler)
|
||||||
|
|
||||||
|
- [x] 1.3.1 创建 `internal/store/postgres/wechat_config_store.go`:实现 Create、GetByID、GetByIDUnscoped(含软删除)、List(分页+筛选)、Update、SoftDelete、GetActive、ActivateInTx(事务内停用所有+激活指定)、Deactivate、CountPendingOrdersByConfigID、CountPendingRechargesByConfigID
|
||||||
|
- [x] 1.3.2 创建 `internal/service/wechat_config/service.go`:实现 CRUD 业务逻辑,包含按 provider_type 校验必填字段、激活/停用(含 Redis 缓存清除)、删除保护(检查激活状态 + 在途订单 + 在途充值)、GetActiveConfig(Redis 缓存 + DB 回源 + 空标记)、更新脱敏字段处理、审计日志记录
|
||||||
|
- [x] 1.3.3 创建 `internal/handler/admin/wechat_config.go`:实现 Create、List、Get、Update、Delete、Activate、Deactivate、GetActive 共 8 个 Handler 方法
|
||||||
|
- [x] 1.3.4 创建 `internal/routes/wechat_config.go`:注册路由到 `/api/admin/wechat-configs/*`,包含平台用户权限中间件
|
||||||
|
- [x] 1.3.5 更新 `internal/bootstrap/`(types.go、stores.go、services.go、handlers.go):注册 WechatConfigStore、WechatConfigService、WechatConfigHandler
|
||||||
|
- [x] 1.3.6 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:注册 WechatConfigHandler 到文档生成器
|
||||||
|
|
||||||
|
### 1.4 富友支付 SDK
|
||||||
|
|
||||||
|
- [x] 1.4.1 创建 `pkg/fuiou/types.go`:定义 WxPreCreateRequest/Response、NotifyRequest 等 XML 结构体
|
||||||
|
- [x] 1.4.2 创建 `pkg/fuiou/client.go`:实现 Client 结构体(持有配置和 RSA 密钥对)、NewClient(从 WechatConfig 模型构造)、签名算法(字典序 → GBK → MD5 → RSA → Base64)、验签算法、HTTP 请求(XML + GBK + 双 URL 编码)、响应解码(URL 解码 → GBK→UTF-8 → XML 解析)
|
||||||
|
- [x] 1.4.3 创建 `pkg/fuiou/wxprecreate.go`:实现 WxPreCreate 方法(公众号 JSAPI + 小程序支付下单),支持 trade_type(JSAPI / LETPAY)和 sub_appid / sub_openid 参数
|
||||||
|
- [x] 1.4.4 创建 `pkg/fuiou/notify.go`:实现 VerifyNotify 方法(GBK→UTF-8 + XML 解析 + RSA 验签),BuildNotifyResponse 成功/失败响应构建
|
||||||
|
- [x] 1.4.5 在 `go.mod` 添加 `golang.org/x/text` 依赖(GBK 编解码)
|
||||||
|
|
||||||
|
### 1.5 订单支付流程改造
|
||||||
|
|
||||||
|
- [x] 1.5.1 改造 `internal/service/order/service.go` 的 `CreateH5Order` 和 `CreateAdminOrder`:注入 `wechatConfigService`(新增字段),下单时查询 active 配置 → 无配置则拒绝第三方支付 → 有配置则记录 `payment_config_id` 到订单
|
||||||
|
- [x] 1.5.2 改造 `WechatPayJSAPI` 方法(**留桩**):添加 TODO 注释 `// TODO: 从 payment_config_id 加载配置动态创建 Payment 实例`,本次保留现有 `s.wechatPayment` 单例调用不变;同时在构造函数中保留 `wechatPayment` 参数(留桩期间仍需注入)
|
||||||
|
- [x] 1.5.3 改造 `WechatPayH5` 方法(**留桩**):同 1.5.2,添加 TODO 注释,保留 `s.wechatPayment` 调用
|
||||||
|
- [x] 1.5.4 新增富友支付发起方法桩:`FuiouPayJSAPI`(返回 "富友支付发起暂未实现" 错误)和 `FuiouPayMiniApp`(同上),标记 TODO
|
||||||
|
|
||||||
|
> **留桩期间的 Bootstrap 注入**:任务 1.5.2/1.5.3 的留桩意味着 `internal/bootstrap/dependencies.go` 的 `WechatPayment` 字段、`services.go` 和 `handlers.go` 的 `deps.WechatPayment` 注入**暂时不改动**。当 WechatPayJSAPI/WechatPayH5 完成动态加载改造(留桩解除)后,再删除 `WechatPayment` 字段和所有注入点。
|
||||||
|
|
||||||
|
### 1.6 回调处理改造
|
||||||
|
|
||||||
|
- [x] 1.6.1 改造 `internal/handler/callback/payment.go` 的 `WechatPayCallback`:解析订单号 → 按前缀分发(`ORD`/`CRCH`/`ARCH`)→ 查询对应表取 `payment_config_id` → 按配置加载验签(本次留桩:验签仍用现有 `wechatPayment` 单例,添加 TODO `// TODO: 按 payment_config_id 加载配置验签`);**订单分发逻辑必须完整实现**(不是留桩),三种订单类型必须全部支持
|
||||||
|
- [x] 1.6.2 修复回调中的前缀匹配:将 `constants.RechargeOrderPrefix("RCH")` 替换为分别匹配 `constants.AssetRechargeOrderPrefix("CRCH")` 和 `constants.AgentRechargeOrderPrefix("ARCH")`;同时修复 `AlipayCallback` 中同样使用 `RechargeOrderPrefix` 的问题(第 84 行)
|
||||||
|
- [x] 1.6.3 新增 `FuiouPayCallback` Handler:接收富友回调 → 解析 XML(GBK→UTF-8)→ 按订单号前缀分发 → 查询对应记录 → 加载配置 → 验签 → 调用对应 Service.HandlePaymentCallback → 返回 XML 响应
|
||||||
|
- [x] 1.6.4 在 `internal/routes/order.go` 注册富友回调路由 `POST /api/callback/fuiou-pay`(无需认证)
|
||||||
|
- [x] 1.6.5 更新 `internal/bootstrap/handlers.go`:`NewPaymentHandler` 新增 `agentRechargeService` 参数(用于 `ARCH` 前缀分发);`WechatPayment` 参数留桩期间保留
|
||||||
|
|
||||||
|
### 1.7 资产充值模块适配
|
||||||
|
|
||||||
|
- [x] 1.7.1 改造 `internal/service/recharge/service.go` 的 `Create` 方法:创建充值订单时查询 active 配置,记录 `payment_config_id`
|
||||||
|
- [x] 1.7.2 改造 `internal/service/recharge/service.go` 的 `HandlePaymentCallback` 方法:回调时按 `payment_config_id` 加载配置验签(留桩)
|
||||||
|
|
||||||
|
### 1.8 集成验证与文档
|
||||||
|
|
||||||
|
- [ ] 1.8.1 验证完整流程:创建微信支付配置 → 激活 → 确认缓存生效 → 停用 → 确认降级为钱包支付
|
||||||
|
- [ ] 1.8.2 验证配置切换:激活配置 A → 创建订单(记录 payment_config_id=A)→ 切换到配置 B → 新订单使用 B
|
||||||
|
- [ ] 1.8.3 验证权限控制:代理/企业账号无法访问微信参数配置管理接口
|
||||||
|
- [ ] 1.8.4 验证审计日志:CRUD 和激活/停用操作产生审计记录
|
||||||
|
- [ ] 1.8.5 创建功能文档 `docs/wechat-config-management/功能总结.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal 2:代理预充值系统
|
||||||
|
|
||||||
|
### 2.1 数据库与模型
|
||||||
|
|
||||||
|
- [x] 2.1.1 创建数据库迁移文件:`tb_agent_recharge_record` 新增 `payment_config_id` 列(bigint, nullable, 带索引)
|
||||||
|
- [x] 2.1.2 修改 `internal/model/agent_wallet.go`:AgentRechargeRecord 模型新增 `PaymentConfigID *uint` 字段
|
||||||
|
- [x] 2.1.3 创建 `internal/model/dto/agent_recharge_dto.go`:CreateAgentRechargeRequest、AgentOfflinePayRequest、AgentRechargeResponse、AgentRechargeListRequest、AgentRechargeListResponse,详细字段定义见 spec
|
||||||
|
|
||||||
|
### 2.2 代理充值 Service
|
||||||
|
|
||||||
|
- [x] 2.2.1 创建 `internal/service/agent_recharge/service.go`:
|
||||||
|
- `Create`:验证权限(代理只能充自己店铺,平台可指定)→ 验证金额范围 → 查找 main 钱包 → 查询 active 配置(wechat 时必须有)→ 创建充值订单 → 记录 payment_config_id
|
||||||
|
- `OfflinePay`:验证平台权限 → 验证操作密码 → 事务内更新订单状态 + 增加钱包余额(乐观锁)+ 创建交易记录 → 审计日志
|
||||||
|
- `HandlePaymentCallback`:幂等检查 → 按 payment_config_id 验签 → 事务内更新订单状态 + 增加余额 + 创建交易记录
|
||||||
|
- `GetByID`、`List`:查询充值订单
|
||||||
|
|
||||||
|
### 2.3 代理充值 Handler + 路由
|
||||||
|
|
||||||
|
- [x] 2.3.1 创建 `internal/handler/admin/agent_recharge.go`:实现 Create、List、Get、OfflinePay 共 4 个 Handler 方法
|
||||||
|
- [x] 2.3.2 创建 `internal/routes/agent_recharge.go`:注册路由到 `/api/admin/agent-recharges/*`
|
||||||
|
- [x] 2.3.3 更新 `internal/bootstrap/`(types.go、stores.go、services.go、handlers.go):注册 AgentRechargeService、AgentRechargeHandler
|
||||||
|
- [x] 2.3.4 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:注册 AgentRechargeHandler 到文档生成器
|
||||||
|
|
||||||
|
### 2.4 代理充值回调集成
|
||||||
|
|
||||||
|
- [x] 2.4.1 在回调 Handler(1.6.2 已完成的前缀分发逻辑)中接入代理充值回调:`"ARCH"` 前缀 → `agentRechargeService.HandlePaymentCallback()`
|
||||||
|
- [x] 2.4.2 确认富友回调 Handler 也支持 `"ARCH"` 前缀分发
|
||||||
|
|
||||||
|
### 2.5 集成验证与文档
|
||||||
|
|
||||||
|
- [ ] 2.5.1 验证微信支付充值流程:创建充值订单(wechat)→ 确认 payment_config_id 记录 → 模拟回调 → 确认余额增加
|
||||||
|
- [ ] 2.5.2 验证线下充值流程:平台创建充值订单(offline)→ 确认线下支付 → 验证操作密码 → 确认余额增加
|
||||||
|
- [ ] 2.5.3 验证权限控制:代理只能充自己店铺、非平台不能线下充值、操作密码错误拒绝
|
||||||
|
- [ ] 2.5.4 验证审计日志:线下充值操作产生审计记录
|
||||||
|
- [ ] 2.5.5 创建功能文档 `docs/agent-recharge/功能总结.md`
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-16
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
`asset-detail-refactor` 建立了 `/api/admin/assets/` 路径下的完整资产体系(解析、状态、套餐、停复机),但缺少钱包维度。现有钱包体系命名混乱:`CardWallet` / `tb_card_wallet` 实际同时承载 `iot_card` 和 `device` 两种资产,名不副实。此外,H5 端个人客户用钱包支付套餐时(`WalletPay`)代码直接 `UPDATE` 余额,从未写入 `CardWalletTransaction` 流水记录,导致流水表只有充值记录、没有扣款记录。
|
||||||
|
|
||||||
|
本次设计目标:改名 + 补流水 + 新增 Admin 查询接口,三件事合并在一个变更里。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 数据表改名:`tb_card_wallet*` → `tb_asset_wallet*`,代码全量跟进
|
||||||
|
- 流水表字段变更:`reference_id (bigint)` → `reference_no (varchar 50)`,存储可读业务编号
|
||||||
|
- 补写 `WalletPay` 卡钱包路径的 `deduct` 流水,填补现有数据空白
|
||||||
|
- 新增 Admin 端资产钱包概况接口 `GET /api/admin/assets/:asset_type/:id/wallet`
|
||||||
|
- 新增 Admin 端资产钱包流水列表接口 `GET /api/admin/assets/:asset_type/:id/wallet/transactions`
|
||||||
|
- 企业账号禁止访问钱包接口;代理账号通过 `shop_id_tag` 自动过滤
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- H5 端不新增/修改任何接口(现有充值 / 订单接口 JSON 字段名不变)
|
||||||
|
- 不新增 Admin 端充值单列表接口(充值记录通过流水的 `reference_no` 跳转即可)
|
||||||
|
- 不做历史数据回填(`WalletPay` 修复只对新数据有效)
|
||||||
|
- 代理主钱包(`AgentWallet`)不在本次范围
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 决策 1:三张表全部改名,保持 `tb_asset_*` 前缀统一
|
||||||
|
|
||||||
|
```
|
||||||
|
tb_card_wallet → tb_asset_wallet
|
||||||
|
tb_card_wallet_transaction → tb_asset_wallet_transaction
|
||||||
|
tb_card_recharge_record → tb_asset_recharge_record
|
||||||
|
```
|
||||||
|
|
||||||
|
代码层 Go 类型名同步改名:
|
||||||
|
```
|
||||||
|
CardWallet → AssetWallet
|
||||||
|
CardWalletTransaction → AssetWalletTransaction
|
||||||
|
CardRechargeRecord → AssetRechargeRecord
|
||||||
|
CardWalletStore → AssetWalletStore
|
||||||
|
CardWalletTransactionStore → AssetWalletTransactionStore
|
||||||
|
CardRechargeStore → AssetRechargeStore
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis Key 常量更名:`RedisCardWalletBalanceKey` → `RedisAssetWalletBalanceKey`
|
||||||
|
|
||||||
|
### 决策 2:`reference_id (bigint)` → `reference_no (varchar 50)`
|
||||||
|
|
||||||
|
原来存主键 ID,前端无法直接用于展示或跳转;改为存业务编号:
|
||||||
|
- 充值场景:`reference_no = recharge.RechargeNo`(格式:`CRCH…`)
|
||||||
|
- 扣款场景:`reference_no = order.OrderNo`(格式:`ORD…`)
|
||||||
|
|
||||||
|
现有流水数据全部在开发阶段写入(无生产数据),直接 `ALTER COLUMN`,不需要数据迁移。
|
||||||
|
|
||||||
|
### 决策 3:`WalletPay` 卡钱包路径补写 `deduct` 流水
|
||||||
|
|
||||||
|
在卡钱包扣款成功后,在同一事务内写入 `AssetWalletTransaction`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
transaction := &model.AssetWalletTransaction{
|
||||||
|
AssetWalletID: wallet.ID,
|
||||||
|
ResourceType: resourceType, // "iot_card" 或 "device"
|
||||||
|
ResourceID: resourceID,
|
||||||
|
UserID: buyerID,
|
||||||
|
TransactionType: "deduct",
|
||||||
|
Amount: -order.TotalAmount, // 负数
|
||||||
|
BalanceBefore: walletBalanceBefore,
|
||||||
|
BalanceAfter: walletBalanceBefore - order.TotalAmount,
|
||||||
|
Status: 1,
|
||||||
|
ReferenceType: strPtr("order"),
|
||||||
|
ReferenceNo: &order.OrderNo,
|
||||||
|
Remark: strPtr("钱包支付套餐"),
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 决策 4:权限控制复用 `ApplyShopTagFilter`
|
||||||
|
|
||||||
|
`tb_asset_wallet` 和 `tb_asset_wallet_transaction` 都有 `shop_id_tag` 字段,已有 `ApplyShopTagFilter` 机制:
|
||||||
|
- 平台/超管:不添加过滤条件
|
||||||
|
- 代理用户:`WHERE shop_id_tag IN (当前店铺及下级店铺IDs)`
|
||||||
|
- 企业账号:在 Handler 层检查 `user_type == UserTypeEnterprise`,直接返回 403
|
||||||
|
|
||||||
|
### 决策 5:新接口挂载在现有 `/assets/` 路径下,由 `AssetWalletHandler` 承载
|
||||||
|
|
||||||
|
独立的 Handler 文件 `internal/handler/admin/asset_wallet.go`,通过 `AssetWalletService` 提供服务逻辑。路由注册追加到 `internal/routes/asset.go`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 合约
|
||||||
|
|
||||||
|
### 接口一:查询资产钱包概况
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/assets/:asset_type/:id/wallet
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `asset_type` | string | 资产类型:`card` 或 `device` |
|
||||||
|
| `id` | uint | 资产数据库 ID |
|
||||||
|
|
||||||
|
**请求体**:无
|
||||||
|
|
||||||
|
**成功响应** `200 OK`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"wallet_id": 123,
|
||||||
|
"resource_type": "iot_card",
|
||||||
|
"resource_id": 456,
|
||||||
|
"balance": 10000,
|
||||||
|
"frozen_balance": 0,
|
||||||
|
"available_balance": 10000,
|
||||||
|
"currency": "CNY",
|
||||||
|
"status": 1,
|
||||||
|
"status_text": "正常",
|
||||||
|
"created_at": "2026-03-10T00:00:00Z",
|
||||||
|
"updated_at": "2026-03-10T00:00:00Z"
|
||||||
|
},
|
||||||
|
"timestamp": 1741564800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `wallet_id` | uint | 钱包数据库 ID |
|
||||||
|
| `resource_type` | string | `iot_card` 或 `device` |
|
||||||
|
| `resource_id` | uint | 对应卡或设备的数据库 ID |
|
||||||
|
| `balance` | int64 | 总余额(分) |
|
||||||
|
| `frozen_balance` | int64 | 冻结余额(分) |
|
||||||
|
| `available_balance` | int64 | 可用余额 = balance - frozen_balance(分) |
|
||||||
|
| `currency` | string | 币种,目前固定 `CNY` |
|
||||||
|
| `status` | int | 钱包状态:1-正常 2-冻结 3-关闭 |
|
||||||
|
| `status_text` | string | 状态文本 |
|
||||||
|
| `created_at` | string | 创建时间(RFC3339) |
|
||||||
|
| `updated_at` | string | 更新时间(RFC3339) |
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
| 场景 | HTTP 状态码 | 错误码 | 错误消息 |
|
||||||
|
|------|------------|--------|---------|
|
||||||
|
| `asset_type` 非法 | 400 | `CodeInvalidParam` | 无效的资产类型 |
|
||||||
|
| `id` 非法 | 400 | `CodeInvalidParam` | 无效的资产ID |
|
||||||
|
| 资产不存在 | 404 | `CodeNotFound` | 资产不存在 |
|
||||||
|
| 钱包不存在 | 404 | `CodeNotFound` | 该资产暂无钱包记录 |
|
||||||
|
| 企业账号调用 | 403 | `CodeForbidden` | 企业账号无权查看钱包信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 接口二:查询资产钱包流水列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/assets/:asset_type/:id/wallet/transactions
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数**:同上
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `page` | int | 否 | 页码,默认 1 |
|
||||||
|
| `page_size` | int | 否 | 每页数量,默认 20,最大 100 |
|
||||||
|
| `transaction_type` | string | 否 | 类型过滤:`recharge` / `deduct` / `refund` |
|
||||||
|
| `start_time` | string | 否 | 开始时间(RFC3339) |
|
||||||
|
| `end_time` | string | 否 | 结束时间(RFC3339) |
|
||||||
|
|
||||||
|
**成功响应** `200 OK`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"transaction_type": "deduct",
|
||||||
|
"transaction_type_text": "扣款",
|
||||||
|
"amount": -3000,
|
||||||
|
"balance_before": 10000,
|
||||||
|
"balance_after": 7000,
|
||||||
|
"reference_type": "order",
|
||||||
|
"reference_no": "ORD20260310001",
|
||||||
|
"remark": "钱包支付套餐",
|
||||||
|
"created_at": "2026-03-10T14:20:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"transaction_type": "recharge",
|
||||||
|
"transaction_type_text": "充值",
|
||||||
|
"amount": 10000,
|
||||||
|
"balance_before": 0,
|
||||||
|
"balance_after": 10000,
|
||||||
|
"reference_type": "recharge",
|
||||||
|
"reference_no": "CRCH20260309001",
|
||||||
|
"remark": "钱包充值",
|
||||||
|
"created_at": "2026-03-09T09:15:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_pages": 1
|
||||||
|
},
|
||||||
|
"timestamp": 1741564800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明(单条流水)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | uint | 流水记录 ID |
|
||||||
|
| `transaction_type` | string | 交易类型:`recharge`/`deduct`/`refund` |
|
||||||
|
| `transaction_type_text` | string | 交易类型文本:充值/扣款/退款 |
|
||||||
|
| `amount` | int64 | 变动金额(分),充值为正数,扣款/退款为负数 |
|
||||||
|
| `balance_before` | int64 | 变动前余额(分) |
|
||||||
|
| `balance_after` | int64 | 变动后余额(分) |
|
||||||
|
| `reference_type` | string | 关联业务类型:`recharge` 或 `order`(可空) |
|
||||||
|
| `reference_no` | string | 关联业务编号:充值单号(CRCH…)或订单号(ORD…)(可空) |
|
||||||
|
| `remark` | string | 备注(可空) |
|
||||||
|
| `created_at` | string | 流水创建时间(RFC3339) |
|
||||||
|
|
||||||
|
**错误响应**:同接口一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口调用流程图
|
||||||
|
|
||||||
|
### 场景一:Admin 查看资产钱包详情(典型调用链)
|
||||||
|
|
||||||
|
```
|
||||||
|
前端(Admin 页面) API 服务 数据库
|
||||||
|
│ │ │
|
||||||
|
│── ① 解析资产 ─────────────→│ │
|
||||||
|
│ GET /assets/resolve/:identifier │
|
||||||
|
│ │── AssetService.Resolve() ──→ │
|
||||||
|
│ │ SELECT iot_card/device │
|
||||||
|
│← 返回 {asset_type, asset_id}│←────────────────────────────│
|
||||||
|
│ │ │
|
||||||
|
│── ② 查询钱包概况 ──────────→│ │
|
||||||
|
│ GET /assets/card/456/wallet │
|
||||||
|
│ │── AssetWalletService │
|
||||||
|
│ │ .GetWallet() │
|
||||||
|
│ │── SELECT tb_asset_wallet ──→ │
|
||||||
|
│ │ WHERE resource_type='iot_card'
|
||||||
|
│ │ AND resource_id=456 │
|
||||||
|
│← 返回 {balance, available…}│←────────────────────────────│
|
||||||
|
│ │ │
|
||||||
|
│── ③ 查询流水列表 ──────────→│ │
|
||||||
|
│ GET /assets/card/456/wallet/transactions?page=1 │
|
||||||
|
│ │── AssetWalletService │
|
||||||
|
│ │ .ListTransactions() │
|
||||||
|
│ │── SELECT tb_asset_wallet_transaction
|
||||||
|
│ │ WHERE resource_type='iot_card'
|
||||||
|
│ │ AND resource_id=456 │
|
||||||
|
│ │ ORDER BY created_at DESC │
|
||||||
|
│← 返回流水列表 ─────────────│←────────────────────────────│
|
||||||
|
│ [{type:deduct, ref:ORD…}, │ │
|
||||||
|
│ {type:recharge, ref:CRCH…}] │
|
||||||
|
│ │ │
|
||||||
|
│── ④(可选)前端用 reference_no 跳转到充值单或订单详情页 │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景二:H5 个人客户钱包支付(补写流水后的完整事务)
|
||||||
|
|
||||||
|
```
|
||||||
|
H5 前端 order.Service.WalletPay() 数据库(事务)
|
||||||
|
│ │ │
|
||||||
|
│── POST /orders/:id/wallet-pay→│ │
|
||||||
|
│ │ │
|
||||||
|
│ │── ① 查询订单 ─────────────────→ │
|
||||||
|
│ │ SELECT tb_order WHERE id=:id │
|
||||||
|
│ │←──────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │── ② 查询 AssetWallet ─────────→ │
|
||||||
|
│ │ SELECT tb_asset_wallet │
|
||||||
|
│ │ WHERE resource_type+resource_id│
|
||||||
|
│ │←──────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │── ③ 开启事务 ─────────────────→ │
|
||||||
|
│ │ BEGIN │
|
||||||
|
│ │ │
|
||||||
|
│ │── ④ 更新订单支付状态 ─────────→ │
|
||||||
|
│ │ UPDATE tb_order │
|
||||||
|
│ │ SET payment_status=paid │
|
||||||
|
│ │ WHERE id=:id AND status=pending│
|
||||||
|
│ │←──────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │── ⑤ 扣减钱包余额(乐观锁)────→ │
|
||||||
|
│ │ UPDATE tb_asset_wallet │
|
||||||
|
│ │ SET balance=balance-amount │
|
||||||
|
│ │ WHERE id=:id AND version=:v │
|
||||||
|
│ │←──────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │── ⑥ ★ 写入扣款流水(新增)────→ │
|
||||||
|
│ │ INSERT tb_asset_wallet_transaction
|
||||||
|
│ │ (transaction_type="deduct", │
|
||||||
|
│ │ amount=-totalAmount, │
|
||||||
|
│ │ reference_type="order", │
|
||||||
|
│ │ reference_no=order.OrderNo) │
|
||||||
|
│ │←──────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │── ⑦ 激活套餐 ─────────────────→ │
|
||||||
|
│ │ activatePackage() │
|
||||||
|
│ │←──────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │── ⑧ 提交事务 ─────────────────→ │
|
||||||
|
│ │ COMMIT │
|
||||||
|
│← 200 OK ──────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 字段变更对现有接口的影响分析
|
||||||
|
|
||||||
|
### H5 充值接口(**无 breaking change**)
|
||||||
|
|
||||||
|
以下 H5 接口 JSON 响应字段名保持不变,前端零感知:
|
||||||
|
|
||||||
|
| 接口 | JSON 字段 | Go 字段改名 | 影响 |
|
||||||
|
|------|-----------|------------|------|
|
||||||
|
| `POST /api/h5/wallets/recharge` 响应 | `wallet_id` | `CardRechargeRecord.CardWalletID` → `AssetRechargeRecord.AssetWalletID` | JSON tag 不变,**无影响** |
|
||||||
|
| `GET /api/h5/wallets/recharges` 响应 | `wallet_id` | 同上 | JSON tag 不变,**无影响** |
|
||||||
|
| `GET /api/h5/wallets/recharges/:id` 响应 | `wallet_id` | 同上 | JSON tag 不变,**无影响** |
|
||||||
|
|
||||||
|
### 新增接口中 `reference_no` 字段(**全新字段,无旧调用方**)
|
||||||
|
|
||||||
|
`tb_asset_wallet_transaction.reference_no` 是首次通过 API 暴露。变更前该字段叫 `reference_id`(uint),从未被任何接口返回,不存在已有调用方,**无 breaking change**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[风险 1] `WalletPay` 中余额快照时机**
|
||||||
|
|
||||||
|
扣款前需记录 `balance_before`,但乐观锁可能因并发重试导致实际余额与快照不符。缓解:在事务内先查询钱包余额作为快照,再执行乐观锁更新;`balance_before` = 查询值,`balance_after` = 查询值 - amount,若乐观锁失败则整个事务回滚,流水不写入。
|
||||||
|
|
||||||
|
**[风险 2] 表改名期间的零停机**
|
||||||
|
|
||||||
|
开发阶段无生产数据,直接 migration 改名,无需双写策略。
|
||||||
|
|
||||||
|
**[Trade-off] 不回填历史扣款流水**
|
||||||
|
|
||||||
|
`WalletPay` 修复前的历史订单无对应 `deduct` 流水。接受:开发阶段,测试数据可清空重来,不引入回填复杂度。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
`asset-detail-refactor` 完成了资产详情体系(解析、状态、套餐、停复机),但遗漏了资产钱包维度的查询入口:管理员无法在 Admin 端查看某张卡或设备的钱包余额与收支流水。同时,现有 `CardWallet` 系列命名(`tb_card_wallet`、`CardWalletStore`…)与实际承载的两种资产(iot_card + device)不符,趁本次新增接口前一并清理,统一更名为 `AssetWallet`。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING(内部重命名)** `CardWallet` → `AssetWallet`:三张数据库表改名,对应 Model / Store / bootstrap / Redis Key 全量重命名;H5 recharge 接口的 JSON 字段名不变,前端零感知
|
||||||
|
- **BREAKING(内部字段变更)** `tb_asset_wallet_transaction.reference_id (bigint)` → `reference_no (varchar 50)`:存储充值单号(CRCH…)或订单号(ORD…),便于前端直接跳转
|
||||||
|
- **新增** 在 `WalletPay`(卡钱包支付路径)中补写 `AssetWalletTransaction` 扣款流水(`transaction_type="deduct"`, `reference_no=order.OrderNo`),修复现有流水表中扣款记录缺失的问题
|
||||||
|
- **新增** 管理端资产钱包概况接口 `GET /api/admin/assets/:asset_type/:id/wallet`
|
||||||
|
- **新增** 管理端资产钱包流水列表接口 `GET /api/admin/assets/:asset_type/:id/wallet/transactions`
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `asset-wallet-query`:Admin 端查询资产(卡/设备)关联钱包的余额概况与收支流水,支持分页,含充值/扣款流水的来源编号可跳转
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `asset-wallet`:`tb_card_wallet`、`tb_card_wallet_transaction`、`tb_card_recharge_record` 三张表统一改名为 `tb_asset_wallet`、`tb_asset_wallet_transaction`、`tb_asset_recharge_record`;`reference_id (bigint)` 字段改为 `reference_no (varchar 50)`;`WalletPay` 补写扣款 transaction 记录
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
**数据库迁移**:
|
||||||
|
- `tb_card_wallet` → `tb_asset_wallet`(含 `reference_id` → `reference_no` 字段变更)
|
||||||
|
- `tb_card_wallet_transaction` → `tb_asset_wallet_transaction`
|
||||||
|
- `tb_card_recharge_record` → `tb_asset_recharge_record`
|
||||||
|
|
||||||
|
**Model 层**:
|
||||||
|
- `internal/model/card_wallet.go` 全量重命名:`CardWallet` → `AssetWallet`、`CardWalletTransaction` → `AssetWalletTransaction`、`CardRechargeRecord` → `AssetRechargeRecord`
|
||||||
|
- `AssetWalletTransaction.ReferenceID *uint` → `ReferenceNo *string`
|
||||||
|
|
||||||
|
**Store 层**:
|
||||||
|
- `card_wallet_store.go` → `asset_wallet_store.go`(`CardWalletStore` → `AssetWalletStore`)
|
||||||
|
- `card_wallet_transaction_store.go` → `asset_wallet_transaction_store.go`
|
||||||
|
- `card_recharge_store.go` → `asset_recharge_store.go`
|
||||||
|
|
||||||
|
**Service 层**:
|
||||||
|
- `internal/service/order/service.go`:`cardWalletStore` → `assetWalletStore`;`WalletPay` 卡钱包路径补写扣款 transaction
|
||||||
|
- `internal/service/recharge/service.go`:`cardWalletStore` → `assetWalletStore`;交易记录写入 `ReferenceNo = recharge.RechargeNo`
|
||||||
|
|
||||||
|
**Handler 层**:
|
||||||
|
- 新增 `internal/handler/admin/asset_wallet.go`:`AssetWalletHandler`(两个新 Handler 方法)
|
||||||
|
|
||||||
|
**路由层**:
|
||||||
|
- `internal/routes/asset.go` 新增两条路由(`wallet`、`wallet/transactions`)
|
||||||
|
|
||||||
|
**Bootstrap 层**:
|
||||||
|
- `internal/bootstrap/stores.go`:`CardWallet*` → `AssetWallet*`
|
||||||
|
- `internal/bootstrap/services.go`:更新依赖注入
|
||||||
|
|
||||||
|
**常量层**:
|
||||||
|
- `pkg/constants/redis.go`:`RedisCardWalletBalanceKey` → `RedisAssetWalletBalanceKey`
|
||||||
|
|
||||||
|
**DTO 层**:
|
||||||
|
- `internal/model/dto/` 新增 `asset_wallet_dto.go`:`AssetWalletResponse`、`AssetWalletTransactionListRequest`、`AssetWalletTransactionListResponse`、`AssetWalletTransactionItem`
|
||||||
|
|
||||||
|
**API 文档**:
|
||||||
|
- `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 新增 `AssetWalletHandler`
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin 端查询资产钱包概况
|
||||||
|
|
||||||
|
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet` 接口,允许平台用户和代理账号查询指定卡或设备的钱包余额概况。
|
||||||
|
|
||||||
|
**接口规格**:
|
||||||
|
- 路径参数 `asset_type`:`card` 或 `device`
|
||||||
|
- 路径参数 `id`:资产数据库 ID(uint)
|
||||||
|
- 无请求体
|
||||||
|
- 返回字段:`wallet_id`、`resource_type`、`resource_id`、`balance`、`frozen_balance`、`available_balance`、`currency`、`status`、`status_text`、`created_at`、`updated_at`
|
||||||
|
|
||||||
|
**权限规则**:
|
||||||
|
- 平台用户/超级管理员:可查询所有资产钱包
|
||||||
|
- 代理账号:只能查询 `shop_id_tag IN (当前店铺及下级店铺)` 的资产钱包(由 `ApplyShopTagFilter` 自动过滤)
|
||||||
|
- 企业账号:Handler 层直接返回 403,禁止访问
|
||||||
|
|
||||||
|
#### Scenario: 平台用户查询卡钱包概况
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡存在钱包记录,余额 100 元,冻结 0 元
|
||||||
|
- **THEN** 系统返回 200,`balance=10000`,`frozen_balance=0`,`available_balance=10000`,`status=1`,`status_text="正常"`
|
||||||
|
|
||||||
|
#### Scenario: 代理账号查询下级资产钱包
|
||||||
|
|
||||||
|
- **WHEN** 代理账号(shop_id=10)请求 `GET /api/admin/assets/device/789/wallet`,该设备的 `shop_id_tag` 在该代理的下级店铺范围内
|
||||||
|
- **THEN** 系统返回 200,返回该设备的钱包详情
|
||||||
|
|
||||||
|
#### Scenario: 代理账号查询越权资产钱包
|
||||||
|
|
||||||
|
- **WHEN** 代理账号(shop_id=10)请求 `GET /api/admin/assets/card/999/wallet`,该卡的 `shop_id_tag` 不在该代理的下级店铺范围内
|
||||||
|
- **THEN** 系统返回 404,错误消息为"该资产暂无钱包记录"(不区分"无权"与"不存在")
|
||||||
|
|
||||||
|
#### Scenario: 企业账号请求被拒绝
|
||||||
|
|
||||||
|
- **WHEN** 企业账号请求 `GET /api/admin/assets/card/456/wallet`
|
||||||
|
- **THEN** 系统返回 403,错误消息为"企业账号无权查看钱包信息"
|
||||||
|
|
||||||
|
#### Scenario: 资产无钱包记录
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡尚未创建钱包(未充值过)
|
||||||
|
- **THEN** 系统返回 404,错误消息为"该资产暂无钱包记录"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Admin 端查询资产钱包流水列表
|
||||||
|
|
||||||
|
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet/transactions` 接口,允许平台用户和代理账号分页查询指定资产的钱包收支流水,每条流水包含可跳转的来源编号。
|
||||||
|
|
||||||
|
**接口规格**:
|
||||||
|
- 路径参数:同上
|
||||||
|
- 查询参数:`page`(默认 1)、`page_size`(默认 20,最大 100)、`transaction_type`(可选过滤)、`start_time`(可选)、`end_time`(可选)
|
||||||
|
- 流水按 `created_at` 倒序排列
|
||||||
|
- 每条流水返回:`id`、`transaction_type`、`transaction_type_text`、`amount`、`balance_before`、`balance_after`、`reference_type`、`reference_no`、`remark`、`created_at`
|
||||||
|
|
||||||
|
**来源编号跳转规则**:
|
||||||
|
- `reference_type = "recharge"` → `reference_no` 为充值单号(`CRCH…`),前端可跳转至充值单详情
|
||||||
|
- `reference_type = "order"` → `reference_no` 为订单号(`ORD…`),前端可跳转至订单详情
|
||||||
|
|
||||||
|
**权限规则**:与钱包概况接口相同
|
||||||
|
|
||||||
|
#### Scenario: 查询充值和扣款流水
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?page=1&page_size=20`,该卡有 1 条充值流水(100 元)和 1 条扣款流水(-30 元)
|
||||||
|
- **THEN** 系统返回 200,`total=2`,按时间倒序返回两条记录,充值流水 `amount=10000`、`reference_type="recharge"`、`reference_no="CRCH20260309001"`;扣款流水 `amount=-3000`、`reference_type="order"`、`reference_no="ORD20260310001"`
|
||||||
|
|
||||||
|
#### Scenario: 按交易类型过滤
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?transaction_type=recharge`
|
||||||
|
- **THEN** 系统只返回 `transaction_type="recharge"` 的流水记录
|
||||||
|
|
||||||
|
#### Scenario: 分页超出范围
|
||||||
|
|
||||||
|
- **WHEN** 请求 `page_size=200`(超过最大值 100)
|
||||||
|
- **THEN** 系统返回 400,错误消息为参数验证失败
|
||||||
|
|
||||||
|
#### Scenario: 资产无流水记录
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求某资产的流水列表,该资产钱包存在但尚无任何流水
|
||||||
|
- **THEN** 系统返回 200,`list=[]`,`total=0`
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 卡钱包实体定义
|
||||||
|
|
||||||
|
系统 SHALL 定义资产钱包(AssetWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。原 `CardWallet` / `tb_card_wallet` 全量改名为 `AssetWallet` / `tb_asset_wallet`。
|
||||||
|
|
||||||
|
**核心概念**:
|
||||||
|
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
|
||||||
|
- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走
|
||||||
|
|
||||||
|
**实体字段(与原 CardWallet 完全一致,仅表名改变)**:
|
||||||
|
- `id`:钱包 ID(主键,BIGINT,自增)
|
||||||
|
- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card" | "device")
|
||||||
|
- `resource_id`:资源 ID(BIGINT)
|
||||||
|
- `balance`:余额(BIGINT,单位:分,默认 0)
|
||||||
|
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0)
|
||||||
|
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||||
|
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1)
|
||||||
|
- `version`:版本号(INT,乐观锁)
|
||||||
|
- `shop_id_tag`:店铺 ID 标签(多租户过滤)
|
||||||
|
- `enterprise_id_tag`:企业 ID 标签(可空)
|
||||||
|
- `created_at` / `updated_at` / `deleted_at`
|
||||||
|
|
||||||
|
**表名变更**:`tb_card_wallet` → `tb_asset_wallet`
|
||||||
|
|
||||||
|
#### Scenario: 创建物联网卡钱包
|
||||||
|
|
||||||
|
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录,为该卡充值
|
||||||
|
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`,`resource_type` 为 "iot_card",`resource_id` 为卡 ID
|
||||||
|
|
||||||
|
#### Scenario: 创建设备钱包
|
||||||
|
|
||||||
|
- **WHEN** 个人客户通过设备号登录,为设备充值
|
||||||
|
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`,`resource_type` 为 "device",设备的所有卡共享该钱包
|
||||||
|
|
||||||
|
#### Scenario: 计算可用余额
|
||||||
|
|
||||||
|
- **WHEN** 钱包余额 10000 分,冻结余额 3000 分
|
||||||
|
- **THEN** 可用余额 = 7000 分
|
||||||
|
|
||||||
|
#### Scenario: 防止同一资源重复创建钱包
|
||||||
|
|
||||||
|
- **WHEN** 物联网卡(ID=100)已有钱包,尝试再次创建
|
||||||
|
- **THEN** 系统拒绝,返回错误"该资源已存在钱包"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产钱包交易记录
|
||||||
|
|
||||||
|
系统 SHALL 记录所有资产钱包余额变动,包括充值、套餐扣费、退款,确保完整收支审计追踪。原 `CardWalletTransaction` / `tb_card_wallet_transaction` 全量改名为 `AssetWalletTransaction` / `tb_asset_wallet_transaction`,同时 `reference_id (bigint)` 字段改为 `reference_no (varchar 50)`。
|
||||||
|
|
||||||
|
**实体字段**:
|
||||||
|
- `id`:交易记录 ID(主键)
|
||||||
|
- `asset_wallet_id`:资产钱包 ID(关联 `tb_asset_wallet.id`,原 `card_wallet_id`)
|
||||||
|
- `resource_type`:资源类型(冗余字段)
|
||||||
|
- `resource_id`:资源 ID(冗余字段)
|
||||||
|
- `user_id`:操作人用户 ID
|
||||||
|
- `transaction_type`:交易类型(`recharge` / `deduct` / `refund`)
|
||||||
|
- `amount`:变动金额(分,充值为正,扣款/退款为负)
|
||||||
|
- `balance_before`:变动前余额(分)
|
||||||
|
- `balance_after`:变动后余额(分)
|
||||||
|
- `status`:交易状态(1-成功 2-失败 3-处理中)
|
||||||
|
- `reference_type`:关联业务类型(`recharge` 或 `order`,可空)
|
||||||
|
- `reference_no`:关联业务编号,存储充值单号(`CRCH…`)或订单号(`ORD…`)(VARCHAR(50),可空)— **原字段 `reference_id (bigint)` 改名并变更类型**
|
||||||
|
- `remark`:备注(TEXT,可空)
|
||||||
|
- `metadata`:扩展信息(JSONB,可空)
|
||||||
|
- `creator`:创建人 ID
|
||||||
|
- `shop_id_tag` / `enterprise_id_tag`:多租户标签
|
||||||
|
|
||||||
|
**表名变更**:`tb_card_wallet_transaction` → `tb_asset_wallet_transaction`
|
||||||
|
|
||||||
|
**字段变更**:`reference_id bigint` → `reference_no varchar(50)`
|
||||||
|
|
||||||
|
#### Scenario: 充值写入流水记录
|
||||||
|
|
||||||
|
- **WHEN** 个人客户完成充值(充值单号 CRCH20260309001,金额 100 元),充值回调成功
|
||||||
|
- **THEN** 系统在 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="recharge"`,`amount=10000`,`reference_type="recharge"`,`reference_no="CRCH20260309001"`
|
||||||
|
|
||||||
|
#### Scenario: 钱包支付套餐写入扣款流水
|
||||||
|
|
||||||
|
- **WHEN** 个人客户使用钱包支付套餐订单(订单号 ORD20260310001,金额 30 元),`WalletPay` 执行成功
|
||||||
|
- **THEN** 系统在同一事务内向 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="deduct"`,`amount=-3000`,`reference_type="order"`,`reference_no="ORD20260310001"`,`balance_before` 为扣款前余额,`balance_after` = `balance_before - 3000`
|
||||||
|
|
||||||
|
#### Scenario: 充值流水 reference_no 格式
|
||||||
|
|
||||||
|
- **WHEN** 系统写入充值流水
|
||||||
|
- **THEN** `reference_no` 存储充值单号(格式:`CRCH` + 时间戳 + 随机数),而非数据库主键 ID
|
||||||
|
|
||||||
|
#### Scenario: 扣款流水 reference_no 格式
|
||||||
|
|
||||||
|
- **WHEN** 系统写入扣款流水
|
||||||
|
- **THEN** `reference_no` 存储订单号(格式:`ORD` + 时间戳 + 6位随机数),而非数据库主键 ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 充值记录表改名
|
||||||
|
|
||||||
|
系统 SHALL 将原 `tb_card_recharge_record` 表重命名为 `tb_asset_recharge_record`,对应 Go 类型由 `CardRechargeRecord` 改名为 `AssetRechargeRecord`。H5 充值接口 JSON 响应字段 `wallet_id` 不变(保持向后兼容)。
|
||||||
|
|
||||||
|
#### Scenario: H5 充值接口字段不变
|
||||||
|
|
||||||
|
- **WHEN** 前端调用 `GET /api/h5/wallets/recharges/:id`,充值记录关联的钱包 ID 为 123
|
||||||
|
- **THEN** 响应 JSON 中 `wallet_id` 仍为 `123`,JSON 字段名不变(仅 Go 内部字段名从 `CardWalletID` 改为 `AssetWalletID`)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
## 1. 数据库迁移(先行)
|
||||||
|
|
||||||
|
- [x] 1.1 创建迁移文件:`tb_card_wallet` → `tb_asset_wallet`,`tb_card_wallet_transaction` → `tb_asset_wallet_transaction`,`tb_card_recharge_record` → `tb_asset_recharge_record`(三张表在同一个迁移文件中完成)
|
||||||
|
- [x] 1.2 创建迁移文件:`tb_asset_wallet_transaction.reference_id (bigint, nullable)` → `reference_no (varchar 50, nullable)`(ALTER TABLE RENAME COLUMN + ALTER COLUMN TYPE)
|
||||||
|
- [x] 1.3 执行全部迁移,使用 PostgreSQL MCP 确认三张表改名成功,`reference_no` 字段类型为 varchar(50)
|
||||||
|
|
||||||
|
## 2. Model 层全量重命名
|
||||||
|
|
||||||
|
- [x] 2.1 重命名 `internal/model/card_wallet.go` → `internal/model/asset_wallet.go`,文件内所有类型改名:
|
||||||
|
- `CardWallet` → `AssetWallet`,`TableName()` 返回 `"tb_asset_wallet"`
|
||||||
|
- `CardWalletTransaction` → `AssetWalletTransaction`,`TableName()` 返回 `"tb_asset_wallet_transaction"`
|
||||||
|
- `CardRechargeRecord` → `AssetRechargeRecord`,`TableName()` 返回 `"tb_asset_recharge_record"`
|
||||||
|
- [x] 2.2 更新 `AssetWalletTransaction` 结构体字段:`CardWalletID uint json:"card_wallet_id"` → `AssetWalletID uint json:"asset_wallet_id"`(GORM column tag 同步更新为 `column:asset_wallet_id`);`ReferenceID *uint json:"reference_id,omitempty"` → `ReferenceNo *string json:"reference_no,omitempty"`(GORM column tag 改为 `column:reference_no;type:varchar(50)`)
|
||||||
|
- [x] 2.3 更新 `AssetRechargeRecord` 结构体字段:`CardWalletID uint json:"card_wallet_id"` → `AssetWalletID uint json:"asset_wallet_id"`(GORM column tag 同步更新)
|
||||||
|
- [x] 2.4 运行 `go build ./...` 确认 Model 层无编译错误
|
||||||
|
|
||||||
|
## 3. Store 层全量重命名
|
||||||
|
|
||||||
|
- [x] 3.1 重命名 `internal/store/postgres/card_wallet_store.go` → `asset_wallet_store.go`,类型 `CardWalletStore` → `AssetWalletStore`,构造函数 `NewCardWalletStore` → `NewAssetWalletStore`,方法内 `model.CardWallet` → `model.AssetWallet`
|
||||||
|
- [x] 3.2 重命名 `internal/store/postgres/card_wallet_transaction_store.go` → `asset_wallet_transaction_store.go`,类型 `CardWalletTransactionStore` → `AssetWalletTransactionStore`,构造函数及方法内 Model 引用同步更新
|
||||||
|
- [x] 3.3 重命名 `internal/store/postgres/card_recharge_store.go` → `asset_recharge_store.go`,类型 `CardRechargeStore` → `AssetRechargeStore`,构造函数及方法内 Model 引用同步更新
|
||||||
|
- [x] 3.4 运行 `go build ./...` 确认 Store 层无编译错误
|
||||||
|
|
||||||
|
## 4. Bootstrap 层更新
|
||||||
|
|
||||||
|
- [x] 4.1 更新 `internal/bootstrap/stores.go`:字段名 `CardWallet` → `AssetWallet`,`CardWalletTransaction` → `AssetWalletTransaction`,`CardRecharge` → `AssetRecharge`;构造函数调用同步更新为 `NewAssetWalletStore`、`NewAssetWalletTransactionStore`、`NewAssetRechargeStore`
|
||||||
|
- [x] 4.2 更新 `internal/bootstrap/services.go`:依赖注入中所有 `s.CardWallet*` 引用改为 `s.AssetWallet*`、`s.CardRecharge` 改为 `s.AssetRecharge`
|
||||||
|
- [x] 4.3 运行 `go build ./...` 确认 bootstrap 层无编译错误
|
||||||
|
|
||||||
|
## 5. 常量层更新
|
||||||
|
|
||||||
|
- [x] 5.1 更新 `pkg/constants/redis.go`:`RedisCardWalletBalanceKey` → `RedisAssetWalletBalanceKey`,函数体不变,仅函数名改变
|
||||||
|
- [x] 5.2 全局搜索 `RedisCardWalletBalanceKey` 调用处(card_wallet_store.go),替换为 `RedisAssetWalletBalanceKey`
|
||||||
|
|
||||||
|
## 6. Service 层适配:order service
|
||||||
|
|
||||||
|
- [x] 6.1 更新 `internal/service/order/service.go`:结构体字段 `cardWalletStore *postgres.CardWalletStore` → `assetWalletStore *postgres.AssetWalletStore`,构造函数参数及所有调用点同步更新
|
||||||
|
- [x] 6.2 在 `WalletPay` 卡钱包支付路径(`resourceType != "shop"` 分支)中,扣款成功后在同一事务内补写 `AssetWalletTransaction` 扣款流水:
|
||||||
|
- 在事务内扣款前记录 `balanceBefore = wallet.Balance`
|
||||||
|
- 扣款成功(`RowsAffected == 1`)后,`INSERT` 一条 `AssetWalletTransaction`:`AssetWalletID=wallet.ID`、`ResourceType=resourceType`、`ResourceID=resourceID`、`UserID=buyerID`、`TransactionType="deduct"`、`Amount=-order.TotalAmount`、`BalanceBefore=balanceBefore`、`BalanceAfter=balanceBefore-order.TotalAmount`、`Status=1`、`ReferenceType=strPtr("order")`、`ReferenceNo=&order.OrderNo`、`Remark=strPtr("钱包支付套餐")`、`ShopIDTag=wallet.ShopIDTag`、`EnterpriseIDTag=wallet.EnterpriseIDTag`
|
||||||
|
- [x] 6.3 运行 `go build ./...` 确认 order service 无编译错误
|
||||||
|
|
||||||
|
## 7. Service 层适配:recharge service
|
||||||
|
|
||||||
|
- [x] 7.1 更新 `internal/service/recharge/service.go`:结构体字段及构造函数 `cardWalletStore` → `assetWalletStore`,`cardWalletTransactionStore` → `assetWalletTransactionStore`;所有 Model 引用 `model.CardWallet*` → `model.AssetWallet*`
|
||||||
|
- [x] 7.2 更新充值回调写入流水记录处(约第 320 行):`ReferenceID: &recharge.ID` → `ReferenceNo: &recharge.RechargeNo`(同时删除原 `ReferenceID` 字段赋值)
|
||||||
|
- [x] 7.3 运行 `go build ./...` 确认 recharge service 无编译错误
|
||||||
|
|
||||||
|
## 8. DTO 新增
|
||||||
|
|
||||||
|
- [x] 8.1 新建 `internal/model/dto/asset_wallet_dto.go`,定义以下 DTO(含所有字段及 `description` tag):
|
||||||
|
|
||||||
|
**AssetWalletResponse**(钱包概况响应):
|
||||||
|
- `wallet_id uint`、`resource_type string`、`resource_id uint`
|
||||||
|
- `balance int64`、`frozen_balance int64`、`available_balance int64`
|
||||||
|
- `currency string`、`status int`、`status_text string`
|
||||||
|
- `created_at time.Time`、`updated_at time.Time`
|
||||||
|
|
||||||
|
**AssetWalletTransactionListRequest**(流水列表请求,查询参数):
|
||||||
|
- `page int`(默认 1)、`page_size int`(默认 20,最大 100)
|
||||||
|
- `transaction_type *string`(可选,oneof=recharge deduct refund)
|
||||||
|
- `start_time *time.Time`(可选)、`end_time *time.Time`(可选)
|
||||||
|
|
||||||
|
**AssetWalletTransactionItem**(单条流水):
|
||||||
|
- `id uint`
|
||||||
|
- `transaction_type string`、`transaction_type_text string`
|
||||||
|
- `amount int64`、`balance_before int64`、`balance_after int64`
|
||||||
|
- `reference_type *string`、`reference_no *string`
|
||||||
|
- `remark *string`
|
||||||
|
- `created_at time.Time`
|
||||||
|
|
||||||
|
**AssetWalletTransactionListResponse**(流水列表响应):
|
||||||
|
- `list []*AssetWalletTransactionItem`
|
||||||
|
- `total int64`、`page int`、`page_size int`、`total_pages int`
|
||||||
|
|
||||||
|
- [x] 8.2 运行 `lsp_diagnostics` 确认 DTO 无错误
|
||||||
|
|
||||||
|
## 9. AssetWalletService 新增
|
||||||
|
|
||||||
|
- [x] 9.1 新建 `internal/service/asset_wallet/service.go`,定义 `Service` 结构体,依赖注入:`AssetWalletStore`、`AssetWalletTransactionStore`
|
||||||
|
- [x] 9.2 实现 `GetWallet(ctx, assetType string, assetID uint) (*dto.AssetWalletResponse, error)`:
|
||||||
|
- 将 `assetType`(`card`/`device`)映射到 `resourceType`(`iot_card`/`device`)
|
||||||
|
- 调用 `AssetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)`
|
||||||
|
- 组装 `AssetWalletResponse`(计算 `available_balance = balance - frozen_balance`,翻译 `status_text`)
|
||||||
|
- 钱包不存在时返回 `errors.New(errors.CodeNotFound, "该资产暂无钱包记录")`
|
||||||
|
- [x] 9.3 实现 `ListTransactions(ctx, assetType string, assetID uint, req *dto.AssetWalletTransactionListRequest) (*dto.AssetWalletTransactionListResponse, error)`:
|
||||||
|
- 将 `assetType` 映射为 `resourceType`
|
||||||
|
- 调用 `AssetWalletTransactionStore.ListByResourceID(ctx, resourceType, assetID, offset, limit)` 和 `CountByResourceID` 获取分页数据
|
||||||
|
- 组装响应:翻译 `transaction_type_text`(recharge→充值 / deduct→扣款 / refund→退款),计算 `total_pages`
|
||||||
|
- 如有 `transaction_type` 过滤参数,在 Store 层新增对应过滤方法(或在 Service 层 in-memory 过滤——推荐 Store 层)
|
||||||
|
- [x] 9.4 运行 `lsp_diagnostics` 确认 Service 无错误
|
||||||
|
|
||||||
|
## 10. Store 层新增查询方法
|
||||||
|
|
||||||
|
- [x] 10.1 在 `AssetWalletTransactionStore` 中新增 `ListByResourceIDWithFilter(ctx, resourceType string, resourceID uint, transactionType *string, startTime, endTime *time.Time, offset, limit int) ([]*model.AssetWalletTransaction, error)` 方法,支持 `transaction_type`、时间范围过滤,应用 `ApplyShopTagFilter` 数据权限
|
||||||
|
- [x] 10.2 在 `AssetWalletTransactionStore` 中新增 `CountByResourceIDWithFilter(ctx, resourceType string, resourceID uint, transactionType *string, startTime, endTime *time.Time) (int64, error)` 方法
|
||||||
|
- [x] 10.3 运行 `lsp_diagnostics` 确认 Store 无错误
|
||||||
|
|
||||||
|
## 11. AssetWalletHandler 新增
|
||||||
|
|
||||||
|
- [x] 11.1 新建 `internal/handler/admin/asset_wallet.go`,定义 `AssetWalletHandler` 结构体(依赖 `*assetWalletSvc.Service`),实现两个 Handler 方法:
|
||||||
|
|
||||||
|
**GetWallet**(`GET /api/admin/assets/:asset_type/:id/wallet`):
|
||||||
|
- 检查企业账号:`user_type == UserTypeEnterprise` → 返回 403
|
||||||
|
- 解析路径参数 `asset_type`(校验为 `card` 或 `device`)和 `id`(校验为正整数)
|
||||||
|
- 调用 `assetWalletSvc.GetWallet(ctx, assetType, id)` → 返回 `response.Success`
|
||||||
|
|
||||||
|
**ListTransactions**(`GET /api/admin/assets/:asset_type/:id/wallet/transactions`):
|
||||||
|
- 检查企业账号:同上
|
||||||
|
- 解析路径参数和查询参数(`QueryParser` 绑定 `AssetWalletTransactionListRequest`)
|
||||||
|
- 参数验证:`page_size` 最大 100,`transaction_type` 需为合法枚举值
|
||||||
|
- 调用 `assetWalletSvc.ListTransactions(ctx, assetType, id, &req)` → 返回 `response.Success`
|
||||||
|
|
||||||
|
- [x] 11.2 运行 `lsp_diagnostics` 确认 Handler 无编译错误
|
||||||
|
|
||||||
|
## 12. 路由注册
|
||||||
|
|
||||||
|
- [x] 12.1 在 `internal/routes/asset.go` 的 `registerAssetRoutes` 函数末尾追加两条路由(需传入 `*admin.AssetWalletHandler` 参数):
|
||||||
|
|
||||||
|
```go
|
||||||
|
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet", walletHandler.GetWallet, RouteSpec{
|
||||||
|
Summary: "资产钱包概况",
|
||||||
|
Description: "查询指定卡或设备的钱包余额概况。企业账号禁止调用。",
|
||||||
|
Tags: []string{"资产管理"},
|
||||||
|
Input: new(dto.AssetTypeIDRequest),
|
||||||
|
Output: new(dto.AssetWalletResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet/transactions", walletHandler.ListTransactions, RouteSpec{
|
||||||
|
Summary: "资产钱包流水列表",
|
||||||
|
Description: "分页查询指定资产的钱包收支流水,含充值/扣款来源编号。企业账号禁止调用。",
|
||||||
|
Tags: []string{"资产管理"},
|
||||||
|
Input: new(dto.AssetWalletTransactionListRequest),
|
||||||
|
Output: new(dto.AssetWalletTransactionListResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 12.2 更新 `registerAssetRoutes` 函数签名,增加 `walletHandler *admin.AssetWalletHandler` 参数
|
||||||
|
- [x] 12.3 更新 `internal/routes/routes.go` 中 `registerAssetRoutes` 的调用处,传入 `AssetWalletHandler`
|
||||||
|
|
||||||
|
## 13. Bootstrap 层注册新 Handler/Service
|
||||||
|
|
||||||
|
- [x] 13.1 更新 `internal/bootstrap/types.go`:`Handlers` 结构体新增 `AssetWallet *admin.AssetWalletHandler`;`Services` 结构体新增 `AssetWallet *assetWalletSvc.Service`(如需独立 service 包则导入)
|
||||||
|
- [x] 13.2 更新 `internal/bootstrap/services.go`:实例化 `assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction)`
|
||||||
|
- [x] 13.3 更新 `internal/bootstrap/handlers.go`:实例化 `admin.NewAssetWalletHandler(svcs.AssetWallet)`
|
||||||
|
- [x] 13.4 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:`handlers.AssetWallet = admin.NewAssetWalletHandler(nil)`(文档生成器注册)
|
||||||
|
- [x] 13.5 运行 `go build ./...` 全量确认无编译错误
|
||||||
|
|
||||||
|
## 14. 文档和最终验收
|
||||||
|
|
||||||
|
- [x] 14.1 运行 `go run cmd/gendocs/main.go` 确认两个新接口(资产钱包概况、资产钱包流水列表)出现在 OpenAPI 文档中
|
||||||
|
- [x] 14.2 使用 PostgreSQL MCP 验证三张表改名成功:`tb_asset_wallet`、`tb_asset_wallet_transaction`(含 `reference_no varchar(50)` 字段)、`tb_asset_recharge_record`
|
||||||
|
- [x] 14.3 使用 PostgreSQL MCP 或 curl 验证:H5 充值接口 `GET /api/h5/wallets/recharges/:id` 响应中 `wallet_id` 字段仍正常返回
|
||||||
|
- [x] 14.4 运行 `go build ./...` 全量确认无编译错误
|
||||||
|
- [x] 14.5 tasks.md 全部任务标记完成
|
||||||
84
openspec/specs/asset-wallet-query/spec.md
Normal file
84
openspec/specs/asset-wallet-query/spec.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# asset-wallet-query Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Admin 端资产钱包查询,允许平台用户和代理账号查询指定物联网卡或设备的钱包余额概况及收支流水,流水包含可跳转的来源编号(充值单号 / 订单号)。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin 端查询资产钱包概况
|
||||||
|
|
||||||
|
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet` 接口,允许平台用户和代理账号查询指定卡或设备的钱包余额概况。
|
||||||
|
|
||||||
|
**接口规格**:
|
||||||
|
- 路径参数 `asset_type`:`card` 或 `device`
|
||||||
|
- 路径参数 `id`:资产数据库 ID(uint)
|
||||||
|
- 无请求体
|
||||||
|
- 返回字段:`wallet_id`、`resource_type`、`resource_id`、`balance`、`frozen_balance`、`available_balance`、`currency`、`status`、`status_text`、`created_at`、`updated_at`
|
||||||
|
|
||||||
|
**权限规则**:
|
||||||
|
- 平台用户/超级管理员:可查询所有资产钱包
|
||||||
|
- 代理账号:只能查询 `shop_id_tag IN (当前店铺及下级店铺)` 的资产钱包(由 `ApplyShopTagFilter` 自动过滤)
|
||||||
|
- 企业账号:Handler 层直接返回 403,禁止访问
|
||||||
|
|
||||||
|
#### Scenario: 平台用户查询卡钱包概况
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡存在钱包记录,余额 100 元,冻结 0 元
|
||||||
|
- **THEN** 系统返回 200,`balance=10000`,`frozen_balance=0`,`available_balance=10000`,`status=1`,`status_text="正常"`
|
||||||
|
|
||||||
|
#### Scenario: 代理账号查询下级资产钱包
|
||||||
|
|
||||||
|
- **WHEN** 代理账号(shop_id=10)请求 `GET /api/admin/assets/device/789/wallet`,该设备的 `shop_id_tag` 在该代理的下级店铺范围内
|
||||||
|
- **THEN** 系统返回 200,返回该设备的钱包详情
|
||||||
|
|
||||||
|
#### Scenario: 代理账号查询越权资产钱包
|
||||||
|
|
||||||
|
- **WHEN** 代理账号(shop_id=10)请求 `GET /api/admin/assets/card/999/wallet`,该卡的 `shop_id_tag` 不在该代理的下级店铺范围内
|
||||||
|
- **THEN** 系统返回 404,错误消息为"该资产暂无钱包记录"(不区分"无权"与"不存在")
|
||||||
|
|
||||||
|
#### Scenario: 企业账号请求被拒绝
|
||||||
|
|
||||||
|
- **WHEN** 企业账号请求 `GET /api/admin/assets/card/456/wallet`
|
||||||
|
- **THEN** 系统返回 403,错误消息为"企业账号无权查看钱包信息"
|
||||||
|
|
||||||
|
#### Scenario: 资产无钱包记录
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡尚未创建钱包(未充值过)
|
||||||
|
- **THEN** 系统返回 404,错误消息为"该资产暂无钱包记录"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Admin 端查询资产钱包流水列表
|
||||||
|
|
||||||
|
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet/transactions` 接口,允许平台用户和代理账号分页查询指定资产的钱包收支流水,每条流水包含可跳转的来源编号。
|
||||||
|
|
||||||
|
**接口规格**:
|
||||||
|
- 路径参数:同上
|
||||||
|
- 查询参数:`page`(默认 1)、`page_size`(默认 20,最大 100)、`transaction_type`(可选过滤)、`start_time`(可选)、`end_time`(可选)
|
||||||
|
- 流水按 `created_at` 倒序排列
|
||||||
|
- 每条流水返回:`id`、`transaction_type`、`transaction_type_text`、`amount`、`balance_before`、`balance_after`、`reference_type`、`reference_no`、`remark`、`created_at`
|
||||||
|
|
||||||
|
**来源编号跳转规则**:
|
||||||
|
- `reference_type = "recharge"` → `reference_no` 为充值单号(`CRCH…`),前端可跳转至充值单详情
|
||||||
|
- `reference_type = "order"` → `reference_no` 为订单号(`ORD…`),前端可跳转至订单详情
|
||||||
|
|
||||||
|
**权限规则**:与钱包概况接口相同
|
||||||
|
|
||||||
|
#### Scenario: 查询充值和扣款流水
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?page=1&page_size=20`,该卡有 1 条充值流水(100 元)和 1 条扣款流水(-30 元)
|
||||||
|
- **THEN** 系统返回 200,`total=2`,按时间倒序返回两条记录,充值流水 `amount=10000`、`reference_type="recharge"`、`reference_no="CRCH20260309001"`;扣款流水 `amount=-3000`、`reference_type="order"`、`reference_no="ORD20260310001"`
|
||||||
|
|
||||||
|
#### Scenario: 按交易类型过滤
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?transaction_type=recharge`
|
||||||
|
- **THEN** 系统只返回 `transaction_type="recharge"` 的流水记录
|
||||||
|
|
||||||
|
#### Scenario: 分页超出范围
|
||||||
|
|
||||||
|
- **WHEN** 请求 `page_size=200`(超过最大值 100)
|
||||||
|
- **THEN** 系统返回 400,错误消息为参数验证失败
|
||||||
|
|
||||||
|
#### Scenario: 资产无流水记录
|
||||||
|
|
||||||
|
- **WHEN** 平台用户请求某资产的流水列表,该资产钱包存在但尚无任何流水
|
||||||
|
- **THEN** 系统返回 200,`list=[]`,`total=0`
|
||||||
@@ -1,178 +1,121 @@
|
|||||||
# card-wallet Specification
|
# card-wallet Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
|
资产钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
|
||||||
|
|
||||||
## ADDED Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 卡钱包实体定义
|
### Requirement: 资产钱包实体定义
|
||||||
|
|
||||||
系统 SHALL 定义卡钱包(CardWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。
|
系统 SHALL 定义资产钱包(AssetWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。原 `CardWallet` / `tb_card_wallet` 全量改名为 `AssetWallet` / `tb_asset_wallet`。
|
||||||
|
|
||||||
**核心概念**:
|
**核心概念**:
|
||||||
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
|
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
|
||||||
- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走
|
- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走
|
||||||
|
|
||||||
**实体字段**:
|
**实体字段(与原 CardWallet 完全一致,仅表名改变)**:
|
||||||
- `id`:钱包 ID(主键,BIGINT,自增)
|
- `id`:钱包 ID(主键,BIGINT,自增)
|
||||||
- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card"-物联网卡 | "device"-设备,唯一约束之一)
|
- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card" | "device")
|
||||||
- `resource_id`:资源 ID(BIGINT,关联 tb_iot_card.id 或 tb_device.id,唯一约束之一)
|
- `resource_id`:资源 ID(BIGINT)
|
||||||
- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0)
|
- `balance`:余额(BIGINT,单位:分,默认 0)
|
||||||
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0)
|
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0)
|
||||||
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||||
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1)
|
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1)
|
||||||
- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款)
|
- `version`:版本号(INT,乐观锁)
|
||||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用)
|
- `shop_id_tag`:店铺 ID 标签(多租户过滤)
|
||||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
- `enterprise_id_tag`:企业 ID 标签(可空)
|
||||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
- `created_at` / `updated_at` / `deleted_at`
|
||||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
|
||||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
**表名变更**:`tb_card_wallet` → `tb_asset_wallet`
|
||||||
|
|
||||||
**唯一约束**:`(resource_type, resource_id)` 在 `deleted_at IS NULL` 条件下唯一
|
**唯一约束**:`(resource_type, resource_id)` 在 `deleted_at IS NULL` 条件下唯一
|
||||||
|
|
||||||
**可用余额计算**:可用余额 = balance - frozen_balance
|
**可用余额计算**:可用余额 = balance - frozen_balance
|
||||||
|
|
||||||
**表名**:`tb_card_wallet`
|
|
||||||
|
|
||||||
#### Scenario: 创建物联网卡钱包
|
#### Scenario: 创建物联网卡钱包
|
||||||
|
|
||||||
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值
|
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录,为该卡充值
|
||||||
- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 0,`status` 为 1(正常)
|
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`,`resource_type` 为 "iot_card",`resource_id` 为卡 ID
|
||||||
|
|
||||||
#### Scenario: 创建设备钱包
|
#### Scenario: 创建设备钱包
|
||||||
|
|
||||||
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值
|
- **WHEN** 个人客户通过设备号登录,为设备充值
|
||||||
- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包
|
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`,`resource_type` 为 "device",设备的所有卡共享该钱包
|
||||||
|
|
||||||
#### Scenario: 计算可用余额
|
#### Scenario: 计算可用余额
|
||||||
|
|
||||||
- **WHEN** 卡钱包余额为 10000 分(100 元),冻结余额为 3000 分(30 元)
|
- **WHEN** 钱包余额 10000 分,冻结余额 3000 分
|
||||||
- **THEN** 系统计算可用余额为 7000 分(70 元)
|
- **THEN** 可用余额 = 7000 分
|
||||||
|
|
||||||
#### Scenario: 防止同一资源创建重复钱包
|
#### Scenario: 防止同一资源重复创建钱包
|
||||||
|
|
||||||
- **WHEN** 物联网卡(ID 为 100)已有钱包,尝试再次创建钱包
|
- **WHEN** 物联网卡(ID=100)已有钱包,尝试再次创建
|
||||||
- **THEN** 系统拒绝创建,返回错误信息"该资源已存在钱包"
|
- **THEN** 系统拒绝,返回错误"该资源已存在钱包"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 卡钱包交易记录
|
### Requirement: 资产钱包交易记录
|
||||||
|
|
||||||
系统 SHALL 记录所有卡钱包余额变动,包括充值、套餐扣费、退款等操作,确保完整的审计追踪。
|
系统 SHALL 记录所有资产钱包余额变动,包括充值、套餐扣费、退款,确保完整收支审计追踪。原 `CardWalletTransaction` / `tb_card_wallet_transaction` 全量改名为 `AssetWalletTransaction` / `tb_asset_wallet_transaction`,同时 `reference_id (bigint)` 字段改为 `reference_no (varchar 50)`。
|
||||||
|
|
||||||
**实体字段**:
|
**实体字段**:
|
||||||
- `id`:交易记录 ID(主键,BIGINT,自增)
|
- `id`:交易记录 ID(主键)
|
||||||
- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id)
|
- `asset_wallet_id`:资产钱包 ID(关联 `tb_asset_wallet.id`,原 `card_wallet_id`)
|
||||||
- `resource_type`:资源类型(VARCHAR(20),冗余字段,便于查询)
|
- `resource_type`:资源类型(冗余字段)
|
||||||
- `resource_id`:资源 ID(BIGINT,冗余字段,便于查询)
|
- `resource_id`:资源 ID(冗余字段)
|
||||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
- `user_id`:操作人用户 ID
|
||||||
- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款)
|
- `transaction_type`:交易类型(`recharge` / `deduct` / `refund`)
|
||||||
- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少)
|
- `amount`:变动金额(分,充值为正,扣款/退款为负)
|
||||||
- `balance_before`:变动前余额(BIGINT,单位:分)
|
- `balance_before`:变动前余额(分)
|
||||||
- `balance_after`:变动后余额(BIGINT,单位:分)
|
- `balance_after`:变动后余额(分)
|
||||||
- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1)
|
- `status`:交易状态(1-成功 2-失败 3-处理中)
|
||||||
- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "topup",可空)
|
- `reference_type`:关联业务类型(`recharge` 或 `order`,可空)
|
||||||
- `reference_id`:关联业务 ID(BIGINT,可空)
|
- `reference_no`:关联业务编号,存储充值单号(`CRCH…`)或订单号(`ORD…`)(VARCHAR(50),可空)— **原字段 `reference_id (bigint)` 改名并变更类型**
|
||||||
- `remark`:备注(TEXT,可空)
|
- `remark`:备注(TEXT,可空)
|
||||||
- `metadata`:扩展信息(JSONB,如套餐信息、支付方式等,可空)
|
- `metadata`:扩展信息(JSONB,可空)
|
||||||
- `creator`:创建人 ID(BIGINT)
|
- `creator`:创建人 ID
|
||||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用)
|
- `shop_id_tag` / `enterprise_id_tag`:多租户标签
|
||||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
|
||||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
|
||||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
|
||||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
|
||||||
|
|
||||||
**表名**:`tb_card_wallet_transaction`
|
**表名变更**:`tb_card_wallet_transaction` → `tb_asset_wallet_transaction`
|
||||||
|
|
||||||
**索引**:
|
**字段变更**:`reference_id bigint` → `reference_no varchar(50)`
|
||||||
- `idx_card_tx_wallet (card_wallet_id, created_at)`:按钱包查询交易历史
|
|
||||||
- `idx_card_tx_resource (resource_type, resource_id, created_at)`:按资源查询交易
|
|
||||||
- `idx_card_tx_ref (reference_type, reference_id)`:按关联业务查询
|
|
||||||
- `idx_card_tx_type (transaction_type, created_at)`:按交易类型统计
|
|
||||||
|
|
||||||
#### Scenario: 充值创建交易记录
|
#### Scenario: 充值写入流水记录
|
||||||
|
|
||||||
- **WHEN** 物联网卡(ICCID "8986001234567890")充值 10000 分(100 元)
|
- **WHEN** 个人客户完成充值(充值单号 CRCH20260309001,金额 100 元),充值回调成功
|
||||||
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 10000,`balance_before` 为 0,`balance_after` 为 10000,`status` 为 1(成功)
|
- **THEN** 系统在 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="recharge"`,`amount=10000`,`reference_type="recharge"`,`reference_no="CRCH20260309001"`
|
||||||
|
|
||||||
#### Scenario: 套餐扣费创建交易记录
|
#### Scenario: 钱包支付套餐写入扣款流水
|
||||||
|
|
||||||
- **WHEN** 物联网卡(ICCID "8986001234567890")购买套餐,钱包支付扣款 3000 分(30 元)
|
- **WHEN** 个人客户使用钱包支付套餐订单(订单号 ORD20260310001,金额 30 元),`WalletPay` 执行成功
|
||||||
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "deduct",`amount` 为 -3000,`balance_before` 为 10000,`balance_after` 为 7000,`reference_type` 为 "order",`reference_id` 为订单 ID
|
- **THEN** 系统在同一事务内向 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="deduct"`,`amount=-3000`,`reference_type="order"`,`reference_no="ORD20260310001"`,`balance_before` 为扣款前余额,`balance_after` = `balance_before - 3000`
|
||||||
|
|
||||||
#### Scenario: 订单退款创建交易记录
|
#### Scenario: 充值流水 reference_no 格式
|
||||||
|
|
||||||
- **WHEN** 物联网卡订单(ID 为 1001)退款 3000 分(30 元)
|
- **WHEN** 系统写入充值流水
|
||||||
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "refund",`amount` 为 3000,`balance_before` 为 7000,`balance_after` 为 10000,`reference_type` 为 "order",`reference_id` 为 1001
|
- **THEN** `reference_no` 存储充值单号(格式:`CRCH` + 时间戳 + 随机数),而非数据库主键 ID
|
||||||
|
|
||||||
#### Scenario: 按资源查询交易历史
|
#### Scenario: 扣款流水 reference_no 格式
|
||||||
|
|
||||||
- **WHEN** 个人客户查询物联网卡(ICCID "8986001234567890")的交易历史
|
- **WHEN** 系统写入扣款流水
|
||||||
- **THEN** 系统使用索引 `idx_card_tx_resource` 查询,返回该卡的所有钱包交易记录,按 `created_at` 降序排序
|
- **THEN** `reference_no` 存储订单号(格式:`ORD` + 时间戳 + 6位随机数),而非数据库主键 ID
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 卡充值记录管理
|
### Requirement: 充值记录表改名
|
||||||
|
|
||||||
系统 SHALL 记录所有卡充值操作,包括充值订单号、金额、支付方式、支付状态等信息。
|
系统 SHALL 将原 `tb_card_recharge_record` 表重命名为 `tb_asset_recharge_record`,对应 Go 类型由 `CardRechargeRecord` 改名为 `AssetRechargeRecord`。H5 充值接口 JSON 响应字段 `wallet_id` 不变(保持向后兼容)。
|
||||||
|
|
||||||
**实体字段**:
|
#### Scenario: H5 充值接口字段不变
|
||||||
- `id`:充值记录 ID(主键,BIGINT,自增)
|
|
||||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
|
||||||
- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id)
|
|
||||||
- `resource_type`:资源类型(VARCHAR(20),冗余字段)
|
|
||||||
- `resource_id`:资源 ID(BIGINT,冗余字段)
|
|
||||||
- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:CRCH+时间戳+随机数)
|
|
||||||
- `amount`:充值金额(BIGINT,单位:分,≥ 100)
|
|
||||||
- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信)
|
|
||||||
- `payment_channel`:支付渠道(VARCHAR(50),可空)
|
|
||||||
- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空)
|
|
||||||
- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1)
|
|
||||||
- `paid_at`:支付时间(TIMESTAMP,可空)
|
|
||||||
- `completed_at`:完成时间(TIMESTAMP,可空)
|
|
||||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用)
|
|
||||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
|
||||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
|
||||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
|
||||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
|
||||||
|
|
||||||
**表名**:`tb_card_recharge_record`
|
- **WHEN** 前端调用 `GET /api/h5/wallets/recharges/:id`,充值记录关联的钱包 ID 为 123
|
||||||
|
- **THEN** 响应 JSON 中 `wallet_id` 仍为 `123`,JSON 字段名不变(仅 Go 内部字段名从 `CardWalletID` 改为 `AssetWalletID`)
|
||||||
**充值金额限制**:
|
|
||||||
- 最小充值金额:100 分(1 元)
|
|
||||||
- 最大充值金额:10000000 分(100000 元)
|
|
||||||
|
|
||||||
**索引**:
|
|
||||||
- `idx_card_recharge_user (user_id, created_at)`:按用户查询充值记录
|
|
||||||
- `idx_card_recharge_resource (resource_type, resource_id, created_at)`:按资源查询充值记录
|
|
||||||
- `idx_card_recharge_status (status, created_at)`:按状态过滤充值记录
|
|
||||||
- `idx_card_recharge_no (recharge_no)`:按订单号查询
|
|
||||||
|
|
||||||
#### Scenario: 创建卡充值订单
|
|
||||||
|
|
||||||
- **WHEN** 个人客户为物联网卡(ICCID "8986001234567890")发起充值 10000 分(100 元),选择微信支付
|
|
||||||
- **THEN** 系统创建卡充值记录,生成唯一的 `recharge_no`(如 "CRCH20260224123456789012"),`amount` 为 10000,`payment_method` 为 "wechat",`status` 为 1(待支付),`resource_type` 为 "iot_card"
|
|
||||||
|
|
||||||
#### Scenario: 充值金额低于最小限制
|
|
||||||
|
|
||||||
- **WHEN** 个人客户尝试充值 50 分(0.5 元)
|
|
||||||
- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 1 元"
|
|
||||||
|
|
||||||
#### Scenario: 充值支付完成
|
|
||||||
|
|
||||||
- **WHEN** 个人客户完成微信支付
|
|
||||||
- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id`
|
|
||||||
|
|
||||||
#### Scenario: 充值到账
|
|
||||||
|
|
||||||
- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账
|
|
||||||
- **THEN** 系统将卡钱包余额增加 10000 分,创建卡钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 卡钱包余额操作
|
### Requirement: 资产钱包余额操作
|
||||||
|
|
||||||
系统 SHALL 支持卡钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。
|
系统 SHALL 支持资产钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。
|
||||||
|
|
||||||
**操作类型**:
|
**操作类型**:
|
||||||
- **充值**:增加钱包余额
|
- **充值**:增加钱包余额
|
||||||
@@ -188,36 +131,36 @@
|
|||||||
- 扣款时,检查可用余额(balance - frozen_balance)是否充足
|
- 扣款时,检查可用余额(balance - frozen_balance)是否充足
|
||||||
- 所有余额变动必须创建交易记录
|
- 所有余额变动必须创建交易记录
|
||||||
|
|
||||||
#### Scenario: 卡钱包充值
|
#### Scenario: 资产钱包充值
|
||||||
|
|
||||||
- **WHEN** 卡钱包当前余额为 10000 分,充值 5000 分
|
- **WHEN** 资产钱包当前余额为 10000 分,充值 5000 分
|
||||||
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 5000)
|
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 5000)
|
||||||
|
|
||||||
#### Scenario: 卡钱包扣款
|
#### Scenario: 资产钱包扣款
|
||||||
|
|
||||||
- **WHEN** 卡钱包当前余额为 15000 分,购买套餐扣款 3000 分
|
- **WHEN** 资产钱包当前余额为 15000 分,购买套餐扣款 3000 分
|
||||||
- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -3000)
|
- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -3000)
|
||||||
|
|
||||||
#### Scenario: 余额不足扣款失败
|
#### Scenario: 余额不足扣款失败
|
||||||
|
|
||||||
- **WHEN** 卡钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
|
- **WHEN** 资产钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
|
||||||
- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足"
|
- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足"
|
||||||
|
|
||||||
#### Scenario: 并发扣款乐观锁生效
|
#### Scenario: 并发扣款乐观锁生效
|
||||||
|
|
||||||
- **WHEN** 卡钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分
|
- **WHEN** 资产钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分
|
||||||
- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)和 version(2)后重试
|
- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)和 version(2)后重试
|
||||||
|
|
||||||
#### Scenario: 订单退款
|
#### Scenario: 订单退款
|
||||||
|
|
||||||
- **WHEN** 卡钱包当前余额为 7000 分,订单退款 3000 分
|
- **WHEN** 资产钱包当前余额为 7000 分,订单退款 3000 分
|
||||||
- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1,创建交易记录(`transaction_type` 为 "refund",`amount` 为 3000)
|
- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1,创建交易记录(`transaction_type` 为 "refund",`amount` 为 3000)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 卡钱包数据校验
|
### Requirement: 资产钱包数据校验
|
||||||
|
|
||||||
系统 SHALL 对卡钱包数据进行校验,确保数据完整性和一致性。
|
系统 SHALL 对资产钱包数据进行校验,确保数据完整性和一致性。
|
||||||
|
|
||||||
**校验规则**:
|
**校验规则**:
|
||||||
- `resource_type`:必填,枚举值 "iot_card" | "device"
|
- `resource_type`:必填,枚举值 "iot_card" | "device"
|
||||||
@@ -230,29 +173,29 @@
|
|||||||
|
|
||||||
#### Scenario: 创建钱包时 resource_type 无效
|
#### Scenario: 创建钱包时 resource_type 无效
|
||||||
|
|
||||||
- **WHEN** 创建卡钱包,`resource_type` 为 "invalid"
|
- **WHEN** 创建资产钱包,`resource_type` 为 "invalid"
|
||||||
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device"
|
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device"
|
||||||
|
|
||||||
#### Scenario: 创建钱包时 resource_id 无效
|
#### Scenario: 创建钱包时 resource_id 无效
|
||||||
|
|
||||||
- **WHEN** 创建卡钱包,`resource_type` 为 "iot_card",`resource_id` 为 0
|
- **WHEN** 创建资产钱包,`resource_type` 为 "iot_card",`resource_id` 为 0
|
||||||
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
|
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
|
||||||
|
|
||||||
#### Scenario: 冻结余额超过总余额
|
#### Scenario: 冻结余额超过总余额
|
||||||
|
|
||||||
- **WHEN** 卡钱包余额为 10000 分,尝试冻结 15000 分
|
- **WHEN** 资产钱包余额为 10000 分,尝试冻结 15000 分
|
||||||
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
||||||
|
|
||||||
#### Scenario: 余额为负数
|
#### Scenario: 余额为负数
|
||||||
|
|
||||||
- **WHEN** 尝试将卡钱包余额设置为 -10000 分
|
- **WHEN** 尝试将资产钱包余额设置为 -10000 分
|
||||||
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
|
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 卡钱包归属资源转手规则
|
### Requirement: 资产钱包归属资源转手规则
|
||||||
|
|
||||||
系统 SHALL 支持卡钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。
|
系统 SHALL 支持资产钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。
|
||||||
|
|
||||||
**归属规则**:
|
**归属规则**:
|
||||||
|
|
||||||
@@ -268,16 +211,16 @@
|
|||||||
#### Scenario: 个人客户购买单卡并充值
|
#### Scenario: 个人客户购买单卡并充值
|
||||||
|
|
||||||
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分
|
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分
|
||||||
- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000
|
- **THEN** 系统创建资产钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000
|
||||||
|
|
||||||
#### Scenario: 个人客户购买设备并充值
|
#### Scenario: 个人客户购买设备并充值
|
||||||
|
|
||||||
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分
|
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分
|
||||||
- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包,`balance` 为 20000
|
- **THEN** 系统创建资产钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包,`balance` 为 20000
|
||||||
|
|
||||||
#### Scenario: 卡转手后新用户查询余额
|
#### Scenario: 卡转手后新用户查询余额
|
||||||
|
|
||||||
- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),卡钱包余额为 5000 分
|
- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),钱包余额为 5000 分
|
||||||
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用
|
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用
|
||||||
|
|
||||||
#### Scenario: 设备转手后新用户查询余额
|
#### Scenario: 设备转手后新用户查询余额
|
||||||
@@ -292,13 +235,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 卡钱包 Redis 缓存策略
|
### Requirement: 资产钱包 Redis 缓存策略
|
||||||
|
|
||||||
系统 SHALL 使用 Redis 缓存卡钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
|
系统 SHALL 使用 Redis 缓存资产钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
|
||||||
|
|
||||||
**缓存 Key 定义**:
|
**缓存 Key 定义**:
|
||||||
- 余额缓存:`card_wallet:balance:{resource_type}:{resource_id}`
|
- 余额缓存:`asset_wallet:balance:{resource_type}:{resource_id}`
|
||||||
- 分布式锁:`card_wallet:lock:{resource_type}:{resource_id}`
|
- 分布式锁:`asset_wallet:lock:{resource_type}:{resource_id}`
|
||||||
|
|
||||||
**缓存 TTL**:
|
**缓存 TTL**:
|
||||||
- 余额缓存:180 秒(3 分钟)
|
- 余额缓存:180 秒(3 分钟)
|
||||||
@@ -308,11 +251,11 @@
|
|||||||
- 余额变动时,删除缓存(Cache-Aside 模式)
|
- 余额变动时,删除缓存(Cache-Aside 模式)
|
||||||
- 下次查询时重新加载到缓存
|
- 下次查询时重新加载到缓存
|
||||||
|
|
||||||
**常量定义位置**:`pkg/constants/wallet.go`
|
**常量定义位置**:`pkg/constants/redis.go`
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string
|
func RedisAssetWalletBalanceKey(resourceType string, resourceID uint) string
|
||||||
func RedisCardWalletLockKey(resourceType string, resourceID uint) string
|
func RedisAssetWalletLockKey(resourceType string, resourceID uint) string
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Scenario: 查询余额时使用缓存
|
#### Scenario: 查询余额时使用缓存
|
||||||
@@ -323,11 +266,9 @@ func RedisCardWalletLockKey(resourceType string, resourceID uint) string
|
|||||||
#### Scenario: 余额变动后删除缓存
|
#### Scenario: 余额变动后删除缓存
|
||||||
|
|
||||||
- **WHEN** 物联网卡(ID 为 100)钱包余额增加 5000 分
|
- **WHEN** 物联网卡(ID 为 100)钱包余额增加 5000 分
|
||||||
- **THEN** 系统删除 Redis 缓存 Key `card_wallet:balance:iot_card:100`,下次查询时重新加载
|
- **THEN** 系统删除 Redis 缓存 Key `asset_wallet:balance:iot_card:100`,下次查询时重新加载
|
||||||
|
|
||||||
#### Scenario: 使用分布式锁防止并发扣款
|
#### Scenario: 使用分布式锁防止并发扣款
|
||||||
|
|
||||||
- **WHEN** 两个并发请求同时尝试从物联网卡(ID 为 100)钱包扣款
|
- **WHEN** 两个并发请求同时尝试从物联网卡(ID 为 100)钱包扣款
|
||||||
- **THEN** 系统使用 Redis 分布式锁 `card_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败
|
- **THEN** 系统使用 Redis 分布式锁 `asset_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ type PresignConfig struct {
|
|||||||
// WechatConfig 微信配置
|
// WechatConfig 微信配置
|
||||||
type WechatConfig struct {
|
type WechatConfig struct {
|
||||||
OfficialAccount OfficialAccountConfig `mapstructure:"official_account"`
|
OfficialAccount OfficialAccountConfig `mapstructure:"official_account"`
|
||||||
Payment PaymentConfig `mapstructure:"payment"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OfficialAccountConfig 微信公众号配置
|
// OfficialAccountConfig 微信公众号配置
|
||||||
@@ -172,20 +171,6 @@ type OfficialAccountConfig struct {
|
|||||||
OAuthRedirectURL string `mapstructure:"oauth_redirect_url"`
|
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 {
|
type requiredField struct {
|
||||||
value string
|
value string
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -120,14 +120,4 @@ wechat:
|
|||||||
token: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN
|
token: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN
|
||||||
aes_key: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY(敏感)
|
aes_key: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY(敏感)
|
||||||
oauth_redirect_url: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL
|
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"
|
|
||||||
|
|||||||
@@ -313,3 +313,14 @@ func RedisDeviceRefreshCooldownKey(deviceID uint) string {
|
|||||||
func RedisPollingQueueProtectKey() string {
|
func RedisPollingQueueProtectKey() string {
|
||||||
return "polling:queue:protect"
|
return "polling:queue:protect"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 微信配置相关 Redis Key
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// RedisWechatConfigActiveKey 生效支付配置缓存键
|
||||||
|
// 用途:缓存当前激活的微信参数配置(JSON 或 "none" 空标记)
|
||||||
|
// 过期时间:5 分钟(有配置)/ 1 分钟(空标记)
|
||||||
|
func RedisWechatConfigActiveKey() string {
|
||||||
|
return "wechat:config:active"
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,37 +47,37 @@ const (
|
|||||||
AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元)
|
AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========== 卡钱包常量 ==========
|
// ========== 资产钱包常量 ==========
|
||||||
|
|
||||||
// 卡钱包资源类型
|
// 资产钱包资源类型
|
||||||
const (
|
const (
|
||||||
CardWalletResourceTypeIotCard = "iot_card" // 物联网卡钱包
|
AssetWalletResourceTypeIotCard = "iot_card" // 物联网卡钱包
|
||||||
CardWalletResourceTypeDevice = "device" // 设备钱包(多卡共享)
|
AssetWalletResourceTypeDevice = "device" // 设备钱包(多卡共享)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 卡钱包状态
|
// 资产钱包状态
|
||||||
const (
|
const (
|
||||||
CardWalletStatusNormal = 1 // 正常
|
AssetWalletStatusNormal = 1 // 正常
|
||||||
CardWalletStatusFrozen = 2 // 冻结
|
AssetWalletStatusFrozen = 2 // 冻结
|
||||||
CardWalletStatusClosed = 3 // 关闭
|
AssetWalletStatusClosed = 3 // 关闭
|
||||||
)
|
)
|
||||||
|
|
||||||
// 卡钱包交易类型
|
// 资产钱包交易类型
|
||||||
const (
|
const (
|
||||||
CardTransactionTypeRecharge = "recharge" // 充值
|
AssetTransactionTypeRecharge = "recharge" // 充值
|
||||||
CardTransactionTypeDeduct = "deduct" // 扣款
|
AssetTransactionTypeDeduct = "deduct" // 扣款
|
||||||
CardTransactionTypeRefund = "refund" // 退款
|
AssetTransactionTypeRefund = "refund" // 退款
|
||||||
)
|
)
|
||||||
|
|
||||||
// 卡充值订单号前缀
|
// 资产充值订单号前缀
|
||||||
const (
|
const (
|
||||||
CardRechargeOrderPrefix = "CRCH" // 卡充值订单号前缀
|
AssetRechargeOrderPrefix = "CRCH" // 资产充值订单号前缀
|
||||||
)
|
)
|
||||||
|
|
||||||
// 卡充值金额限制(单位:分)
|
// 资产充值金额限制(单位:分)
|
||||||
const (
|
const (
|
||||||
CardRechargeMinAmount = 100 // 最小充值金额(1元)
|
AssetRechargeMinAmount = 100 // 最小充值金额(1元)
|
||||||
CardRechargeMaxAmount = 10000000 // 最大充值金额(100000元)
|
AssetRechargeMaxAmount = 10000000 // 最大充值金额(100000元)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========== 通用常量 ==========
|
// ========== 通用常量 ==========
|
||||||
@@ -154,31 +154,31 @@ const WalletTypeMain = AgentWalletTypeMain
|
|||||||
// WalletTypeCommission 分佣钱包(已废弃,使用 AgentWalletTypeCommission)
|
// WalletTypeCommission 分佣钱包(已废弃,使用 AgentWalletTypeCommission)
|
||||||
const WalletTypeCommission = AgentWalletTypeCommission
|
const WalletTypeCommission = AgentWalletTypeCommission
|
||||||
|
|
||||||
// WalletResourceTypeIotCard 物联网卡钱包(已废弃,使用 CardWalletResourceTypeIotCard)
|
// WalletResourceTypeIotCard 物联网卡钱包(已废弃,使用 AssetWalletResourceTypeIotCard)
|
||||||
const WalletResourceTypeIotCard = CardWalletResourceTypeIotCard
|
const WalletResourceTypeIotCard = AssetWalletResourceTypeIotCard
|
||||||
|
|
||||||
// WalletResourceTypeDevice 设备钱包(已废弃,使用 CardWalletResourceTypeDevice)
|
// WalletResourceTypeDevice 设备钱包(已废弃,使用 AssetWalletResourceTypeDevice)
|
||||||
const WalletResourceTypeDevice = CardWalletResourceTypeDevice
|
const WalletResourceTypeDevice = AssetWalletResourceTypeDevice
|
||||||
|
|
||||||
// WalletResourceTypeShop 店铺钱包(已废弃,代理钱包不再使用 resource_type)
|
// WalletResourceTypeShop 店铺钱包(已废弃,代理钱包不再使用 resource_type)
|
||||||
const WalletResourceTypeShop = "shop"
|
const WalletResourceTypeShop = "shop"
|
||||||
|
|
||||||
// WalletStatusNormal 钱包状态-正常(已废弃,使用 AgentWalletStatusNormal 或 CardWalletStatusNormal)
|
// WalletStatusNormal 钱包状态-正常(已废弃,使用 AgentWalletStatusNormal 或 AssetWalletStatusNormal)
|
||||||
const WalletStatusNormal = AgentWalletStatusNormal
|
const WalletStatusNormal = AgentWalletStatusNormal
|
||||||
|
|
||||||
// WalletStatusFrozen 钱包状态-冻结(已废弃,使用 AgentWalletStatusFrozen 或 CardWalletStatusFrozen)
|
// WalletStatusFrozen 钱包状态-冻结(已废弃,使用 AgentWalletStatusFrozen 或 AssetWalletStatusFrozen)
|
||||||
const WalletStatusFrozen = AgentWalletStatusFrozen
|
const WalletStatusFrozen = AgentWalletStatusFrozen
|
||||||
|
|
||||||
// WalletStatusClosed 钱包状态-关闭(已废弃,使用 AgentWalletStatusClosed 或 CardWalletStatusClosed)
|
// WalletStatusClosed 钱包状态-关闭(已废弃,使用 AgentWalletStatusClosed 或 AssetWalletStatusClosed)
|
||||||
const WalletStatusClosed = AgentWalletStatusClosed
|
const WalletStatusClosed = AgentWalletStatusClosed
|
||||||
|
|
||||||
// TransactionTypeRecharge 交易类型-充值(已废弃,使用 AgentTransactionTypeRecharge 或 CardTransactionTypeRecharge)
|
// TransactionTypeRecharge 交易类型-充值(已废弃,使用 AgentTransactionTypeRecharge 或 AssetTransactionTypeRecharge)
|
||||||
const TransactionTypeRecharge = AgentTransactionTypeRecharge
|
const TransactionTypeRecharge = AgentTransactionTypeRecharge
|
||||||
|
|
||||||
// TransactionTypeDeduct 交易类型-扣款(已废弃,使用 AgentTransactionTypeDeduct 或 CardTransactionTypeDeduct)
|
// TransactionTypeDeduct 交易类型-扣款(已废弃,使用 AgentTransactionTypeDeduct 或 AssetTransactionTypeDeduct)
|
||||||
const TransactionTypeDeduct = AgentTransactionTypeDeduct
|
const TransactionTypeDeduct = AgentTransactionTypeDeduct
|
||||||
|
|
||||||
// TransactionTypeRefund 交易类型-退款(已废弃,使用 AgentTransactionTypeRefund 或 CardTransactionTypeRefund)
|
// TransactionTypeRefund 交易类型-退款(已废弃,使用 AgentTransactionTypeRefund 或 AssetTransactionTypeRefund)
|
||||||
const TransactionTypeRefund = AgentTransactionTypeRefund
|
const TransactionTypeRefund = AgentTransactionTypeRefund
|
||||||
|
|
||||||
// TransactionTypeCommission 交易类型-分佣(已废弃,使用 AgentTransactionTypeCommission)
|
// TransactionTypeCommission 交易类型-分佣(已废弃,使用 AgentTransactionTypeCommission)
|
||||||
@@ -187,11 +187,46 @@ const TransactionTypeCommission = AgentTransactionTypeCommission
|
|||||||
// TransactionTypeWithdrawal 交易类型-提现(已废弃,使用 AgentTransactionTypeWithdrawal)
|
// TransactionTypeWithdrawal 交易类型-提现(已废弃,使用 AgentTransactionTypeWithdrawal)
|
||||||
const TransactionTypeWithdrawal = AgentTransactionTypeWithdrawal
|
const TransactionTypeWithdrawal = AgentTransactionTypeWithdrawal
|
||||||
|
|
||||||
// RechargeOrderPrefix 充值订单号前缀(已废弃,使用 AgentRechargeOrderPrefix 或 CardRechargeOrderPrefix)
|
// RechargeOrderPrefix 充值订单号前缀(已废弃,使用 AgentRechargeOrderPrefix 或 AssetRechargeOrderPrefix)
|
||||||
const RechargeOrderPrefix = "RCH"
|
const RechargeOrderPrefix = "RCH"
|
||||||
|
|
||||||
// RechargeMinAmount 最小充值金额(已废弃,使用 AgentRechargeMinAmount 或 CardRechargeMinAmount)
|
// RechargeMinAmount 最小充值金额(已废弃,使用 AgentRechargeMinAmount 或 AssetRechargeMinAmount)
|
||||||
const RechargeMinAmount = CardRechargeMinAmount
|
const RechargeMinAmount = AssetRechargeMinAmount
|
||||||
|
|
||||||
// RechargeMaxAmount 最大充值金额(已废弃,使用 AgentRechargeMaxAmount 或 CardRechargeMaxAmount)
|
// RechargeMaxAmount 最大充值金额(已废弃,使用 AgentRechargeMaxAmount 或 AssetRechargeMaxAmount)
|
||||||
const RechargeMaxAmount = CardRechargeMaxAmount
|
const RechargeMaxAmount = AssetRechargeMaxAmount
|
||||||
|
|
||||||
|
// ========== Card* 废弃别名(向后兼容)==========
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetWalletResourceTypeIotCard
|
||||||
|
const CardWalletResourceTypeIotCard = AssetWalletResourceTypeIotCard
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetWalletResourceTypeDevice
|
||||||
|
const CardWalletResourceTypeDevice = AssetWalletResourceTypeDevice
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetWalletStatusNormal
|
||||||
|
const CardWalletStatusNormal = AssetWalletStatusNormal
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetWalletStatusFrozen
|
||||||
|
const CardWalletStatusFrozen = AssetWalletStatusFrozen
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetWalletStatusClosed
|
||||||
|
const CardWalletStatusClosed = AssetWalletStatusClosed
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetTransactionTypeRecharge
|
||||||
|
const CardTransactionTypeRecharge = AssetTransactionTypeRecharge
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetTransactionTypeDeduct
|
||||||
|
const CardTransactionTypeDeduct = AssetTransactionTypeDeduct
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetTransactionTypeRefund
|
||||||
|
const CardTransactionTypeRefund = AssetTransactionTypeRefund
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetRechargeOrderPrefix
|
||||||
|
const CardRechargeOrderPrefix = AssetRechargeOrderPrefix
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetRechargeMinAmount
|
||||||
|
const CardRechargeMinAmount = AssetRechargeMinAmount
|
||||||
|
|
||||||
|
// Deprecated: 使用 AssetRechargeMaxAmount
|
||||||
|
const CardRechargeMaxAmount = AssetRechargeMaxAmount
|
||||||
|
|||||||
@@ -126,13 +126,21 @@ const (
|
|||||||
CodePollingCleanupConfigNotFound = 1155 // 数据清理配置不存在
|
CodePollingCleanupConfigNotFound = 1155 // 数据清理配置不存在
|
||||||
CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限
|
CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限
|
||||||
|
|
||||||
// 套餐相关错误 (1160-1179)
|
// 套餐相关错误 (1160-1169)
|
||||||
CodeNoAvailablePackage = 1160 // 没有可用套餐
|
CodeNoAvailablePackage = 1160 // 没有可用套餐
|
||||||
CodePackageActivationConflict = 1161 // 套餐正在激活中
|
CodePackageActivationConflict = 1161 // 套餐正在激活中
|
||||||
CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包
|
CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包
|
||||||
CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐
|
CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐
|
||||||
CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包
|
CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包
|
||||||
|
|
||||||
|
// 微信配置相关错误 (1170-1179)
|
||||||
|
CodeWechatConfigNotFound = 1170 // 微信支付配置不存在
|
||||||
|
CodeWechatConfigActive = 1171 // 不能删除/操作当前生效的支付配置
|
||||||
|
CodeWechatConfigHasPendingOrders = 1172 // 该配置存在未完成的支付订单
|
||||||
|
CodeFuiouPayFailed = 1173 // 富友支付发起失败
|
||||||
|
CodeFuiouCallbackInvalid = 1174 // 富友回调签名验证失败
|
||||||
|
CodeNoPaymentConfig = 1175 // 当前无可用的支付配置
|
||||||
|
|
||||||
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
||||||
CodeInternalError = 2001 // 内部服务器错误
|
CodeInternalError = 2001 // 内部服务器错误
|
||||||
CodeDatabaseError = 2002 // 数据库错误
|
CodeDatabaseError = 2002 // 数据库错误
|
||||||
@@ -244,6 +252,12 @@ var allErrorCodes = []int{
|
|||||||
CodeNoMainPackage,
|
CodeNoMainPackage,
|
||||||
CodeRealnameRequired,
|
CodeRealnameRequired,
|
||||||
CodeMixedOrderForbidden,
|
CodeMixedOrderForbidden,
|
||||||
|
CodeWechatConfigNotFound,
|
||||||
|
CodeWechatConfigActive,
|
||||||
|
CodeWechatConfigHasPendingOrders,
|
||||||
|
CodeFuiouPayFailed,
|
||||||
|
CodeFuiouCallbackInvalid,
|
||||||
|
CodeNoPaymentConfig,
|
||||||
CodeInternalError,
|
CodeInternalError,
|
||||||
CodeDatabaseError,
|
CodeDatabaseError,
|
||||||
CodeRedisError,
|
CodeRedisError,
|
||||||
@@ -353,6 +367,12 @@ var errorMessages = map[int]string{
|
|||||||
CodeNoMainPackage: "必须有主套餐才能购买加油包",
|
CodeNoMainPackage: "必须有主套餐才能购买加油包",
|
||||||
CodeRealnameRequired: "设备/卡必须先完成实名认证才能购买套餐",
|
CodeRealnameRequired: "设备/卡必须先完成实名认证才能购买套餐",
|
||||||
CodeMixedOrderForbidden: "同订单不能同时购买正式套餐和加油包",
|
CodeMixedOrderForbidden: "同订单不能同时购买正式套餐和加油包",
|
||||||
|
CodeWechatConfigNotFound: "微信支付配置不存在",
|
||||||
|
CodeWechatConfigActive: "不能删除当前生效的支付配置,请先停用",
|
||||||
|
CodeWechatConfigHasPendingOrders: "该配置存在未完成的支付订单,暂时无法删除",
|
||||||
|
CodeFuiouPayFailed: "支付发起失败,请重试",
|
||||||
|
CodeFuiouCallbackInvalid: "支付回调签名验证失败",
|
||||||
|
CodeNoPaymentConfig: "当前无可用的支付配置,请联系管理员",
|
||||||
CodeInvalidCredentials: "用户名或密码错误",
|
CodeInvalidCredentials: "用户名或密码错误",
|
||||||
CodeAccountLocked: "账号已锁定",
|
CodeAccountLocked: "账号已锁定",
|
||||||
CodePasswordExpired: "密码已过期",
|
CodePasswordExpired: "密码已过期",
|
||||||
|
|||||||
317
pkg/fuiou/client.go
Normal file
317
pkg/fuiou/client.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package fuiou
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client 富友支付客户端
|
||||||
|
type Client struct {
|
||||||
|
InsCd string // 机构号
|
||||||
|
MchntCd string // 商户号
|
||||||
|
TermId string // 终端号
|
||||||
|
ApiURL string // 富友 API 地址
|
||||||
|
NotifyURL string // 支付回调地址
|
||||||
|
privateKey *rsa.PrivateKey // 商户私钥(用于签名)
|
||||||
|
publicKey *rsa.PublicKey // 富友公钥(用于验签)
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 从配置参数构造富友客户端
|
||||||
|
// privateKeyBase64: 商户 RSA 私钥 Base64 编码(支持 PEM/DER、PKCS1/PKCS8 格式)
|
||||||
|
// publicKeyBase64: 富友 RSA 公钥 Base64 编码(支持 PEM/DER 格式)
|
||||||
|
func NewClient(insCd, mchntCd, termId, apiURL, notifyURL, privateKeyBase64, publicKeyBase64 string, logger *zap.Logger) (*Client, error) {
|
||||||
|
privKey, err := parsePrivateKey(privateKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析商户私钥失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := parsePublicKey(publicKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析富友公钥失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
InsCd: insCd,
|
||||||
|
MchntCd: mchntCd,
|
||||||
|
TermId: termId,
|
||||||
|
ApiURL: apiURL,
|
||||||
|
NotifyURL: notifyURL,
|
||||||
|
privateKey: privKey,
|
||||||
|
publicKey: pubKey,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign 对请求数据签名
|
||||||
|
// 算法: 按字典序排列非空字段 → key=value&key=value → GBK 编码 → MD5 → RSA PKCS1v15 签名 → Base64
|
||||||
|
func (c *Client) Sign(data interface{}) (string, error) {
|
||||||
|
signStr := buildSignString(data)
|
||||||
|
|
||||||
|
// UTF-8 → GBK
|
||||||
|
gbkBytes, err := utf8ToGBK([]byte(signStr))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GBK 编码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5
|
||||||
|
hash := md5.Sum(gbkBytes)
|
||||||
|
|
||||||
|
// RSA PKCS1v15 签名
|
||||||
|
signature, err := rsa.SignPKCS1v15(rand.Reader, c.privateKey, crypto.MD5, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("RSA 签名失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(signature), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 验证签名
|
||||||
|
func (c *Client) Verify(data interface{}, sign string) error {
|
||||||
|
signStr := buildSignString(data)
|
||||||
|
|
||||||
|
gbkBytes, err := utf8ToGBK([]byte(signStr))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GBK 编码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := md5.Sum(gbkBytes)
|
||||||
|
|
||||||
|
sigBytes, err := base64.StdEncoding.DecodeString(sign)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Base64 解码签名失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsa.VerifyPKCS1v15(c.publicKey, crypto.MD5, hash[:], sigBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoRequest 发送 HTTP 请求到富友
|
||||||
|
// 处理流程: XML 编码 → GBK 转换 → 双重 URL 编码 → POST → URL 解码 → GBK→UTF-8 → XML 解析
|
||||||
|
func (c *Client) DoRequest(path string, req interface{}, resp interface{}) error {
|
||||||
|
// 1. XML 编码
|
||||||
|
xmlBytes, err := xml.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("XML 编码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 添加 XML 声明并替换编码声明为 GBK
|
||||||
|
xmlStr := `<?xml version="1.0" encoding="GBK"?>` + string(xmlBytes)
|
||||||
|
|
||||||
|
// 3. UTF-8 → GBK
|
||||||
|
gbkBytes, err := utf8ToGBK([]byte(xmlStr))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GBK 编码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 两次 URL 编码
|
||||||
|
encoded := url.QueryEscape(url.QueryEscape(string(gbkBytes)))
|
||||||
|
|
||||||
|
// 5. POST 请求
|
||||||
|
reqURL := strings.TrimRight(c.ApiURL, "/") + path
|
||||||
|
body := "req=" + encoded
|
||||||
|
|
||||||
|
c.logger.Debug("富友请求发送",
|
||||||
|
zap.String("url", reqURL),
|
||||||
|
zap.String("path", path),
|
||||||
|
)
|
||||||
|
|
||||||
|
httpResp, err := http.Post(reqURL, "application/x-www-form-urlencoded", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HTTP 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
// 6. 读取响应
|
||||||
|
respBody, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. URL 解码
|
||||||
|
decoded, err := url.QueryUnescape(string(respBody))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("URL 解码响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. GBK → UTF-8
|
||||||
|
utf8Bytes, err := GBKToUTF8([]byte(decoded))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("UTF-8 转换失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 替换 XML 声明中的编码为 UTF-8
|
||||||
|
utf8Str := strings.Replace(string(utf8Bytes), `encoding="GBK"`, `encoding="UTF-8"`, 1)
|
||||||
|
|
||||||
|
// 10. XML 解析
|
||||||
|
if err := xml.Unmarshal([]byte(utf8Str), resp); err != nil {
|
||||||
|
return fmt.Errorf("XML 解析响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignString 构建签名原文
|
||||||
|
// 提取非空字段(排除 sign 和 reserved_ 开头字段),按字典序拼接为 key=value&key=value
|
||||||
|
func buildSignString(data interface{}) string {
|
||||||
|
fields := structToMap(data)
|
||||||
|
|
||||||
|
// 按 key 字典序排列
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for k := range fields {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
// 拼接
|
||||||
|
pairs := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
pairs = append(pairs, k+"="+fields[k])
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(pairs, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// structToMap 通过反射提取结构体的 xml tag 和值
|
||||||
|
// 排除 sign 字段、reserved_ 开头字段、空值字段
|
||||||
|
func structToMap(data interface{}) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
|
||||||
|
v := reflect.ValueOf(data)
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
t := v.Type()
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
value := v.Field(i).String()
|
||||||
|
|
||||||
|
tag := field.Tag.Get("xml")
|
||||||
|
if tag == "" || tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除 sign 字段
|
||||||
|
if tag == "sign" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除 reserved_ 开头字段
|
||||||
|
if strings.HasPrefix(tag, "reserved_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除空值
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result[tag] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf8ToGBK 将 UTF-8 字节转换为 GBK 编码
|
||||||
|
func utf8ToGBK(input []byte) ([]byte, error) {
|
||||||
|
reader := transform.NewReader(strings.NewReader(string(input)), simplifiedchinese.GBK.NewEncoder())
|
||||||
|
return io.ReadAll(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GBKToUTF8 将 GBK 字节转换为 UTF-8 编码
|
||||||
|
func GBKToUTF8(input []byte) ([]byte, error) {
|
||||||
|
reader := transform.NewReader(strings.NewReader(string(input)), simplifiedchinese.GBK.NewDecoder())
|
||||||
|
return io.ReadAll(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomStr 生成 32 字符随机字符串
|
||||||
|
func generateRandomStr() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrivateKey 解析 Base64 编码的 RSA 私钥
|
||||||
|
// 支持多种格式: PEM(PKCS1/PKCS8) 和 DER(PKCS1/PKCS8)
|
||||||
|
func parsePrivateKey(base64Str string) (*rsa.PrivateKey, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Base64 解码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 PEM 格式(Base64 解码后是 PEM 文本)
|
||||||
|
if block, _ := pem.Decode(decoded); block != nil {
|
||||||
|
return parseDERPrivateKey(block.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 DER 格式(Base64 解码后直接是 DER 字节)
|
||||||
|
return parseDERPrivateKey(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDERPrivateKey 从 DER 字节解析 RSA 私钥,先尝试 PKCS8 再尝试 PKCS1
|
||||||
|
func parseDERPrivateKey(derBytes []byte) (*rsa.PrivateKey, error) {
|
||||||
|
// 先尝试 PKCS8
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(derBytes); err == nil {
|
||||||
|
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("PKCS8 解析结果不是 RSA 私钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再尝试 PKCS1
|
||||||
|
if key, err := x509.ParsePKCS1PrivateKey(derBytes); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("无法解析私钥(已尝试 PKCS8 和 PKCS1 格式)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePublicKey 解析 Base64 编码的 RSA 公钥
|
||||||
|
// 支持 PEM 和 DER 格式
|
||||||
|
func parsePublicKey(base64Str string) (*rsa.PublicKey, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Base64 解码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 PEM 格式
|
||||||
|
if block, _ := pem.Decode(decoded); block != nil {
|
||||||
|
return parseDERPublicKey(block.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 DER 格式
|
||||||
|
return parseDERPublicKey(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDERPublicKey 从 DER 字节解析 RSA 公钥
|
||||||
|
func parseDERPublicKey(derBytes []byte) (*rsa.PublicKey, error) {
|
||||||
|
pub, err := x509.ParsePKIXPublicKey(derBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("PKIX 公钥解析失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("解析结果不是 RSA 公钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsaPub, nil
|
||||||
|
}
|
||||||
84
pkg/fuiou/notify.go
Normal file
84
pkg/fuiou/notify.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package fuiou
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyNotify 验证回调通知并解析数据
|
||||||
|
// rawBody: 原始请求 body(可能包含 req= 前缀的 URL 编码 GBK XML)
|
||||||
|
func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) {
|
||||||
|
content := string(rawBody)
|
||||||
|
|
||||||
|
// 处理 req= 前缀(富友回调可能以 form 格式发送)
|
||||||
|
if strings.HasPrefix(content, "req=") {
|
||||||
|
content = content[4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 解码
|
||||||
|
decoded, err := url.QueryUnescape(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("URL 解码回调数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GBK → UTF-8
|
||||||
|
utf8Bytes, err := GBKToUTF8([]byte(decoded))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GBK 转 UTF-8 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换编码声明
|
||||||
|
utf8Str := strings.Replace(string(utf8Bytes), `encoding="GBK"`, `encoding="UTF-8"`, 1)
|
||||||
|
|
||||||
|
// XML 解析
|
||||||
|
var notify NotifyRequest
|
||||||
|
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
||||||
|
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
if err := c.Verify(¬ify, notify.Sign); err != nil {
|
||||||
|
return nil, fmt.Errorf("回调签名验证失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查结果码
|
||||||
|
if notify.ResultCode != "000000" {
|
||||||
|
return ¬ify, fmt.Errorf("回调结果非成功: %s - %s", notify.ResultCode, notify.ResultMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ¬ify, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNotifySuccessResponse 构建成功响应(GBK 编码的 XML)
|
||||||
|
func BuildNotifySuccessResponse() []byte {
|
||||||
|
return buildNotifyResponse("000000", "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNotifyFailResponse 构建失败响应(GBK 编码的 XML)
|
||||||
|
func BuildNotifyFailResponse(msg string) []byte {
|
||||||
|
return buildNotifyResponse("999999", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNotifyResponse 构建回调响应 XML(GBK 编码)
|
||||||
|
func buildNotifyResponse(code, msg string) []byte {
|
||||||
|
resp := NotifyResponse{
|
||||||
|
ResultCode: code,
|
||||||
|
ResultMsg: msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlBytes, err := xml.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return []byte(`<?xml version="1.0" encoding="GBK"?><xml><result_code>999999</result_code><result_msg>internal error</result_msg></xml>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlStr := `<?xml version="1.0" encoding="GBK"?>` + string(xmlBytes)
|
||||||
|
|
||||||
|
gbkBytes, err := utf8ToGBK([]byte(xmlStr))
|
||||||
|
if err != nil {
|
||||||
|
return []byte(xmlStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gbkBytes
|
||||||
|
}
|
||||||
61
pkg/fuiou/types.go
Normal file
61
pkg/fuiou/types.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Package fuiou 富友支付 SDK
|
||||||
|
// 实现富友 wxPreCreate 接口的签名、通信和回调处理
|
||||||
|
package fuiou
|
||||||
|
|
||||||
|
// WxPreCreateRequest wxPreCreate 下单请求
|
||||||
|
type WxPreCreateRequest struct {
|
||||||
|
Version string `xml:"version"` // 版本号: 1.0
|
||||||
|
InsCd string `xml:"ins_cd"` // 机构号
|
||||||
|
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||||
|
TermId string `xml:"term_id"` // 终端号
|
||||||
|
RandomStr string `xml:"random_str"` // 随机字符串
|
||||||
|
Sign string `xml:"sign"` // 签名
|
||||||
|
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||||
|
TradeType string `xml:"trade_type"` // 交易类型: JSAPI=公众号 LETPAY=小程序
|
||||||
|
OrderAmt string `xml:"order_amt"` // 订单金额(分)
|
||||||
|
GoodsDesc string `xml:"goods_des"` // 商品描述
|
||||||
|
TermIp string `xml:"term_ip"` // 终端IP
|
||||||
|
NotifyUrl string `xml:"notify_url"` // 回调地址
|
||||||
|
SubAppid string `xml:"sub_appid"` // 子应用ID(公众号/小程序AppID)
|
||||||
|
SubOpenid string `xml:"sub_openid"` // 用户OpenID
|
||||||
|
LimitPay string `xml:"limit_pay"` // 限制支付方式(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WxPreCreateResponse wxPreCreate 下单响应
|
||||||
|
type WxPreCreateResponse struct {
|
||||||
|
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
|
||||||
|
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||||
|
InsCd string `xml:"ins_cd"` // 机构号
|
||||||
|
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||||
|
RandomStr string `xml:"random_str"` // 随机字符串
|
||||||
|
Sign string `xml:"sign"` // 签名
|
||||||
|
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号
|
||||||
|
// JSAPI 支付参数
|
||||||
|
SdkAppid string `xml:"sdk_appid"` // 应用ID
|
||||||
|
SdkTimestamp string `xml:"sdk_timestamp"` // 时间戳
|
||||||
|
SdkNoncestr string `xml:"sdk_noncestr"` // 随机字符串
|
||||||
|
SdkPrepayid string `xml:"sdk_prepayid"` // 预支付ID
|
||||||
|
SdkPackage string `xml:"sdk_package"` // 扩展字段
|
||||||
|
SdkSigntype string `xml:"sdk_signtype"` // 签名类型
|
||||||
|
SdkPaysign string `xml:"sdk_paysign"` // 支付签名
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyRequest 支付回调请求
|
||||||
|
type NotifyRequest struct {
|
||||||
|
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||||
|
InsCd string `xml:"ins_cd"` // 机构号
|
||||||
|
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||||
|
OrderAmt string `xml:"order_amt"` // 订单金额(分)
|
||||||
|
TransactionId string `xml:"transaction_id"` // 交易流水号
|
||||||
|
ResultCode string `xml:"result_code"` // 结果码
|
||||||
|
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||||
|
Sign string `xml:"sign"` // 签名
|
||||||
|
RandomStr string `xml:"random_str"` // 随机字符串
|
||||||
|
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyResponse 回调响应
|
||||||
|
type NotifyResponse struct {
|
||||||
|
ResultCode string `xml:"result_code"` // 结果码
|
||||||
|
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||||
|
}
|
||||||
56
pkg/fuiou/wxprecreate.go
Normal file
56
pkg/fuiou/wxprecreate.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package fuiou
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WxPreCreate 微信预下单(公众号JSAPI + 小程序)
|
||||||
|
// tradeType: "JSAPI"(公众号)或 "LETPAY"(小程序)
|
||||||
|
// subAppid: 公众号或小程序 AppID
|
||||||
|
// subOpenid: 用户在对应应用下的 OpenID
|
||||||
|
func (c *Client) WxPreCreate(orderNo, amount, goodsDesc, termIP, tradeType, subAppid, subOpenid string) (*WxPreCreateResponse, error) {
|
||||||
|
req := &WxPreCreateRequest{
|
||||||
|
Version: "1.0",
|
||||||
|
InsCd: c.InsCd,
|
||||||
|
MchntCd: c.MchntCd,
|
||||||
|
TermId: c.TermId,
|
||||||
|
RandomStr: generateRandomStr(),
|
||||||
|
MchntOrderNo: orderNo,
|
||||||
|
TradeType: tradeType,
|
||||||
|
OrderAmt: amount,
|
||||||
|
GoodsDesc: goodsDesc,
|
||||||
|
TermIp: termIP,
|
||||||
|
NotifyUrl: c.NotifyURL,
|
||||||
|
SubAppid: subAppid,
|
||||||
|
SubOpenid: subOpenid,
|
||||||
|
}
|
||||||
|
|
||||||
|
sign, err := c.Sign(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("签名失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Sign = sign
|
||||||
|
|
||||||
|
var resp WxPreCreateResponse
|
||||||
|
if err := c.DoRequest("/wxPreCreate", req, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("请求富友失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ResultCode != "000000" {
|
||||||
|
c.logger.Error("富友预下单失败",
|
||||||
|
zap.String("order_no", orderNo),
|
||||||
|
zap.String("result_code", resp.ResultCode),
|
||||||
|
zap.String("result_msg", resp.ResultMsg),
|
||||||
|
)
|
||||||
|
return nil, fmt.Errorf("富友预下单失败: %s", resp.ResultMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("富友预下单成功",
|
||||||
|
zap.String("order_no", orderNo),
|
||||||
|
zap.String("trade_type", tradeType),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
AdminOrder: admin.NewOrderHandler(nil, nil),
|
AdminOrder: admin.NewOrderHandler(nil, nil),
|
||||||
H5Order: h5.NewOrderHandler(nil),
|
H5Order: h5.NewOrderHandler(nil),
|
||||||
H5Recharge: h5.NewRechargeHandler(nil),
|
H5Recharge: h5.NewRechargeHandler(nil),
|
||||||
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),
|
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil),
|
||||||
PollingConfig: admin.NewPollingConfigHandler(nil),
|
PollingConfig: admin.NewPollingConfigHandler(nil),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
||||||
PollingMonitoring: admin.NewPollingMonitoringHandler(nil),
|
PollingMonitoring: admin.NewPollingMonitoringHandler(nil),
|
||||||
@@ -54,5 +54,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
|
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
|
||||||
Asset: admin.NewAssetHandler(nil, nil, nil),
|
Asset: admin.NewAssetHandler(nil, nil, nil),
|
||||||
AssetWallet: admin.NewAssetWalletHandler(nil),
|
AssetWallet: admin.NewAssetWalletHandler(nil),
|
||||||
|
WechatConfig: admin.NewWechatConfigHandler(nil),
|
||||||
|
AgentRecharge: admin.NewAgentRechargeHandler(nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
||||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
|
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
|
||||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -50,36 +49,3 @@ func NewOfficialAccountApp(cfg *config.Config, cache kernel.CacheInterface, logg
|
|||||||
logger.Info("微信公众号应用初始化成功", zap.String("app_id", oaCfg.AppID))
|
logger.Info("微信公众号应用初始化成功", zap.String("app_id", oaCfg.AppID))
|
||||||
return app, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user