Compare commits
31 Commits
7f18765911
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1e714366 | |||
| d2b765327c | |||
| 7dfcf41b41 | |||
| ed334b946b | |||
| 95b2334658 | |||
| da66e673fe | |||
| 284f6c15c7 | |||
| 55918a0b88 | |||
| d2494798aa | |||
| b9733c4913 | |||
| 9bd55a1695 | |||
| e78f5794b9 | |||
| df76e33105 | |||
| ec86dbf463 | |||
| 817d0d6e04 | |||
| b44363b335 | |||
| 3e8f613475 | |||
| 242e0b1f40 | |||
| 060d8fd65e | |||
| f3297f0529 | |||
| 63ca12393b | |||
| 429edf0d19 | |||
| 7c64e433e8 | |||
| 269769bfe4 | |||
| 1980c846f2 | |||
| 89f9875a97 | |||
| 30c56e66dd | |||
| c86afbfa8f | |||
| aa41a5ed5e | |||
| a308ee228b | |||
| b0da71bd25 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"ralph-loop@claude-plugins-official": true
|
"ralph-loop@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
.config/dbhub.toml
Normal file
13
.config/dbhub.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[[sources]]
|
||||||
|
id = "main"
|
||||||
|
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "search_objects"
|
||||||
|
source = "main"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "execute_sql"
|
||||||
|
source = "main"
|
||||||
|
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||||
|
max_rows = 1000 # Limit query results
|
||||||
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.
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -7,8 +7,8 @@ GOCLEAN=$(GOCMD) clean
|
|||||||
GOTEST=$(GOCMD) test
|
GOTEST=$(GOCMD) test
|
||||||
GOGET=$(GOCMD) get
|
GOGET=$(GOCMD) get
|
||||||
BINARY_NAME=bin/junhong-cmp
|
BINARY_NAME=bin/junhong-cmp
|
||||||
MAIN_PATH=cmd/api/main.go
|
MAIN_PATH=./cmd/api
|
||||||
WORKER_PATH=cmd/worker/main.go
|
WORKER_PATH=./cmd/worker
|
||||||
WORKER_BINARY=bin/junhong-worker
|
WORKER_BINARY=bin/junhong-worker
|
||||||
|
|
||||||
# Database migration parameters
|
# Database migration parameters
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
@@ -22,6 +24,15 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
|
|
||||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||||
handlers := openapi.BuildDocHandlers()
|
handlers := openapi.BuildDocHandlers()
|
||||||
|
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||||
|
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||||
|
handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil)
|
||||||
|
handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.AdminExchange = admin.NewExchangeHandler(nil, nil)
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||||
|
|||||||
102
cmd/api/main.go
102
cmd/api/main.go
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/sms"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,8 +43,6 @@ func main() {
|
|||||||
// 3. 初始化日志
|
// 3. 初始化日志
|
||||||
appLogger := initLogger(cfg)
|
appLogger := initLogger(cfg)
|
||||||
|
|
||||||
// 4. 验证微信配置
|
|
||||||
validateWechatConfig(cfg, appLogger)
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = logger.Sync()
|
_ = logger.Sync()
|
||||||
}()
|
}()
|
||||||
@@ -247,14 +246,11 @@ func applyRateLimiterToBusinessRoutes(app *fiber.App, rateLimitMiddleware fiber.
|
|||||||
adminGroup := app.Group("/api/admin")
|
adminGroup := app.Group("/api/admin")
|
||||||
adminGroup.Use(rateLimitMiddleware)
|
adminGroup.Use(rateLimitMiddleware)
|
||||||
|
|
||||||
h5Group := app.Group("/api/h5")
|
|
||||||
h5Group.Use(rateLimitMiddleware)
|
|
||||||
|
|
||||||
personalGroup := app.Group("/api/c/v1")
|
personalGroup := app.Group("/api/c/v1")
|
||||||
personalGroup.Use(rateLimitMiddleware)
|
personalGroup.Use(rateLimitMiddleware)
|
||||||
|
|
||||||
appLogger.Info("限流器已应用到业务路由组",
|
appLogger.Info("限流器已应用到业务路由组",
|
||||||
zap.Strings("paths", []string{"/api/admin", "/api/h5", "/api/c/v1"}),
|
zap.Strings("paths", []string{"/api/admin", "/api/c/v1"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +307,42 @@ func initAuthComponents(cfg *config.Config, redisClient *redis.Client, appLogger
|
|||||||
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
|
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
|
||||||
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
|
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
|
||||||
|
|
||||||
verificationSvc := verification.NewService(redisClient, nil, appLogger)
|
smsClient := initSMS(cfg, appLogger)
|
||||||
|
verificationSvc := verification.NewService(redisClient, smsClient, appLogger)
|
||||||
|
|
||||||
return jwtManager, tokenManager, verificationSvc
|
return jwtManager, tokenManager, verificationSvc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initSMS(cfg *config.Config, appLogger *zap.Logger) *sms.Client {
|
||||||
|
if cfg.SMS.GatewayURL == "" {
|
||||||
|
appLogger.Info("短信服务未配置,跳过初始化")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.SMS.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := sms.NewStandardHTTPClient(0)
|
||||||
|
client := sms.NewClient(
|
||||||
|
cfg.SMS.GatewayURL,
|
||||||
|
cfg.SMS.Username,
|
||||||
|
cfg.SMS.Password,
|
||||||
|
cfg.SMS.Signature,
|
||||||
|
timeout,
|
||||||
|
appLogger,
|
||||||
|
httpClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
appLogger.Info("短信服务已初始化",
|
||||||
|
zap.String("gateway_url", cfg.SMS.GatewayURL),
|
||||||
|
zap.String("signature", cfg.SMS.Signature),
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
|
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
|
||||||
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
|
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
|
||||||
appLogger.Info("对象存储未配置,跳过初始化")
|
appLogger.Info("对象存储未配置,跳过初始化")
|
||||||
@@ -355,64 +382,3 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
|||||||
|
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
|
|
||||||
wechatCfg := cfg.Wechat
|
|
||||||
|
|
||||||
if wechatCfg.OfficialAccount.AppID == "" && wechatCfg.Payment.AppID == "" {
|
|
||||||
appLogger.Warn("微信配置未设置,微信相关功能将不可用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if wechatCfg.OfficialAccount.AppID != "" {
|
|
||||||
if wechatCfg.OfficialAccount.AppSecret == "" {
|
|
||||||
appLogger.Fatal("微信公众号配置不完整",
|
|
||||||
zap.String("missing", "app_secret"),
|
|
||||||
zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET"))
|
|
||||||
}
|
|
||||||
appLogger.Info("微信公众号配置已验证",
|
|
||||||
zap.String("app_id", wechatCfg.OfficialAccount.AppID))
|
|
||||||
}
|
|
||||||
|
|
||||||
if wechatCfg.Payment.AppID != "" {
|
|
||||||
missingFields := []string{}
|
|
||||||
|
|
||||||
if wechatCfg.Payment.MchID == "" {
|
|
||||||
missingFields = append(missingFields, "mch_id (JUNHONG_WECHAT_PAYMENT_MCH_ID)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.APIV3Key == "" {
|
|
||||||
missingFields = append(missingFields, "api_v3_key (JUNHONG_WECHAT_PAYMENT_API_V3_KEY)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.CertPath == "" {
|
|
||||||
missingFields = append(missingFields, "cert_path (JUNHONG_WECHAT_PAYMENT_CERT_PATH)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.KeyPath == "" {
|
|
||||||
missingFields = append(missingFields, "key_path (JUNHONG_WECHAT_PAYMENT_KEY_PATH)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.SerialNo == "" {
|
|
||||||
missingFields = append(missingFields, "serial_no (JUNHONG_WECHAT_PAYMENT_SERIAL_NO)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.NotifyURL == "" {
|
|
||||||
missingFields = append(missingFields, "notify_url (JUNHONG_WECHAT_PAYMENT_NOTIFY_URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missingFields) > 0 {
|
|
||||||
appLogger.Fatal("微信支付配置不完整",
|
|
||||||
zap.Strings("missing_fields", missingFields))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(wechatCfg.Payment.CertPath); os.IsNotExist(err) {
|
|
||||||
appLogger.Fatal("微信支付证书文件不存在",
|
|
||||||
zap.String("cert_path", wechatCfg.Payment.CertPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(wechatCfg.Payment.KeyPath); os.IsNotExist(err) {
|
|
||||||
appLogger.Fatal("微信支付私钥文件不存在",
|
|
||||||
zap.String("key_path", wechatCfg.Payment.KeyPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
appLogger.Info("微信支付配置已验证",
|
|
||||||
zap.String("app_id", wechatCfg.Payment.AppID),
|
|
||||||
zap.String("mch_id", wechatCfg.Payment.MchID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
@@ -31,6 +33,15 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
|
|
||||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||||
handlers := openapi.BuildDocHandlers()
|
handlers := openapi.BuildDocHandlers()
|
||||||
|
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||||
|
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||||
|
handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil)
|
||||||
|
handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.AdminExchange = admin.NewExchangeHandler(nil, nil)
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ version: '3.8'
|
|||||||
#
|
#
|
||||||
# 可选配置(根据需要启用):
|
# 可选配置(根据需要启用):
|
||||||
# - Gateway 服务配置(JUNHONG_GATEWAY_*)
|
# - Gateway 服务配置(JUNHONG_GATEWAY_*)
|
||||||
# - 微信公众号配置(JUNHONG_WECHAT_OFFICIAL_ACCOUNT_*)
|
|
||||||
# - 微信支付配置(JUNHONG_WECHAT_PAYMENT_*)
|
|
||||||
# - 对象存储配置(JUNHONG_STORAGE_*)
|
# - 对象存储配置(JUNHONG_STORAGE_*)
|
||||||
|
# - 短信服务配置(JUNHONG_SMS_*)
|
||||||
|
#
|
||||||
|
# 微信公众号/小程序/支付配置已迁移至数据库(tb_wechat_config 表),
|
||||||
|
# 不再需要环境变量和证书文件挂载。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
@@ -65,28 +67,13 @@ services:
|
|||||||
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
||||||
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
||||||
- JUNHONG_GATEWAY_TIMEOUT=30
|
- JUNHONG_GATEWAY_TIMEOUT=30
|
||||||
# 微信公众号配置(可选)
|
# 短信服务配置
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
|
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
|
- JUNHONG_SMS_USERNAME=JH0001
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
|
- JUNHONG_SMS_PASSWORD=wwR8E4qnL6F0
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
|
- JUNHONG_SMS_SIGNATURE=【JHFTIOT】
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
|
|
||||||
# 微信支付配置(可选)
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
|
|
||||||
volumes:
|
volumes:
|
||||||
# 仅挂载日志目录(配置已嵌入二进制文件)
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
|
|
||||||
# - ./certs:/app/certs:ro
|
|
||||||
networks:
|
networks:
|
||||||
- junhong-network
|
- junhong-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -137,27 +124,8 @@ services:
|
|||||||
- JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
- JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
||||||
- JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
- JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
||||||
- JUNHONG_GATEWAY_TIMEOUT=30
|
- JUNHONG_GATEWAY_TIMEOUT=30
|
||||||
# 微信公众号配置(可选)
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
|
|
||||||
# 微信支付配置(可选)
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
|
|
||||||
# - ./certs:/app/certs:ro
|
|
||||||
networks:
|
networks:
|
||||||
- junhong-network
|
- junhong-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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`)创建订单成功后,前端获取支付参数的接口本次未实现。充值回调处理已完整实现——等支付发起改造完成后,完整的充值支付闭环即可联通。
|
||||||
128
docs/client-api-data-model-fixes/功能总结.md
Normal file
128
docs/client-api-data-model-fixes/功能总结.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# 客户端接口数据模型基础准备 - 功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本提案作为客户端接口系列的前置基础,完成三类工作:BUG 修复、基础字段准备、旧接口清理。
|
||||||
|
|
||||||
|
## 一、BUG 修复
|
||||||
|
|
||||||
|
### BUG-1:代理零售价修复
|
||||||
|
|
||||||
|
**问题**:`ShopPackageAllocation` 缺少 `retail_price` 字段,所有渠道统一使用 `Package.SuggestedRetailPrice`,代理无法设定自己的零售价。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `ShopPackageAllocation` 新增 `retail_price` 字段(迁移中存量数据批量回填为 `SuggestedRetailPrice`)
|
||||||
|
- `GetPurchasePrice()` 改为按渠道取价:代理渠道返回 `allocation.RetailPrice`,平台渠道返回 `SuggestedRetailPrice`
|
||||||
|
- `validatePackages()` 价格累加同步修正,代理渠道额外校验 `RetailPrice >= CostPrice`
|
||||||
|
- 分配创建(`shop_package_batch_allocation`、`shop_series_grant`)时自动设置 `RetailPrice = SuggestedRetailPrice`
|
||||||
|
- 新增 cost_price 分配锁定:存在下级分配记录时禁止修改 `cost_price`
|
||||||
|
- `BatchUpdatePricing` 接口仅支持成本价批量调整(保留 cost_price 锁定规则)
|
||||||
|
- 新增独立接口 `PATCH /api/admin/packages/:id/retail-price`,代理可修改自己的套餐零售价
|
||||||
|
- `PackageResponse` 新增 `retail_price` 字段,利润计算修正为 `RetailPrice - CostPrice`
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/model/shop_package_allocation.go`
|
||||||
|
- `internal/model/dto/shop_package_batch_pricing_dto.go`
|
||||||
|
- `internal/model/dto/package_dto.go`
|
||||||
|
- `internal/service/purchase_validation/service.go`
|
||||||
|
- `internal/service/shop_package_batch_allocation/service.go`
|
||||||
|
- `internal/service/shop_series_grant/service.go`
|
||||||
|
- `internal/service/shop_package_batch_pricing/service.go`
|
||||||
|
- `internal/service/package/service.go`
|
||||||
|
|
||||||
|
### BUG-2:一次性佣金触发条件修复
|
||||||
|
|
||||||
|
**问题**:后台所有订单(包括代理自购)都可能触发一次性佣金。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `Order` 新增 `source` 字段(`admin`/`client`),默认 `admin`
|
||||||
|
- 佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`
|
||||||
|
- `CreateAdminOrder()` 设置 `Source: constants.OrderSourceAdmin`
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/model/order.go`
|
||||||
|
- `internal/service/commission_calculation/service.go`(两个方法)
|
||||||
|
- `internal/service/order/service.go`
|
||||||
|
|
||||||
|
### BUG-4:充值回调事务一致性修复
|
||||||
|
|
||||||
|
**问题**:`HandlePaymentCallback` 中 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 使用 `s.db` 而非事务内 `tx`。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `AssetRechargeStore` 新增 `UpdateStatusWithOptimisticLockDB` 和 `UpdatePaymentInfoWithDB` 方法(支持传入 `tx`)
|
||||||
|
- 原方法保留(委托调用新方法),确保向后兼容
|
||||||
|
- `HandlePaymentCallback` 改用事务内 `tx` 调用
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/store/postgres/asset_recharge_store.go`
|
||||||
|
- `internal/service/recharge/service.go`
|
||||||
|
|
||||||
|
## 二、基础字段准备
|
||||||
|
|
||||||
|
### 新增常量文件
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `pkg/constants/asset_status.go` | 资产业务状态(在库/已销售/已换货/已停用) |
|
||||||
|
| `pkg/constants/order_source.go` | 订单来源(admin/client) |
|
||||||
|
| `pkg/constants/operator_type.go` | 操作人类型(admin_user/personal_customer) |
|
||||||
|
| `pkg/constants/realname_link.go` | 实名链接类型(none/template/gateway) |
|
||||||
|
|
||||||
|
### 模型字段变更
|
||||||
|
|
||||||
|
| 模型 | 新增字段 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `IotCard` | `asset_status`, `generation` | 业务生命周期状态、资产世代编号 |
|
||||||
|
| `Device` | `asset_status`, `generation` | 同上 |
|
||||||
|
| `Order` | `source`, `generation` | 订单来源、资产世代快照 |
|
||||||
|
| `PackageUsage` | `generation` | 资产世代快照 |
|
||||||
|
| `AssetRechargeRecord` | `operator_type`, `generation`, `linked_package_ids`, `linked_order_type`, `linked_carrier_type`, `linked_carrier_id` | 操作人类型、世代、强充关联字段 |
|
||||||
|
| `Carrier` | `realname_link_type`, `realname_link_template` | 实名链接配置 |
|
||||||
|
| `ShopPackageAllocation` | `retail_price` | 代理零售价 |
|
||||||
|
| `PersonalCustomer` | `wx_open_id` 索引变更 | 唯一索引改为普通索引 |
|
||||||
|
|
||||||
|
### Carrier 管理 DTO 更新
|
||||||
|
|
||||||
|
- `CarrierCreateRequest`、`CarrierUpdateRequest` 新增 `realname_link_type` 和 `realname_link_template` 字段
|
||||||
|
- `CarrierResponse` 新增对应展示字段
|
||||||
|
- Carrier Service 的 Create/Update 方法同步处理,Update 时 `template` 类型强制校验模板非空
|
||||||
|
|
||||||
|
### 资产手动停用
|
||||||
|
|
||||||
|
- 新增 `PATCH /api/admin/iot-cards/:id/deactivate` 和 `PATCH /api/admin/devices/:id/deactivate`
|
||||||
|
- 仅 `asset_status` 为 1(在库)或 2(已销售)时允许停用
|
||||||
|
- 使用条件更新确保幂等
|
||||||
|
|
||||||
|
## 三、旧接口清理
|
||||||
|
|
||||||
|
### H5 接口删除
|
||||||
|
|
||||||
|
- 删除 `internal/handler/h5/` 全部文件(5 个)
|
||||||
|
- 删除 `internal/routes/h5*.go`(3 个文件)
|
||||||
|
- 清理 `routes.go`、`order.go`、`recharge.go` 中的 H5 路由注册
|
||||||
|
- 清理 `bootstrap/` 中 H5 Handler 构造和字段
|
||||||
|
- 清理 `middlewares.go` 中 H5 认证中间件
|
||||||
|
- 清理 `pkg/openapi/handlers.go` 中 H5 文档生成引用
|
||||||
|
- 清理 `cmd/api/main.go` 中 H5 限流挂载
|
||||||
|
|
||||||
|
### 个人客户旧登录方法删除
|
||||||
|
|
||||||
|
- 删除 `internal/handler/app/personal_customer.go` 中 Login、SendCode、WechatOAuthLogin、BindWechat 方法
|
||||||
|
- 清理对应路由注册
|
||||||
|
- 保留 UpdateProfile 和 GetProfile
|
||||||
|
|
||||||
|
## 四、数据库迁移
|
||||||
|
|
||||||
|
- 迁移编号:000082
|
||||||
|
- 涉及 7 张表、15+ 个字段变更
|
||||||
|
- 包含存量 `retail_price` 批量回填
|
||||||
|
- 包含 `wx_open_id` 索引从唯一改为普通
|
||||||
|
- 所有字段使用 `NOT NULL DEFAULT` 确保存量兼容
|
||||||
|
|
||||||
|
## 五、后台订单 generation 快照
|
||||||
|
|
||||||
|
- `CreateAdminOrder()` 创建订单时从资产(IotCard/Device)获取当前 `Generation` 值写入订单
|
||||||
|
- 不再依赖数据库默认值 1
|
||||||
1214
docs/client-api-requirements/需求说明.md
Normal file
1214
docs/client-api-requirements/需求说明.md
Normal file
File diff suppressed because it is too large
Load Diff
141
docs/client-auth-system/功能总结.md
Normal file
141
docs/client-auth-system/功能总结.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# C 端认证系统功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次实现了面向个人客户(C 端)的完整认证体系,替代旧 H5 登录接口。支持微信公众号和小程序两种登录方式,基于「资产标识符验证 → 微信授权 → 自动绑定资产 → 可选绑定手机号」的流程。
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
| 接口 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| A1 | `POST /api/c/v1/auth/verify-asset` | 否 | 资产标识符验证,返回 asset_token |
|
||||||
|
| A2 | `POST /api/c/v1/auth/wechat-login` | 否 | 微信公众号登录 |
|
||||||
|
| A3 | `POST /api/c/v1/auth/miniapp-login` | 否 | 微信小程序登录 |
|
||||||
|
| A4 | `POST /api/c/v1/auth/send-code` | 否 | 发送手机验证码 |
|
||||||
|
| A5 | `POST /api/c/v1/auth/bind-phone` | 是 | 首次绑定手机号 |
|
||||||
|
| A6 | `POST /api/c/v1/auth/change-phone` | 是 | 换绑手机号(双验证码) |
|
||||||
|
| A7 | `POST /api/c/v1/auth/logout` | 是 | 退出登录 |
|
||||||
|
|
||||||
|
## 登录流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入资产标识符(SN/IMEI/ICCID)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[A1] verify-asset → asset_token(5分钟有效)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
微信授权(前端完成)
|
||||||
|
│
|
||||||
|
├── 公众号 → [A2] wechat-login (code + asset_token)
|
||||||
|
└── 小程序 → [A3] miniapp-login (code + asset_token)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
解析 asset_token → 获取微信 openid
|
||||||
|
→ 查找/创建客户 → 绑定资产
|
||||||
|
→ 签发 JWT + Redis 存储
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
返回 { token, need_bind_phone, is_new_user }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
need_bind_phone == true?
|
||||||
|
YES → [A4] 发送验证码 → [A5] 绑定手机号
|
||||||
|
NO → 进入主页面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
### 有状态 JWT(JWT + Redis)
|
||||||
|
|
||||||
|
- JWT payload 仅含 `customer_id` + `exp`
|
||||||
|
- 登录时将 token 写入 Redis,TTL 与 JWT 一致
|
||||||
|
- 每次请求在中间件同时校验 JWT 签名和 Redis 有效状态
|
||||||
|
- 支持服务端主动失效(封禁、强制下线、退出登录)
|
||||||
|
- 单点登录:新登录覆盖旧 token
|
||||||
|
|
||||||
|
### OpenID 多记录管理
|
||||||
|
|
||||||
|
- 新增 `tb_personal_customer_openid` 表
|
||||||
|
- 同一客户可在多个 AppID(公众号/小程序)下拥有不同 OpenID
|
||||||
|
- 唯一约束:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
|
||||||
|
- 客户查找逻辑:openid 精确匹配 → unionid 回退合并 → 创建新客户
|
||||||
|
|
||||||
|
### 资产绑定
|
||||||
|
|
||||||
|
- 每次登录创建 `PersonalCustomerDevice` 绑定记录
|
||||||
|
- 同一资产允许被多个客户绑定(支持转手场景)
|
||||||
|
- 首次绑定时自动将资产状态从「在库(1)」更新为「已销售(2)」
|
||||||
|
|
||||||
|
### 微信配置动态加载
|
||||||
|
|
||||||
|
- 登录时从数据库 `tb_wechat_config` 动态读取激活配置
|
||||||
|
- 优先走 WechatConfigService 的 Redis 缓存
|
||||||
|
- 小程序登录直接 HTTP 调用微信 `jscode2session`(不依赖 PowerWeChat SDK)
|
||||||
|
|
||||||
|
## 限流策略
|
||||||
|
|
||||||
|
| 接口 | 维度 | 限制 |
|
||||||
|
|------|------|------|
|
||||||
|
| A1 | IP | 30 次/分钟 |
|
||||||
|
| A4 | 手机号 | 60 秒冷却 |
|
||||||
|
| A4 | IP | 20 次/小时 |
|
||||||
|
| A4 | 手机号 | 10 次/天 |
|
||||||
|
|
||||||
|
## 新增/修改文件
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `internal/model/personal_customer_openid.go` | OpenID 关联模型 |
|
||||||
|
| `internal/model/dto/client_auth_dto.go` | A1-A7 请求/响应 DTO |
|
||||||
|
| `internal/store/postgres/personal_customer_openid_store.go` | OpenID Store |
|
||||||
|
| `internal/service/client_auth/service.go` | 认证 Service(核心业务逻辑) |
|
||||||
|
| `internal/handler/app/client_auth.go` | 认证 Handler(7 个端点) |
|
||||||
|
| `pkg/wechat/miniapp.go` | 小程序 SDK 封装 |
|
||||||
|
| `migrations/000083_add_personal_customer_openid.up.sql` | 迁移文件 |
|
||||||
|
| `migrations/000083_add_personal_customer_openid.down.sql` | 回滚文件 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `internal/middleware/personal_auth.go` | 增加 Redis 双重校验 |
|
||||||
|
| `pkg/constants/redis.go` | 新增 token 和限流 Redis Key |
|
||||||
|
| `pkg/errors/codes.go` | 新增错误码 1180-1186 |
|
||||||
|
| `pkg/config/defaults/config.yaml` | 新增 `client.require_phone_binding` |
|
||||||
|
| `pkg/wechat/wechat.go` | 新增 MiniAppServiceInterface |
|
||||||
|
| `pkg/wechat/config.go` | 新增 3 个 DB 动态工厂函数 |
|
||||||
|
| `internal/bootstrap/types.go` | 新增 ClientAuth Handler 字段 |
|
||||||
|
| `internal/bootstrap/handlers.go` | 实例化 ClientAuth Handler |
|
||||||
|
| `internal/bootstrap/services.go` | 初始化 ClientAuth Service |
|
||||||
|
| `internal/bootstrap/stores.go` | 初始化 OpenID Store |
|
||||||
|
| `internal/routes/personal.go` | 注册 7 个认证端点 |
|
||||||
|
| `cmd/api/docs.go` | 注册文档生成器 |
|
||||||
|
| `cmd/gendocs/main.go` | 注册文档生成器 |
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
| 码值 | 常量名 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 1180 | CodeAssetNotFound | 资产不存在 |
|
||||||
|
| 1181 | CodeWechatConfigUnavailable | 微信配置不可用 |
|
||||||
|
| 1182 | CodeSmsSendFailed | 短信发送失败 |
|
||||||
|
| 1183 | CodeVerificationCodeInvalid | 验证码错误或已过期 |
|
||||||
|
| 1184 | CodePhoneAlreadyBound | 手机号已被其他客户绑定 |
|
||||||
|
| 1185 | CodeAlreadyBoundPhone | 已绑定手机号不可重复绑定 |
|
||||||
|
| 1186 | CodeOldPhoneMismatch | 旧手机号与当前绑定不匹配 |
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
- 新建表 `tb_personal_customer_openid`(迁移 000083)
|
||||||
|
- 唯一索引:`idx_pco_app_id_open_id` (app_id, open_id) 软删除条件
|
||||||
|
- 普通索引:`idx_pco_customer_id` (customer_id)
|
||||||
|
- 条件索引:`idx_pco_union_id` (union_id) WHERE union_id != ''
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 配置路径 | 环境变量 | 默认值 | 说明 |
|
||||||
|
|---------|---------|-------|------|
|
||||||
|
| `client.require_phone_binding` | `JUNHONG_CLIENT_REQUIRE_PHONE_BINDING` | `true` | 是否要求绑定手机号 |
|
||||||
122
docs/client-core-business-api/功能总结.md
Normal file
122
docs/client-core-business-api/功能总结.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# 客户端核心业务 API — 功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本提案为客户端(C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。
|
||||||
|
|
||||||
|
**前置依赖**:提案 0(数据模型修复)、提案 1(C 端认证系统)。
|
||||||
|
|
||||||
|
## API 端点一览
|
||||||
|
|
||||||
|
### 模块 B:资产信息(4 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/asset/info` | B1 资产基本信息查询 |
|
||||||
|
| GET | `/api/c/v1/asset/packages` | B2 可购买套餐列表 |
|
||||||
|
| GET | `/api/c/v1/asset/package-history` | B3 历史套餐列表 |
|
||||||
|
| POST | `/api/c/v1/asset/refresh` | B4 手动刷新资产状态 |
|
||||||
|
|
||||||
|
### 模块 C:钱包与充值(5 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/wallet/detail` | C1 钱包详情(不存在自动创建) |
|
||||||
|
| GET | `/api/c/v1/wallet/transactions` | C2 钱包流水列表 |
|
||||||
|
| GET | `/api/c/v1/wallet/recharge-check` | C3 充值预检(强充检查) |
|
||||||
|
| POST | `/api/c/v1/wallet/recharge` | C4 创建充值订单(JSAPI 支付) |
|
||||||
|
| GET | `/api/c/v1/wallet/recharges` | C5 充值订单列表 |
|
||||||
|
|
||||||
|
### 模块 D:套餐购买(3 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/c/v1/orders/create` | D1 创建套餐购买订单(含强充分流) |
|
||||||
|
| GET | `/api/c/v1/orders` | D2 套餐订单列表 |
|
||||||
|
| GET | `/api/c/v1/orders/:id` | D3 套餐订单详情 |
|
||||||
|
|
||||||
|
### 模块 E:实名认证(1 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/realname/link` | E1 获取实名跳转链接 |
|
||||||
|
|
||||||
|
### 模块 F:设备能力(5 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/device/cards` | F1 设备卡列表 |
|
||||||
|
| POST | `/api/c/v1/device/reboot` | F2 设备重启 |
|
||||||
|
| POST | `/api/c/v1/device/factory-reset` | F3 恢复出厂设置 |
|
||||||
|
| POST | `/api/c/v1/device/wifi` | F4 设置 WiFi |
|
||||||
|
| POST | `/api/c/v1/device/switch-card` | F5 切卡 |
|
||||||
|
|
||||||
|
## 核心设计决策
|
||||||
|
|
||||||
|
### 1. 数据权限绕过
|
||||||
|
|
||||||
|
客户端调用后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)` 绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
|
||||||
|
|
||||||
|
### 2. 归属校验
|
||||||
|
|
||||||
|
所有涉及资产操作的接口统一前置归属校验:查询 `PersonalCustomerDevice` 条件 `customer_id = 当前登录客户` 且 `virtual_no = 资产虚拟号`,未命中返回 403。
|
||||||
|
|
||||||
|
### 3. Generation 过滤
|
||||||
|
|
||||||
|
客户端历史查询统一附加 `WHERE generation = 资产当前 generation`,确保转手后数据隔离。
|
||||||
|
|
||||||
|
### 4. OpenID 安全规范
|
||||||
|
|
||||||
|
支付接口(C4/D1)所需 OpenID 由后端按 `customer_id + app_type` 查询,客户端禁止传入 OpenID。根据 `app_type` 选择对应的微信 AppID 创建支付实例。
|
||||||
|
|
||||||
|
### 5. 强充两阶段
|
||||||
|
|
||||||
|
- 第一阶段(同步):充值入账、更新状态
|
||||||
|
- 第二阶段(异步 Asynq):钱包扣款 → 创建订单 → 激活套餐
|
||||||
|
|
||||||
|
`AssetRechargeRecord.auto_purchase_status` 字段追踪异步状态(pending/success/failed)。
|
||||||
|
|
||||||
|
## 新增文件
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/model/dto/client_asset_dto.go # 资产模块 DTO
|
||||||
|
internal/model/dto/client_wallet_dto.go # 钱包模块 DTO
|
||||||
|
internal/model/dto/client_order_dto.go # 订单模块 DTO
|
||||||
|
internal/model/dto/client_realname_device_dto.go # 实名+设备模块 DTO
|
||||||
|
internal/handler/app/client_asset.go # 资产 Handler
|
||||||
|
internal/handler/app/client_wallet.go # 钱包 Handler
|
||||||
|
internal/handler/app/client_order.go # 订单 Handler
|
||||||
|
internal/handler/app/client_realname.go # 实名 Handler
|
||||||
|
internal/handler/app/client_device.go # 设备 Handler
|
||||||
|
internal/service/client_order/service.go # 客户端订单编排 Service
|
||||||
|
internal/task/auto_purchase.go # 强充异步自动购买任务
|
||||||
|
migrations/000084_add_auto_purchase_status_*.sql # 数据库迁移
|
||||||
|
```
|
||||||
|
|
||||||
|
## 修改文件
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/constants/constants.go # 新增 auto_purchase_status 常量 + 任务类型
|
||||||
|
pkg/constants/redis.go # 新增客户端购买幂等键
|
||||||
|
pkg/errors/codes.go # 新增 NEED_REALNAME/OPENID_NOT_FOUND 错误码
|
||||||
|
internal/model/asset_wallet.go # AssetRechargeRecord 新增字段
|
||||||
|
internal/bootstrap/types.go # 5 个 Handler 字段
|
||||||
|
internal/bootstrap/handlers.go # Handler 实例化
|
||||||
|
internal/routes/personal.go # 18 个路由注册
|
||||||
|
pkg/openapi/handlers.go # 文档生成 Handler
|
||||||
|
cmd/api/docs.go # 文档注册
|
||||||
|
cmd/gendocs/main.go # 文档注册
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增错误码
|
||||||
|
|
||||||
|
| 错误码 | 常量名 | 消息 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 1187 | CodeNeedRealname | 该套餐需实名认证后购买 |
|
||||||
|
| 1188 | CodeOpenIDNotFound | 未找到微信授权信息,请先完成授权 |
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
- 表:`tb_asset_recharge_record`
|
||||||
|
- 新增字段:`auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL`
|
||||||
|
- 迁移版本:000084
|
||||||
94
docs/client-exchange-system/功能总结.md
Normal file
94
docs/client-exchange-system/功能总结.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 客户端换货系统功能总结
|
||||||
|
|
||||||
|
## 1. 功能概述
|
||||||
|
|
||||||
|
本次实现完成了客户端换货系统的后台与客户端闭环能力,覆盖「后台建单 → 客户端填写收货信息 → 后台发货 → 后台确认完成(可选全量迁移) → 旧资产转新」完整流程。
|
||||||
|
|
||||||
|
## 2. 数据模型与迁移
|
||||||
|
|
||||||
|
- 新增 `tb_exchange_order` 表,承载换货生命周期全量字段:旧/新资产、收货信息、物流信息、迁移状态、业务状态、多租户字段。
|
||||||
|
- 保留历史能力:将旧表 `tb_card_replacement_record` 重命名为 `tb_card_replacement_record_legacy`。
|
||||||
|
- 新增迁移文件:
|
||||||
|
- `000085_add_exchange_order.up/down.sql`
|
||||||
|
- `000086_rename_card_replacement_to_legacy.up/down.sql`
|
||||||
|
|
||||||
|
## 3. 后端实现
|
||||||
|
|
||||||
|
### 3.1 Store 层
|
||||||
|
|
||||||
|
- 新增 `ExchangeOrderStore`:
|
||||||
|
- 创建、按 ID 查询、分页列表查询
|
||||||
|
- 条件状态流转更新(`WHERE status = fromStatus`)
|
||||||
|
- 按旧资产查询进行中换货单(状态 `1/2/3`)
|
||||||
|
|
||||||
|
- 新增 `ResourceTagStore`:用于资源标签复制。
|
||||||
|
|
||||||
|
### 3.2 Service 层
|
||||||
|
|
||||||
|
- 新增 `internal/service/exchange/service.go`:
|
||||||
|
- H1 创建换货单(资产存在校验、进行中校验、单号生成、状态初始化)
|
||||||
|
- H2 列表查询
|
||||||
|
- H3 详情查询
|
||||||
|
- H4 发货(状态校验、同类型校验、新资产在库校验、物流与新资产快照写入)
|
||||||
|
- H5 确认完成(状态校验,可选全量迁移)
|
||||||
|
- H6 取消(仅允许 `1/2 -> 5`)
|
||||||
|
- H7 转新(校验已换货状态、`generation+1`、状态重置、清理绑定、创建新钱包)
|
||||||
|
- G1 查询待处理换货单
|
||||||
|
- G2 提交收货信息(`1 -> 2`)
|
||||||
|
|
||||||
|
- 新增 `internal/service/exchange/migration.go`:
|
||||||
|
- 单事务迁移实现
|
||||||
|
- 钱包余额迁移并写入迁移流水
|
||||||
|
- 套餐使用记录迁移(`tb_package_usage`)
|
||||||
|
- 套餐日记录联动更新(`tb_package_usage_daily_record`)
|
||||||
|
- 累计充值/首充字段复制(旧资产 -> 新资产)
|
||||||
|
- 标签复制(`tb_resource_tag`)
|
||||||
|
- 客户绑定 `virtual_no` 更新(`tb_personal_customer_device`)
|
||||||
|
- 旧资产状态置为已换货(`asset_status=3`)
|
||||||
|
- 换货单迁移结果回写(`migration_completed`、`migration_balance`)
|
||||||
|
|
||||||
|
## 4. Handler 与路由
|
||||||
|
|
||||||
|
### 4.1 后台换货接口
|
||||||
|
|
||||||
|
- 新增 `internal/handler/admin/exchange.go`
|
||||||
|
- 新增 `internal/routes/exchange.go`
|
||||||
|
- 注册接口(标签:`换货管理`):
|
||||||
|
- `POST /api/admin/exchanges`
|
||||||
|
- `GET /api/admin/exchanges`
|
||||||
|
- `GET /api/admin/exchanges/:id`
|
||||||
|
- `POST /api/admin/exchanges/:id/ship`
|
||||||
|
- `POST /api/admin/exchanges/:id/complete`
|
||||||
|
- `POST /api/admin/exchanges/:id/cancel`
|
||||||
|
- `POST /api/admin/exchanges/:id/renew`
|
||||||
|
|
||||||
|
### 4.2 客户端换货接口
|
||||||
|
|
||||||
|
- 新增 `internal/handler/app/client_exchange.go`
|
||||||
|
- 在 `internal/routes/personal.go` 注册:
|
||||||
|
- `GET /api/c/v1/exchange/pending`
|
||||||
|
- `POST /api/c/v1/exchange/:id/shipping-info`
|
||||||
|
|
||||||
|
## 5. 兼容与替换
|
||||||
|
|
||||||
|
- `iot_card_store.go` 的 `is_replaced` 过滤逻辑已切换至 `tb_exchange_order`。
|
||||||
|
- 业务主流程不再依赖旧换卡表(仅模型与 legacy 表保留用于历史数据)。
|
||||||
|
|
||||||
|
## 6. 启动装配与文档生成
|
||||||
|
|
||||||
|
已完成换货模块在以下位置的全链路接入:
|
||||||
|
|
||||||
|
- `internal/bootstrap/types.go`
|
||||||
|
- `internal/bootstrap/stores.go`
|
||||||
|
- `internal/bootstrap/services.go`
|
||||||
|
- `internal/bootstrap/handlers.go`
|
||||||
|
- `internal/routes/admin.go`
|
||||||
|
- `pkg/openapi/handlers.go`
|
||||||
|
- `cmd/api/docs.go`
|
||||||
|
- `cmd/gendocs/main.go`
|
||||||
|
|
||||||
|
## 7. 验证结果
|
||||||
|
|
||||||
|
- 已执行:`go build ./...`,编译通过。
|
||||||
|
- 已执行:数据库迁移 `make migrate-up`,版本到 `86`。
|
||||||
|
- 已完成:变更文件 LSP 诊断检查(无 error 级问题)。
|
||||||
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. 首次上线后,需要在管理后台手动创建并激活一个微信配置,否则第三方支付功能处于禁用状态(系统自动降级为仅支持钱包/线下支付)
|
||||||
1205
docs/前端接口变更说明.md
Normal file
1205
docs/前端接口变更说明.md
Normal file
File diff suppressed because it is too large
Load Diff
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=
|
||||||
|
|||||||
@@ -15,15 +15,14 @@ import (
|
|||||||
// Dependencies 封装所有基础依赖
|
// Dependencies 封装所有基础依赖
|
||||||
// 这些是应用启动时初始化的核心组件
|
// 这些是应用启动时初始化的核心组件
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
DB *gorm.DB // PostgreSQL 数据库连接
|
DB *gorm.DB // PostgreSQL 数据库连接
|
||||||
Redis *redis.Client // Redis 客户端
|
Redis *redis.Client // Redis 客户端
|
||||||
Logger *zap.Logger // 应用日志器
|
Logger *zap.Logger // 应用日志器
|
||||||
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
|
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
|
||||||
TokenManager *auth.TokenManager // Token 管理器(后台和H5认证)
|
TokenManager *auth.TokenManager // Token 管理器(后台和H5认证)
|
||||||
VerificationService *verification.Service // 验证码服务
|
VerificationService *verification.Service // 验证码服务
|
||||||
QueueClient *queue.Client // Asynq 任务队列客户端
|
QueueClient *queue.Client // Asynq 任务队列客户端
|
||||||
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
|
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
|
||||||
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
||||||
WechatOfficialAccount wechat.OfficialAccountServiceInterface // 微信公众号服务(可选)
|
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
||||||
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,41 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||||
validate := validator.New()
|
validate := validator.New()
|
||||||
|
personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB)
|
||||||
|
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
|
||||||
|
packageStore := postgres.NewPackageStore(deps.DB)
|
||||||
|
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)
|
||||||
|
iotCardStore := postgres.NewIotCardStore(deps.DB, deps.Redis)
|
||||||
|
deviceStore := postgres.NewDeviceStore(deps.DB, deps.Redis)
|
||||||
|
assetWalletTransactionStore := postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis)
|
||||||
|
assetRechargeStore := postgres.NewAssetRechargeStore(deps.DB, deps.Redis)
|
||||||
|
personalCustomerOpenIDStore := postgres.NewPersonalCustomerOpenIDStore(deps.DB)
|
||||||
|
orderStore := postgres.NewOrderStore(deps.DB, deps.Redis)
|
||||||
|
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
|
||||||
|
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
|
||||||
|
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
|
||||||
|
carrierStore := postgres.NewCarrierStore(deps.DB)
|
||||||
|
clientOrderService := clientOrderSvc.New(
|
||||||
|
svc.Asset,
|
||||||
|
svc.PurchaseValidation,
|
||||||
|
orderStore,
|
||||||
|
assetRechargeStore,
|
||||||
|
assetWalletStore,
|
||||||
|
personalCustomerDeviceStore,
|
||||||
|
personalCustomerOpenIDStore,
|
||||||
|
svc.WechatConfig,
|
||||||
|
packageSeriesStore,
|
||||||
|
shopSeriesAllocationStore,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||||
@@ -18,17 +47,22 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
Role: admin.NewRoleHandler(svc.Role, validate),
|
Role: admin.NewRoleHandler(svc.Role, validate),
|
||||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||||
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
||||||
|
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
|
||||||
|
ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
|
||||||
|
ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, assetRechargeStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
|
||||||
|
ClientOrder: app.NewClientOrderHandler(clientOrderService, svc.Asset, orderStore, personalCustomerDeviceStore, iotCardStore, deviceStore, deps.Logger, deps.DB),
|
||||||
|
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
|
||||||
|
ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger),
|
||||||
|
ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
|
||||||
Shop: admin.NewShopHandler(svc.Shop),
|
Shop: admin.NewShopHandler(svc.Shop),
|
||||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||||
H5Auth: h5.NewAuthHandler(svc.Auth, validate),
|
|
||||||
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
||||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
||||||
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
||||||
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
||||||
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
||||||
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
||||||
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
|
||||||
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
|
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
|
||||||
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
||||||
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
||||||
@@ -41,14 +75,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||||
Package: admin.NewPackageHandler(svc.Package),
|
Package: admin.NewPackageHandler(svc.Package),
|
||||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||||
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
|
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||||
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
||||||
H5Order: h5.NewOrderHandler(svc.Order),
|
AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate),
|
||||||
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
|
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
|
||||||
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, 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),
|
||||||
@@ -56,6 +88,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||||
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),
|
||||||
|
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||||
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
||||||
|
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||||
|
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
|||||||
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
||||||
|
|
||||||
// 创建个人客户认证中间件
|
// 创建个人客户认证中间件
|
||||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
|
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Redis, deps.Logger)
|
||||||
|
|
||||||
// 创建 Token Manager(用于后台和H5认证)
|
// 创建 Token Manager(用于后台和H5认证)
|
||||||
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
|
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
|
||||||
@@ -32,13 +32,9 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
|||||||
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
||||||
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
|
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
|
||||||
|
|
||||||
// 创建H5认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
|
||||||
h5AuthMiddleware := createH5AuthMiddleware(tokenManager, stores.Shop)
|
|
||||||
|
|
||||||
return &Middlewares{
|
return &Middlewares{
|
||||||
PersonalAuth: personalAuthMiddleware,
|
PersonalAuth: personalAuthMiddleware,
|
||||||
AdminAuth: adminAuthMiddleware,
|
AdminAuth: adminAuthMiddleware,
|
||||||
H5Auth: h5AuthMiddleware,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,29 +64,3 @@ func createAdminAuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkg
|
|||||||
ShopStore: shopStore,
|
ShopStore: shopStore,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createH5AuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkgmiddleware.AuthShopStoreInterface) fiber.Handler {
|
|
||||||
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
|
|
||||||
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
|
|
||||||
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户类型:H5 允许 Agent(3), Enterprise(4)
|
|
||||||
if tokenInfo.UserType != constants.UserTypeAgent &&
|
|
||||||
tokenInfo.UserType != constants.UserTypeEnterprise {
|
|
||||||
return nil, errors.New(errors.CodeForbidden, "权限不足")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pkgmiddleware.UserContextInfo{
|
|
||||||
UserID: tokenInfo.UserID,
|
|
||||||
UserType: tokenInfo.UserType,
|
|
||||||
ShopID: tokenInfo.ShopID,
|
|
||||||
EnterpriseID: tokenInfo.EnterpriseID,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
SkipPaths: []string{"/api/h5/login", "/api/h5/refresh-token"},
|
|
||||||
ShopStore: shopStore,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||||
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||||
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
||||||
|
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||||
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
|
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
|
||||||
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
|
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
|
||||||
enterpriseDeviceSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
|
enterpriseDeviceSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
|
||||||
|
exchangeSvc "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
||||||
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
||||||
@@ -32,11 +34,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 {
|
||||||
@@ -45,6 +49,7 @@ type services struct {
|
|||||||
Role *roleSvc.Service
|
Role *roleSvc.Service
|
||||||
Permission *permissionSvc.Service
|
Permission *permissionSvc.Service
|
||||||
PersonalCustomer *personalCustomerSvc.Service
|
PersonalCustomer *personalCustomerSvc.Service
|
||||||
|
ClientAuth *clientAuthSvc.Service
|
||||||
Shop *shopSvc.Service
|
Shop *shopSvc.Service
|
||||||
Auth *authSvc.Service
|
Auth *authSvc.Service
|
||||||
ShopCommission *shopCommissionSvc.Service
|
ShopCommission *shopCommissionSvc.Service
|
||||||
@@ -72,6 +77,7 @@ type services struct {
|
|||||||
CommissionStats *commissionStatsSvc.Service
|
CommissionStats *commissionStatsSvc.Service
|
||||||
PurchaseValidation *purchaseValidationSvc.Service
|
PurchaseValidation *purchaseValidationSvc.Service
|
||||||
Order *orderSvc.Service
|
Order *orderSvc.Service
|
||||||
|
Exchange *exchangeSvc.Service
|
||||||
Recharge *rechargeSvc.Service
|
Recharge *rechargeSvc.Service
|
||||||
PollingConfig *pollingSvc.ConfigService
|
PollingConfig *pollingSvc.ConfigService
|
||||||
PollingConcurrency *pollingSvc.ConcurrencyService
|
PollingConcurrency *pollingSvc.ConcurrencyService
|
||||||
@@ -80,8 +86,11 @@ type services struct {
|
|||||||
PollingCleanup *pollingSvc.CleanupService
|
PollingCleanup *pollingSvc.CleanupService
|
||||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||||
Asset *assetSvc.Service
|
Asset *assetSvc.Service
|
||||||
|
AssetLifecycle *assetSvc.LifecycleService
|
||||||
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,13 +102,30 @@ 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,
|
||||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||||
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
||||||
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
|
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
|
||||||
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole),
|
ClientAuth: clientAuthSvc.New(
|
||||||
|
deps.DB,
|
||||||
|
s.PersonalCustomerOpenID,
|
||||||
|
s.PersonalCustomer,
|
||||||
|
s.PersonalCustomerDevice,
|
||||||
|
s.PersonalCustomerPhone,
|
||||||
|
s.IotCard,
|
||||||
|
s.Device,
|
||||||
|
wechatConfig,
|
||||||
|
deps.VerificationService,
|
||||||
|
deps.JWTManager,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
),
|
||||||
|
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
|
||||||
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
||||||
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
|
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
|
||||||
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
|
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
|
||||||
@@ -142,8 +168,9 @@ 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),
|
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, 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),
|
||||||
@@ -151,7 +178,21 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||||
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
|
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
|
||||||
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),
|
||||||
|
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device),
|
||||||
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,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type stores struct {
|
|||||||
ShopRole *postgres.ShopRoleStore
|
ShopRole *postgres.ShopRoleStore
|
||||||
RolePermission *postgres.RolePermissionStore
|
RolePermission *postgres.RolePermissionStore
|
||||||
PersonalCustomer *postgres.PersonalCustomerStore
|
PersonalCustomer *postgres.PersonalCustomerStore
|
||||||
|
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
|
||||||
|
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
|
||||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||||
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
||||||
CommissionRecord *postgres.CommissionRecordStore
|
CommissionRecord *postgres.CommissionRecordStore
|
||||||
@@ -38,6 +40,8 @@ type stores struct {
|
|||||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||||
Order *postgres.OrderStore
|
Order *postgres.OrderStore
|
||||||
OrderItem *postgres.OrderItemStore
|
OrderItem *postgres.OrderItemStore
|
||||||
|
ExchangeOrder *postgres.ExchangeOrderStore
|
||||||
|
ResourceTag *postgres.ResourceTagStore
|
||||||
PollingConfig *postgres.PollingConfigStore
|
PollingConfig *postgres.PollingConfigStore
|
||||||
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
|
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
|
||||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||||
@@ -53,6 +57,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 {
|
||||||
@@ -66,6 +72,8 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
|
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
|
||||||
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
|
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
|
||||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||||
|
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
|
||||||
|
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
|
||||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||||
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
||||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||||
@@ -90,6 +98,8 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||||
|
ExchangeOrder: postgres.NewExchangeOrderStore(deps.DB),
|
||||||
|
ResourceTag: postgres.NewResourceTagStore(deps.DB),
|
||||||
PollingConfig: postgres.NewPollingConfigStore(deps.DB),
|
PollingConfig: postgres.NewPollingConfigStore(deps.DB),
|
||||||
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
|
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
|
||||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||||
@@ -105,5 +115,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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -16,17 +15,22 @@ type Handlers struct {
|
|||||||
Role *admin.RoleHandler
|
Role *admin.RoleHandler
|
||||||
Permission *admin.PermissionHandler
|
Permission *admin.PermissionHandler
|
||||||
PersonalCustomer *app.PersonalCustomerHandler
|
PersonalCustomer *app.PersonalCustomerHandler
|
||||||
|
ClientAuth *app.ClientAuthHandler
|
||||||
|
ClientAsset *app.ClientAssetHandler
|
||||||
|
ClientWallet *app.ClientWalletHandler
|
||||||
|
ClientOrder *app.ClientOrderHandler
|
||||||
|
ClientExchange *app.ClientExchangeHandler
|
||||||
|
ClientRealname *app.ClientRealnameHandler
|
||||||
|
ClientDevice *app.ClientDeviceHandler
|
||||||
Shop *admin.ShopHandler
|
Shop *admin.ShopHandler
|
||||||
ShopRole *admin.ShopRoleHandler
|
ShopRole *admin.ShopRoleHandler
|
||||||
AdminAuth *admin.AuthHandler
|
AdminAuth *admin.AuthHandler
|
||||||
H5Auth *h5.AuthHandler
|
|
||||||
ShopCommission *admin.ShopCommissionHandler
|
ShopCommission *admin.ShopCommissionHandler
|
||||||
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
||||||
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
||||||
Enterprise *admin.EnterpriseHandler
|
Enterprise *admin.EnterpriseHandler
|
||||||
EnterpriseCard *admin.EnterpriseCardHandler
|
EnterpriseCard *admin.EnterpriseCardHandler
|
||||||
EnterpriseDevice *admin.EnterpriseDeviceHandler
|
EnterpriseDevice *admin.EnterpriseDeviceHandler
|
||||||
EnterpriseDeviceH5 *h5.EnterpriseDeviceHandler
|
|
||||||
Authorization *admin.AuthorizationHandler
|
Authorization *admin.AuthorizationHandler
|
||||||
MyCommission *admin.MyCommissionHandler
|
MyCommission *admin.MyCommissionHandler
|
||||||
IotCard *admin.IotCardHandler
|
IotCard *admin.IotCardHandler
|
||||||
@@ -39,13 +43,11 @@ type Handlers struct {
|
|||||||
PackageSeries *admin.PackageSeriesHandler
|
PackageSeries *admin.PackageSeriesHandler
|
||||||
Package *admin.PackageHandler
|
Package *admin.PackageHandler
|
||||||
PackageUsage *admin.PackageUsageHandler
|
PackageUsage *admin.PackageUsageHandler
|
||||||
H5PackageUsage *h5.PackageUsageHandler
|
|
||||||
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
||||||
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
||||||
ShopSeriesGrant *admin.ShopSeriesGrantHandler
|
ShopSeriesGrant *admin.ShopSeriesGrantHandler
|
||||||
AdminOrder *admin.OrderHandler
|
AdminOrder *admin.OrderHandler
|
||||||
H5Order *h5.OrderHandler
|
AdminExchange *admin.ExchangeHandler
|
||||||
H5Recharge *h5.RechargeHandler
|
|
||||||
PaymentCallback *callback.PaymentHandler
|
PaymentCallback *callback.PaymentHandler
|
||||||
PollingConfig *admin.PollingConfigHandler
|
PollingConfig *admin.PollingConfigHandler
|
||||||
PollingConcurrency *admin.PollingConcurrencyHandler
|
PollingConcurrency *admin.PollingConcurrencyHandler
|
||||||
@@ -54,7 +56,10 @@ type Handlers struct {
|
|||||||
PollingCleanup *admin.PollingCleanupHandler
|
PollingCleanup *admin.PollingCleanupHandler
|
||||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||||
Asset *admin.AssetHandler
|
Asset *admin.AssetHandler
|
||||||
|
AssetLifecycle *admin.AssetLifecycleHandler
|
||||||
AssetWallet *admin.AssetWalletHandler
|
AssetWallet *admin.AssetWalletHandler
|
||||||
|
WechatConfig *admin.WechatConfigHandler
|
||||||
|
AgentRecharge *admin.AgentRechargeHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middlewares 封装所有中间件
|
// Middlewares 封装所有中间件
|
||||||
@@ -62,6 +67,5 @@ type Handlers struct {
|
|||||||
type Middlewares struct {
|
type Middlewares struct {
|
||||||
PersonalAuth *middleware.PersonalAuthMiddleware
|
PersonalAuth *middleware.PersonalAuthMiddleware
|
||||||
AdminAuth func(*fiber.Ctx) error
|
AdminAuth func(*fiber.Ctx) error
|
||||||
H5Auth func(*fiber.Ctx) error
|
|
||||||
// TODO: 新增 Middleware 在此添加字段
|
// TODO: 新增 Middleware 在此添加字段
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
59
internal/handler/admin/asset_lifecycle.go
Normal file
59
internal/handler/admin/asset_lifecycle.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssetLifecycleService 资产生命周期服务接口
|
||||||
|
type AssetLifecycleService interface {
|
||||||
|
// DeactivateIotCard 停用 IoT 卡
|
||||||
|
DeactivateIotCard(ctx context.Context, id uint) error
|
||||||
|
// DeactivateDevice 停用设备
|
||||||
|
DeactivateDevice(ctx context.Context, id uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetLifecycleHandler 资产生命周期处理器
|
||||||
|
type AssetLifecycleHandler struct {
|
||||||
|
service AssetLifecycleService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAssetLifecycleHandler 创建资产生命周期处理器
|
||||||
|
func NewAssetLifecycleHandler(service AssetLifecycleService) *AssetLifecycleHandler {
|
||||||
|
return &AssetLifecycleHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateIotCard 手动停用 IoT 卡
|
||||||
|
// PATCH /api/admin/iot-cards/:id/deactivate
|
||||||
|
func (h *AssetLifecycleHandler) DeactivateIotCard(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.DeactivateIotCard(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateDevice 手动停用设备
|
||||||
|
// PATCH /api/admin/devices/:id/deactivate
|
||||||
|
func (h *AssetLifecycleHandler) DeactivateDevice(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.DeactivateDevice(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
131
internal/handler/admin/exchange.go
Normal file
131
internal/handler/admin/exchange.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExchangeHandler struct {
|
||||||
|
service *exchangeService.Service
|
||||||
|
validator *validator.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler {
|
||||||
|
return &ExchangeHandler{service: service, validator: validator}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateExchangeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.ExchangeListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ExchangeShipRequest
|
||||||
|
if err = c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err = h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.Ship(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.Complete(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ExchangeCancelRequest
|
||||||
|
if err = c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err = h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.Cancel(c.UserContext(), uint(id), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.Renew(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
@@ -128,3 +128,21 @@ func (h *PackageHandler) UpdateShelfStatus(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return response.Success(c, nil)
|
return response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) UpdateRetailPrice(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.UpdateRetailPriceRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.UpdateRetailPrice(c.UserContext(), uint(id), req.RetailPrice); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
588
internal/handler/app/client_asset.go
Normal file
588
internal/handler/app/client_asset.go
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"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/response"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientAssetHandler C 端资产信息处理器
|
||||||
|
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
|
||||||
|
type ClientAssetHandler struct {
|
||||||
|
assetService *asset.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
assetWalletStore *postgres.AssetWalletStore
|
||||||
|
packageStore *postgres.PackageStore
|
||||||
|
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
db *gorm.DB
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientAssetHandler 创建 C 端资产信息处理器
|
||||||
|
func NewClientAssetHandler(
|
||||||
|
assetService *asset.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
assetWalletStore *postgres.AssetWalletStore,
|
||||||
|
packageStore *postgres.PackageStore,
|
||||||
|
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
db *gorm.DB,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ClientAssetHandler {
|
||||||
|
return &ClientAssetHandler{
|
||||||
|
assetService: assetService,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
assetWalletStore: assetWalletStore,
|
||||||
|
packageStore: packageStore,
|
||||||
|
shopPackageAllocationStore: shopPackageAllocationStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedAssetContext struct {
|
||||||
|
CustomerID uint
|
||||||
|
Identifier string
|
||||||
|
Asset *dto.AssetResolveResponse
|
||||||
|
Generation int
|
||||||
|
WalletBalance int64
|
||||||
|
SkipPermissionCtx context.Context
|
||||||
|
IsAgentChannel bool
|
||||||
|
SellerShopID uint
|
||||||
|
MainPackageActived bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
||||||
|
// 处理流程:客户鉴权 -> 标识符解析 -> 资产解析 -> 归属校验 -> 世代与钱包信息补齐
|
||||||
|
func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(c.Query("identifier"))
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
var req dto.AssetRefreshRequest
|
||||||
|
if err := c.BodyParser(&req); err == nil {
|
||||||
|
identifier = strings.TrimSpace(req.Identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, genErr
|
||||||
|
}
|
||||||
|
|
||||||
|
walletBalance, walletErr := h.getAssetWalletBalance(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if walletErr != nil {
|
||||||
|
return nil, walletErr
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxInfo := &resolvedAssetContext{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Identifier: identifier,
|
||||||
|
Asset: assetInfo,
|
||||||
|
Generation: generation,
|
||||||
|
WalletBalance: walletBalance,
|
||||||
|
SkipPermissionCtx: skipPermissionCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetInfo.ShopID != nil && *assetInfo.ShopID > 0 {
|
||||||
|
ctxInfo.IsAgentChannel = true
|
||||||
|
ctxInfo.SellerShopID = *assetInfo.ShopID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctxInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssetInfo B1 资产信息
|
||||||
|
// GET /api/c/v1/asset/info
|
||||||
|
func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetInfoRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.AssetInfoResponse{
|
||||||
|
AssetType: resolved.Asset.AssetType,
|
||||||
|
AssetID: resolved.Asset.AssetID,
|
||||||
|
Identifier: resolved.Identifier,
|
||||||
|
VirtualNo: resolved.Asset.VirtualNo,
|
||||||
|
Status: resolved.Asset.Status,
|
||||||
|
RealNameStatus: resolved.Asset.RealNameStatus,
|
||||||
|
CarrierName: resolved.Asset.CarrierName,
|
||||||
|
Generation: strconv.Itoa(resolved.Generation),
|
||||||
|
WalletBalance: resolved.WalletBalance,
|
||||||
|
ActivatedAt: resolved.Asset.ActivatedAt,
|
||||||
|
CurrentPackage: resolved.Asset.CurrentPackage,
|
||||||
|
PackageTotalMB: resolved.Asset.PackageTotalMB,
|
||||||
|
PackageUsedMB: resolved.Asset.PackageUsedMB,
|
||||||
|
PackageRemainMB: resolved.Asset.PackageRemainMB,
|
||||||
|
DeviceName: resolved.Asset.DeviceName,
|
||||||
|
IMEI: resolved.Asset.IMEI,
|
||||||
|
SN: resolved.Asset.SN,
|
||||||
|
DeviceModel: resolved.Asset.DeviceModel,
|
||||||
|
DeviceType: resolved.Asset.DeviceType,
|
||||||
|
Manufacturer: resolved.Asset.Manufacturer,
|
||||||
|
MaxSimSlots: resolved.Asset.MaxSimSlots,
|
||||||
|
BoundCardCount: resolved.Asset.BoundCardCount,
|
||||||
|
Cards: resolved.Asset.Cards,
|
||||||
|
DeviceProtectStatus: resolved.Asset.DeviceProtectStatus,
|
||||||
|
ICCID: resolved.Asset.ICCID,
|
||||||
|
MSISDN: resolved.Asset.MSISDN,
|
||||||
|
CarrierID: resolved.Asset.CarrierID,
|
||||||
|
CarrierType: resolved.Asset.CarrierType,
|
||||||
|
NetworkStatus: resolved.Asset.NetworkStatus,
|
||||||
|
ActivationStatus: resolved.Asset.ActivationStatus,
|
||||||
|
CardCategory: resolved.Asset.CardCategory,
|
||||||
|
BoundDeviceID: resolved.Asset.BoundDeviceID,
|
||||||
|
BoundDeviceNo: resolved.Asset.BoundDeviceNo,
|
||||||
|
BoundDeviceName: resolved.Asset.BoundDeviceName,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailablePackages B2 资产可购套餐列表
|
||||||
|
// GET /api/c/v1/asset/packages
|
||||||
|
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetPackageListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.Asset.SeriesID == nil || *resolved.Asset.SeriesID == 0 {
|
||||||
|
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved.MainPackageActived = hasActiveMainPackage(allUsages)
|
||||||
|
|
||||||
|
listCtx := resolved.SkipPermissionCtx
|
||||||
|
if resolved.IsAgentChannel {
|
||||||
|
listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent)
|
||||||
|
listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: constants.MaxPageSize,
|
||||||
|
OrderBy: "id DESC",
|
||||||
|
}, map[string]any{
|
||||||
|
"series_id": *resolved.Asset.SeriesID,
|
||||||
|
"status": constants.StatusEnabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
allocationMap := make(map[uint]*model.ShopPackageAllocation)
|
||||||
|
if resolved.IsAgentChannel {
|
||||||
|
packageIDs := collectPackageIDs(pkgs)
|
||||||
|
allocations, allocErr := h.shopPackageAllocationStore.GetByShopAndPackages(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
resolved.SellerShopID,
|
||||||
|
packageIDs,
|
||||||
|
)
|
||||||
|
if allocErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, allocErr, "查询套餐分配记录失败")
|
||||||
|
}
|
||||||
|
for _, allocation := range allocations {
|
||||||
|
allocationMap[allocation.PackageID] = allocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.ClientPackageItem, 0, len(pkgs))
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
item, ok := buildClientPackageItem(pkg, resolved, allocationMap)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].RetailPrice < items[j].RetailPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageHistory B3 资产套餐历史
|
||||||
|
// GET /api/c/v1/asset/package-history
|
||||||
|
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetPackageHistoryRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}).
|
||||||
|
Where("generation = ?", resolved.Generation)
|
||||||
|
if resolved.Asset.AssetType == "card" {
|
||||||
|
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
|
||||||
|
} else {
|
||||||
|
query = query.Where("device_id = ?", resolved.Asset.AssetID)
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
if req.PackageType != nil {
|
||||||
|
query = query.Where("package_id IN (?)",
|
||||||
|
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var usages []*model.PackageUsage
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.AssetPackageResponse, 0, len(usages))
|
||||||
|
for _, usage := range usages {
|
||||||
|
pkg := packageMap[usage.PackageID]
|
||||||
|
ratio := 1.0
|
||||||
|
pkgName := ""
|
||||||
|
pkgType := ""
|
||||||
|
if pkg != nil {
|
||||||
|
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||||
|
pkgName = pkg.PackageName
|
||||||
|
pkgType = pkg.PackageType
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, dto.AssetPackageResponse{
|
||||||
|
PackageUsageID: usage.ID,
|
||||||
|
PackageID: usage.PackageID,
|
||||||
|
PackageName: pkgName,
|
||||||
|
PackageType: pkgType,
|
||||||
|
UsageType: usage.UsageType,
|
||||||
|
Status: usage.Status,
|
||||||
|
StatusName: packageStatusName(usage.Status),
|
||||||
|
DataLimitMB: usage.DataLimitMB,
|
||||||
|
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
|
||||||
|
DataUsageMB: usage.DataUsageMB,
|
||||||
|
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
|
||||||
|
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
|
||||||
|
VirtualRatio: ratio,
|
||||||
|
ActivatedAt: nonZeroTimePtr(usage.ActivatedAt),
|
||||||
|
ExpiresAt: nonZeroTimePtr(usage.ExpiresAt),
|
||||||
|
MasterUsageID: usage.MasterUsageID,
|
||||||
|
Priority: usage.Priority,
|
||||||
|
CreatedAt: usage.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAsset B4 资产刷新
|
||||||
|
// POST /api/c/v1/asset/refresh
|
||||||
|
func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetRefreshRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.assetService.Refresh(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
resolved.Asset.AssetType,
|
||||||
|
resolved.Asset.AssetID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.AssetRefreshResponse{
|
||||||
|
RefreshType: resolved.Asset.AssetType,
|
||||||
|
Accepted: true,
|
||||||
|
CooldownSeconds: 0,
|
||||||
|
}
|
||||||
|
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
|
||||||
|
resp.CooldownSeconds = int(constants.DeviceRefreshCooldownDuration / time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
|
||||||
|
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.Generation, nil
|
||||||
|
case "device":
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.Generation, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) getAssetWalletBalance(ctx context.Context, assetType string, assetID uint) (int64, error) {
|
||||||
|
resourceType := constants.AssetWalletResourceTypeIotCard
|
||||||
|
if assetType == constants.ResourceTypeDevice {
|
||||||
|
resourceType = constants.AssetWalletResourceTypeDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return wallet.Balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) loadPackageMap(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
|
||||||
|
ids := make([]uint, 0, len(usages))
|
||||||
|
seen := make(map[uint]struct{}, len(usages))
|
||||||
|
for _, usage := range usages {
|
||||||
|
if usage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[usage.PackageID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[usage.PackageID] = struct{}{}
|
||||||
|
ids = append(ids, usage.PackageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, err := h.packageStore.GetByIDsUnscoped(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint]*model.Package, len(packages))
|
||||||
|
for _, pkg := range packages {
|
||||||
|
result[pkg.ID] = pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPackageIDs(pkgs []*model.Package) []uint {
|
||||||
|
ids := make([]uint, 0, len(pkgs))
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
if pkg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, pkg.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasActiveMainPackage(usages []*dto.AssetPackageResponse) bool {
|
||||||
|
for _, usage := range usages {
|
||||||
|
if usage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usage.PackageType == constants.PackageTypeFormal && usage.Status == constants.PackageUsageStatusActive {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientPackageItem(
|
||||||
|
pkg *model.Package,
|
||||||
|
resolved *resolvedAssetContext,
|
||||||
|
allocationMap map[uint]*model.ShopPackageAllocation,
|
||||||
|
) (dto.ClientPackageItem, bool) {
|
||||||
|
if pkg == nil || pkg.Status != constants.StatusEnabled {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
isAddon := pkg.PackageType == constants.PackageTypeAddon
|
||||||
|
if isAddon && !resolved.MainPackageActived {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
retailPrice := pkg.SuggestedRetailPrice
|
||||||
|
costPrice := pkg.CostPrice
|
||||||
|
|
||||||
|
if resolved.IsAgentChannel {
|
||||||
|
allocation, ok := allocationMap[pkg.ID]
|
||||||
|
if !ok || allocation == nil {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
retailPrice = allocation.RetailPrice
|
||||||
|
costPrice = allocation.CostPrice
|
||||||
|
} else if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if retailPrice < costPrice {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
validityDays := pkg.DurationDays
|
||||||
|
if validityDays <= 0 && pkg.DurationMonths > 0 {
|
||||||
|
validityDays = pkg.DurationMonths * 30
|
||||||
|
}
|
||||||
|
|
||||||
|
dataAllowance := pkg.VirtualDataMB
|
||||||
|
if dataAllowance <= 0 {
|
||||||
|
dataAllowance = pkg.RealDataMB
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.ClientPackageItem{
|
||||||
|
PackageID: pkg.ID,
|
||||||
|
PackageName: pkg.PackageName,
|
||||||
|
PackageType: pkg.PackageType,
|
||||||
|
RetailPrice: retailPrice,
|
||||||
|
CostPrice: costPrice,
|
||||||
|
ValidityDays: validityDays,
|
||||||
|
IsAddon: isAddon,
|
||||||
|
DataAllowance: dataAllowance,
|
||||||
|
DataUnit: "MB",
|
||||||
|
Description: pkg.PackageCode,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonZeroTimePtr(t time.Time) *time.Time {
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeVirtualRatio(ratio float64) float64 {
|
||||||
|
if ratio <= 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageStatusName(status int) string {
|
||||||
|
switch status {
|
||||||
|
case constants.PackageUsageStatusPending:
|
||||||
|
return "待生效"
|
||||||
|
case constants.PackageUsageStatusActive:
|
||||||
|
return "生效中"
|
||||||
|
case constants.PackageUsageStatusDepleted:
|
||||||
|
return "已用完"
|
||||||
|
case constants.PackageUsageStatusExpired:
|
||||||
|
return "已过期"
|
||||||
|
case constants.PackageUsageStatusInvalidated:
|
||||||
|
return "已失效"
|
||||||
|
default:
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/handler/app/client_auth.go
Normal file
165
internal/handler/app/client_auth.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientAuthValidator = validator.New()
|
||||||
|
|
||||||
|
// ClientAuthHandler C 端认证处理器
|
||||||
|
type ClientAuthHandler struct {
|
||||||
|
service *clientAuthSvc.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientAuthHandler 创建 C 端认证处理器
|
||||||
|
func NewClientAuthHandler(service *clientAuthSvc.Service, logger *zap.Logger) *ClientAuthHandler {
|
||||||
|
return &ClientAuthHandler{service: service, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAsset A1 资产验证
|
||||||
|
// POST /api/c/v1/auth/verify-asset
|
||||||
|
func (h *ClientAuthHandler) VerifyAsset(c *fiber.Ctx) error {
|
||||||
|
var req dto.VerifyAssetRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("资产验证参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.VerifyAsset(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatLogin A2 公众号登录
|
||||||
|
// POST /api/c/v1/auth/wechat-login
|
||||||
|
func (h *ClientAuthHandler) WechatLogin(c *fiber.Ctx) error {
|
||||||
|
var req dto.WechatLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("公众号登录参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.WechatLogin(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniappLogin A3 小程序登录
|
||||||
|
// POST /api/c/v1/auth/miniapp-login
|
||||||
|
func (h *ClientAuthHandler) MiniappLogin(c *fiber.Ctx) error {
|
||||||
|
var req dto.MiniappLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("小程序登录参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.MiniappLogin(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCode A4 发送验证码
|
||||||
|
// POST /api/c/v1/auth/send-code
|
||||||
|
func (h *ClientAuthHandler) SendCode(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientSendCodeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("发送验证码参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.SendCode(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindPhone A5 绑定手机号
|
||||||
|
// POST /api/c/v1/auth/bind-phone
|
||||||
|
func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.BindPhoneRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("绑定手机号参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.BindPhone(c.UserContext(), customerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePhone A6 更换手机号
|
||||||
|
// POST /api/c/v1/auth/change-phone
|
||||||
|
func (h *ClientAuthHandler) ChangePhone(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ChangePhoneRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("更换手机号参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.ChangePhone(c.UserContext(), customerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout A7 退出登录
|
||||||
|
// POST /api/c/v1/auth/logout
|
||||||
|
func (h *ClientAuthHandler) Logout(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.Logout(c.UserContext(), customerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
317
internal/handler/app/client_device.go
Normal file
317
internal/handler/app/client_device.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientDeviceValidator = validator.New()
|
||||||
|
|
||||||
|
// deviceAssetInfo validateDeviceAsset 解析后的设备资产信息
|
||||||
|
type deviceAssetInfo struct {
|
||||||
|
DeviceID uint // 设备数据库 ID
|
||||||
|
IMEI string // 设备 IMEI(用于 Gateway API 调用)
|
||||||
|
VirtualNo string // 设备虚拟号(用于所有权校验)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientDeviceHandler C 端设备能力处理器
|
||||||
|
// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作
|
||||||
|
type ClientDeviceHandler struct {
|
||||||
|
assetService *assetSvc.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
gatewayClient *gateway.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientDeviceHandler 创建 C 端设备能力处理器
|
||||||
|
func NewClientDeviceHandler(
|
||||||
|
assetService *assetSvc.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
gatewayClient *gateway.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ClientDeviceHandler {
|
||||||
|
return &ClientDeviceHandler{
|
||||||
|
assetService: assetService,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
deviceSimBindingStore: deviceSimBindingStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
gatewayClient: gatewayClient,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDeviceAsset 校验设备资产的所有权和有效性
|
||||||
|
// 流程:认证 → 资产解析 → 类型校验(仅设备)→ 所有权校验 → IMEI 校验
|
||||||
|
func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier string) (*deviceAssetInfo, error) {
|
||||||
|
// 获取当前登录的个人客户 ID
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 通过标识符解析资产
|
||||||
|
asset, err := h.assetService.Resolve(ctx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅设备资产支持设备能力操作
|
||||||
|
if asset.AssetType != "device" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "仅设备资产支持该操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验个人客户对该设备的所有权
|
||||||
|
owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("校验设备所有权失败",
|
||||||
|
zap.Uint("customer_id", customerID),
|
||||||
|
zap.String("virtual_no", asset.VirtualNo),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errors.New(errors.CodeInternalError)
|
||||||
|
}
|
||||||
|
if !owns {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验设备 IMEI 是否存在(Gateway API 调用必需)
|
||||||
|
if asset.IMEI == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "设备IMEI缺失")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &deviceAssetInfo{
|
||||||
|
DeviceID: asset.AssetID,
|
||||||
|
IMEI: asset.IMEI,
|
||||||
|
VirtualNo: asset.VirtualNo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceCards F1 获取设备卡列表
|
||||||
|
// GET /api/c/v1/device/cards
|
||||||
|
func (h *ClientDeviceHandler) GetDeviceCards(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceCardListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("设备卡列表参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 查询设备绑定的所有 SIM 卡
|
||||||
|
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, info.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("查询设备SIM绑定失败",
|
||||||
|
zap.Uint("device_id", info.DeviceID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无绑定卡时返回空列表
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return response.Success(c, &dto.DeviceCardListResponse{Cards: []dto.DeviceCardItem{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集卡 ID 并记录插槽位置映射
|
||||||
|
cardIDs := make([]uint, 0, len(bindings))
|
||||||
|
slotMap := make(map[uint]int, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
cardIDs = append(cardIDs, b.IotCardID)
|
||||||
|
slotMap[b.IotCardID] = b.SlotPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询卡详情
|
||||||
|
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("批量查询IoT卡失败",
|
||||||
|
zap.Uints("card_ids", cardIDs),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装响应,slot_position == 1 视为当前激活卡
|
||||||
|
items := make([]dto.DeviceCardItem, 0, len(cards))
|
||||||
|
for _, card := range cards {
|
||||||
|
slot := slotMap[card.ID]
|
||||||
|
items = append(items, dto.DeviceCardItem{
|
||||||
|
CardID: card.ID,
|
||||||
|
ICCID: card.ICCID,
|
||||||
|
MSISDN: card.MSISDN,
|
||||||
|
CarrierName: card.CarrierName,
|
||||||
|
NetworkStatus: networkStatusText(card.NetworkStatus),
|
||||||
|
RealNameStatus: card.RealNameStatus,
|
||||||
|
SlotPosition: slot,
|
||||||
|
IsActive: slot == 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceCardListResponse{Cards: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebootDevice F2 设备重启
|
||||||
|
// POST /api/c/v1/device/reboot
|
||||||
|
func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceRebootRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("设备重启参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 重启设备
|
||||||
|
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
|
||||||
|
DeviceID: info.IMEI,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway重启设备失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "设备重启失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactoryResetDevice F3 恢复出厂设置
|
||||||
|
// POST /api/c/v1/device/factory-reset
|
||||||
|
func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceFactoryResetRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("恢复出厂设置参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 恢复出厂设置
|
||||||
|
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
|
||||||
|
DeviceID: info.IMEI,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway恢复出厂设置失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWiFi F4 设备WiFi配置
|
||||||
|
// POST /api/c/v1/device/wifi
|
||||||
|
// 注意:WiFiReq.CardNo 字段名具有误导性,实际传入的是设备 IMEI,而非卡号
|
||||||
|
func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceWifiRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("WiFi配置参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 配置 WiFi
|
||||||
|
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI
|
||||||
|
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{
|
||||||
|
CardNo: info.IMEI,
|
||||||
|
DeviceID: info.IMEI,
|
||||||
|
SSID: req.SSID,
|
||||||
|
Password: req.Password,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway配置WiFi失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.String("ssid", req.SSID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "WiFi配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchCard F5 设备切卡
|
||||||
|
// POST /api/c/v1/device/switch-card
|
||||||
|
func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceSwitchCardRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("设备切卡参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 切卡,CardNo 传设备 IMEI
|
||||||
|
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
|
||||||
|
CardNo: info.IMEI,
|
||||||
|
ICCID: req.TargetICCID,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway切卡失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.String("target_iccid", req.TargetICCID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "设备切卡失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceSwitchCardResponse{
|
||||||
|
Accepted: true,
|
||||||
|
TargetICCID: req.TargetICCID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkStatusText 将网络状态码转为文本描述
|
||||||
|
func networkStatusText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case 0:
|
||||||
|
return "停机"
|
||||||
|
case 1:
|
||||||
|
return "开机"
|
||||||
|
default:
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/handler/app/client_exchange.go
Normal file
57
internal/handler/app/client_exchange.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientExchangeHandler struct {
|
||||||
|
service *exchangeService.Service
|
||||||
|
validator *validator.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientExchangeHandler(service *exchangeService.Service) *ClientExchangeHandler {
|
||||||
|
return &ClientExchangeHandler{service: service, validator: validator.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientExchangeHandler) GetPending(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientExchangePendingRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.GetPending(c.UserContext(), req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientExchangeHandler) SubmitShippingInfo(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ClientShippingInfoRequest
|
||||||
|
if err = c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err = h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.SubmitShippingInfo(c.UserContext(), uint(id), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
415
internal/handler/app/client_order.go
Normal file
415
internal/handler/app/client_order.go
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
clientorder "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||||
|
"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/response"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientOrderHandler C 端订单处理器
|
||||||
|
// 提供 D1~D3 下单、列表、详情接口。
|
||||||
|
type ClientOrderHandler struct {
|
||||||
|
clientOrderService *clientorder.Service
|
||||||
|
assetService *asset.Service
|
||||||
|
orderStore *postgres.OrderStore
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
logger *zap.Logger
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientOrderHandler 创建 C 端订单处理器。
|
||||||
|
func NewClientOrderHandler(
|
||||||
|
clientOrderService *clientorder.Service,
|
||||||
|
assetService *asset.Service,
|
||||||
|
orderStore *postgres.OrderStore,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
logger *zap.Logger,
|
||||||
|
db *gorm.DB,
|
||||||
|
) *ClientOrderHandler {
|
||||||
|
return &ClientOrderHandler{
|
||||||
|
clientOrderService: clientOrderService,
|
||||||
|
assetService: assetService,
|
||||||
|
orderStore: orderStore,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder D1 创建订单。
|
||||||
|
// POST /api/c/v1/orders/create
|
||||||
|
func (h *ClientOrderHandler) CreateOrder(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientCreateOrderRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.clientOrderService.CreateOrder(c.UserContext(), customerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrders D2 订单列表。
|
||||||
|
// GET /api/c/v1/orders
|
||||||
|
func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientOrderListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.WithContext(resolved.SkipPermissionCtx).
|
||||||
|
Model(&model.Order{}).
|
||||||
|
Where("generation = ?", resolved.Generation)
|
||||||
|
|
||||||
|
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
|
||||||
|
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, resolved.Asset.AssetID)
|
||||||
|
} else {
|
||||||
|
query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, resolved.Asset.AssetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PaymentStatus != nil {
|
||||||
|
paymentStatus, ok := clientPaymentStatusToOrderStatus(*req.PaymentStatus)
|
||||||
|
if !ok {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
query = query.Where("payment_status = ?", paymentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var orders []*model.Order
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
orderIDs := make([]uint, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
if order == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orderIDs = append(orderIDs, order.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemMap, err := h.loadOrderItemMap(resolved.SkipPermissionCtx, orderIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.ClientOrderListItem, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
if order == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
packageNames := make([]string, 0, len(itemMap[order.ID]))
|
||||||
|
for _, item := range itemMap[order.ID] {
|
||||||
|
if item == nil || item.PackageName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
packageNames = append(packageNames, item.PackageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, dto.ClientOrderListItem{
|
||||||
|
OrderID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
TotalAmount: order.TotalAmount,
|
||||||
|
PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus),
|
||||||
|
CreatedAt: formatClientOrderTime(order.CreatedAt),
|
||||||
|
PackageNames: packageNames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderDetail D3 订单详情。
|
||||||
|
// GET /api/c/v1/orders/:id
|
||||||
|
func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || orderID == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, items, err := h.orderStore.GetByIDWithItems(c.UserContext(), uint(orderID))
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualNo, err := h.getOrderVirtualNo(c.UserContext(), order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(c.UserContext(), customerID, virtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make([]dto.ClientOrderPackageItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
packages = append(packages, dto.ClientOrderPackageItem{
|
||||||
|
PackageID: item.PackageID,
|
||||||
|
PackageName: item.PackageName,
|
||||||
|
Price: item.UnitPrice,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.ClientOrderDetailResponse{
|
||||||
|
OrderID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
TotalAmount: order.TotalAmount,
|
||||||
|
PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus),
|
||||||
|
PaymentMethod: order.PaymentMethod,
|
||||||
|
CreatedAt: formatClientOrderTime(order.CreatedAt),
|
||||||
|
PaidAt: formatClientOrderTimePtr(order.PaidAt),
|
||||||
|
CompletedAt: nil,
|
||||||
|
Packages: packages,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(c.Query("identifier"))
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, genErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resolvedAssetContext{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Identifier: identifier,
|
||||||
|
Asset: assetInfo,
|
||||||
|
Generation: generation,
|
||||||
|
SkipPermissionCtx: skipPermissionCtx,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
|
||||||
|
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.Generation, nil
|
||||||
|
case constants.ResourceTypeDevice:
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.Generation, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) loadOrderItemMap(ctx context.Context, orderIDs []uint) (map[uint][]*model.OrderItem, error) {
|
||||||
|
result := make(map[uint][]*model.OrderItem)
|
||||||
|
if len(orderIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []*model.OrderItem
|
||||||
|
if err := h.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Order("id ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[item.OrderID] = append(result[item.OrderID], item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) getOrderVirtualNo(ctx context.Context, order *model.Order) (string, error) {
|
||||||
|
if order == nil {
|
||||||
|
return "", errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch order.OrderType {
|
||||||
|
case model.OrderTypeSingleCard:
|
||||||
|
if order.IotCardID == nil || *order.IotCardID == 0 {
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, *order.IotCardID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return "", errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.VirtualNo, nil
|
||||||
|
case model.OrderTypeDevice:
|
||||||
|
if order.DeviceID == nil || *order.DeviceID == 0 {
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, *order.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return "", errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.VirtualNo, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderStatusToClientPaymentStatus(status int) int {
|
||||||
|
switch status {
|
||||||
|
case model.PaymentStatusPending:
|
||||||
|
return 0
|
||||||
|
case model.PaymentStatusPaid:
|
||||||
|
return 1
|
||||||
|
case model.PaymentStatusCancelled:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientPaymentStatusToOrderStatus(status int) (int, bool) {
|
||||||
|
switch status {
|
||||||
|
case 0:
|
||||||
|
return model.PaymentStatusPending, true
|
||||||
|
case 1:
|
||||||
|
return model.PaymentStatusPaid, true
|
||||||
|
case 2:
|
||||||
|
return model.PaymentStatusCancelled, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClientOrderTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClientOrderTimePtr(t *time.Time) *string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
formatted := formatClientOrderTime(*t)
|
||||||
|
return &formatted
|
||||||
|
}
|
||||||
249
internal/handler/app/client_realname.go
Normal file
249
internal/handler/app/client_realname.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"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/logger"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientRealnameValidator = validator.New()
|
||||||
|
|
||||||
|
// ClientRealnameHandler C 端实名认证处理器
|
||||||
|
type ClientRealnameHandler struct {
|
||||||
|
assetService *assetService.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||||
|
carrierStore *postgres.CarrierStore
|
||||||
|
gatewayClient *gateway.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientRealnameHandler 创建 C 端实名认证处理器
|
||||||
|
func NewClientRealnameHandler(
|
||||||
|
assetSvc *assetService.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||||
|
carrierStore *postgres.CarrierStore,
|
||||||
|
gatewayClient *gateway.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ClientRealnameHandler {
|
||||||
|
return &ClientRealnameHandler{
|
||||||
|
assetService: assetSvc,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceSimBindingStore: deviceSimBindingStore,
|
||||||
|
carrierStore: carrierStore,
|
||||||
|
gatewayClient: gatewayClient,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRealnameLink E1 获取实名认证链接
|
||||||
|
// GET /api/c/v1/realname/link
|
||||||
|
func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||||
|
// 1. 获取当前登录客户
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解析请求参数
|
||||||
|
var req dto.RealnimeLinkRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientRealnameValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("实名链接参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 3. 通过标识符解析资产
|
||||||
|
asset, err := h.assetService.Resolve(ctx, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证资产归属(个人客户必须绑定过该资产)
|
||||||
|
owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询资产归属失败",
|
||||||
|
zap.Uint("customer_id", customerID),
|
||||||
|
zap.String("virtual_no", asset.VirtualNo),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInternalError, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 定位目标卡(3 条路径)
|
||||||
|
var targetCard *model.IotCard
|
||||||
|
switch {
|
||||||
|
case asset.AssetType == "card":
|
||||||
|
// 路径 1:资产本身就是卡,直接使用
|
||||||
|
card, cardErr := h.iotCardStore.GetByID(ctx, asset.AssetID)
|
||||||
|
if cardErr != nil {
|
||||||
|
return errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
|
||||||
|
}
|
||||||
|
targetCard = card
|
||||||
|
|
||||||
|
case asset.AssetType == "device" && req.ICCID != "":
|
||||||
|
// 路径 2:资产是设备,指定了 ICCID,从设备绑定中查找该卡
|
||||||
|
card, cardErr := h.findCardInDeviceBindings(c, asset.AssetID, req.ICCID)
|
||||||
|
if cardErr != nil {
|
||||||
|
return cardErr
|
||||||
|
}
|
||||||
|
targetCard = card
|
||||||
|
|
||||||
|
case asset.AssetType == "device":
|
||||||
|
// 路径 3:资产是设备,未指定 ICCID,取第一张绑定卡(按插槽位置排序)
|
||||||
|
card, cardErr := h.findFirstBoundCard(c, asset.AssetID)
|
||||||
|
if cardErr != nil {
|
||||||
|
return cardErr
|
||||||
|
}
|
||||||
|
targetCard = card
|
||||||
|
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 检查实名状态
|
||||||
|
if targetCard.RealNameStatus == 1 {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "该卡已完成实名")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 获取运营商信息,根据实名链接类型生成 URL
|
||||||
|
carrier, err := h.carrierStore.GetByID(ctx, targetCard.CarrierID)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询运营商失败",
|
||||||
|
zap.Uint("carrier_id", targetCard.CarrierID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeCarrierNotFound, "运营商信息查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.RealnimeLinkResponse{
|
||||||
|
CardInfo: dto.CardInfoBrief{
|
||||||
|
ICCID: targetCard.ICCID,
|
||||||
|
MSISDN: targetCard.MSISDN,
|
||||||
|
VirtualNo: targetCard.VirtualNo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
switch carrier.RealnameLinkType {
|
||||||
|
case constants.RealnameLinkTypeNone:
|
||||||
|
// 该运营商不支持在线实名
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
|
||||||
|
|
||||||
|
case constants.RealnameLinkTypeTemplate:
|
||||||
|
// 模板模式:替换占位符生成实名链接
|
||||||
|
url := carrier.RealnameLinkTemplate
|
||||||
|
url = strings.ReplaceAll(url, "{iccid}", targetCard.ICCID)
|
||||||
|
url = strings.ReplaceAll(url, "{msisdn}", targetCard.MSISDN)
|
||||||
|
url = strings.ReplaceAll(url, "{virtual_no}", targetCard.VirtualNo)
|
||||||
|
resp.RealnameMode = constants.RealnameLinkTypeTemplate
|
||||||
|
resp.RealnameURL = url
|
||||||
|
|
||||||
|
case constants.RealnameLinkTypeGateway:
|
||||||
|
// 网关模式:调用 Gateway 接口获取实名链接
|
||||||
|
linkResp, gwErr := h.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
|
||||||
|
CardNo: targetCard.ICCID,
|
||||||
|
})
|
||||||
|
if gwErr != nil {
|
||||||
|
logger.GetAppLogger().Error("Gateway 获取实名链接失败",
|
||||||
|
zap.String("iccid", targetCard.ICCID),
|
||||||
|
zap.Error(gwErr))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, gwErr, "获取实名链接失败")
|
||||||
|
}
|
||||||
|
resp.RealnameMode = constants.RealnameLinkTypeGateway
|
||||||
|
resp.RealnameURL = linkResp.URL
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.GetAppLogger().Warn("未知的实名链接类型",
|
||||||
|
zap.Uint("carrier_id", carrier.ID),
|
||||||
|
zap.String("realname_link_type", carrier.RealnameLinkType))
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCardInDeviceBindings 在设备绑定中查找指定 ICCID 的卡
|
||||||
|
func (h *ClientRealnameHandler) findCardInDeviceBindings(c *fiber.Ctx, deviceID uint, iccid string) (*model.IotCard, error) {
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 查询设备的所有有效绑定
|
||||||
|
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询设备绑定失败",
|
||||||
|
zap.Uint("device_id", deviceID),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有绑定卡的 ID
|
||||||
|
cardIDs := make([]uint, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
cardIDs = append(cardIDs, b.IotCardID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cardIDs) == 0 {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询卡,匹配指定的 ICCID
|
||||||
|
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, card := range cards {
|
||||||
|
if card.ICCID == iccid {
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定指定的 ICCID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFirstBoundCard 获取设备第一张绑定卡(按插槽位置排序,取第一张)
|
||||||
|
func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint) (*model.IotCard, error) {
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// ListByDeviceID 返回 bind_status=1 的绑定,按 slot_position ASC 排序
|
||||||
|
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询设备绑定失败",
|
||||||
|
zap.Uint("device_id", deviceID),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取第一张绑定卡(插槽位置最小的)
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, bindings[0].IotCardID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
660
internal/handler/app/client_wallet.go
Normal file
660
internal/handler/app/client_wallet.go
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||||
|
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
|
"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/response"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientWalletHandler C 端钱包处理器
|
||||||
|
// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口
|
||||||
|
type ClientWalletHandler struct {
|
||||||
|
assetService *asset.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
walletStore *postgres.AssetWalletStore
|
||||||
|
transactionStore *postgres.AssetWalletTransactionStore
|
||||||
|
rechargeStore *postgres.AssetRechargeStore
|
||||||
|
rechargeService *rechargeSvc.Service
|
||||||
|
openIDStore *postgres.PersonalCustomerOpenIDStore
|
||||||
|
wechatConfigService *wechatConfigSvc.Service
|
||||||
|
redis *redis.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
db *gorm.DB
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWalletHandler 创建 C 端钱包处理器
|
||||||
|
func NewClientWalletHandler(
|
||||||
|
assetService *asset.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
walletStore *postgres.AssetWalletStore,
|
||||||
|
transactionStore *postgres.AssetWalletTransactionStore,
|
||||||
|
rechargeStore *postgres.AssetRechargeStore,
|
||||||
|
rechargeService *rechargeSvc.Service,
|
||||||
|
openIDStore *postgres.PersonalCustomerOpenIDStore,
|
||||||
|
wechatConfigService *wechatConfigSvc.Service,
|
||||||
|
redisClient *redis.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
db *gorm.DB,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
) *ClientWalletHandler {
|
||||||
|
return &ClientWalletHandler{
|
||||||
|
assetService: assetService,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
walletStore: walletStore,
|
||||||
|
transactionStore: transactionStore,
|
||||||
|
rechargeStore: rechargeStore,
|
||||||
|
rechargeService: rechargeService,
|
||||||
|
openIDStore: openIDStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
|
redis: redisClient,
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedWalletAssetContext struct {
|
||||||
|
CustomerID uint
|
||||||
|
Identifier string
|
||||||
|
Asset *dto.AssetResolveResponse
|
||||||
|
Generation int
|
||||||
|
ResourceType string
|
||||||
|
SkipPermissionCtx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWalletDetail C1 钱包详情
|
||||||
|
// GET /api/c/v1/wallet/detail
|
||||||
|
func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error {
|
||||||
|
var req dto.WalletDetailRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.getOrCreateWallet(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.WalletDetailResponse{
|
||||||
|
WalletID: wallet.ID,
|
||||||
|
ResourceType: wallet.ResourceType,
|
||||||
|
ResourceID: wallet.ResourceID,
|
||||||
|
Balance: wallet.Balance,
|
||||||
|
FrozenBalance: wallet.FrozenBalance,
|
||||||
|
UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWalletTransactions C2 钱包流水列表
|
||||||
|
// GET /api/c/v1/wallet/transactions
|
||||||
|
func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error {
|
||||||
|
var req dto.WalletTransactionListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var txType *string
|
||||||
|
if strings.TrimSpace(req.TransactionType) != "" {
|
||||||
|
v := strings.TrimSpace(req.TransactionType)
|
||||||
|
txType = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime, err := parseOptionalTime(req.StartTime)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
endTime, err := parseOptionalTime(req.EndTime)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if startTime != nil && endTime != nil && endTime.Before(*startTime) {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
list, err := h.transactionStore.ListByResourceIDWithFilter(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
wallet.ResourceType,
|
||||||
|
wallet.ResourceID,
|
||||||
|
txType,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
offset,
|
||||||
|
req.PageSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := h.transactionStore.CountByResourceIDWithFilter(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
wallet.ResourceType,
|
||||||
|
wallet.ResourceID,
|
||||||
|
txType,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.WalletTransactionItem, 0, len(list))
|
||||||
|
for _, tx := range list {
|
||||||
|
if tx == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remark := ""
|
||||||
|
if tx.Remark != nil {
|
||||||
|
remark = *tx.Remark
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, dto.WalletTransactionItem{
|
||||||
|
TransactionID: tx.ID,
|
||||||
|
Type: tx.TransactionType,
|
||||||
|
Amount: tx.Amount,
|
||||||
|
BalanceAfter: tx.BalanceAfter,
|
||||||
|
CreatedAt: tx.CreatedAt.Format(time.RFC3339),
|
||||||
|
Remark: remark,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRechargeCheck C3 充值前校验
|
||||||
|
// GET /api/c/v1/wallet/recharge-check
|
||||||
|
func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientRechargeCheckRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.ClientRechargeCheckResponse{
|
||||||
|
NeedForceRecharge: check.NeedForceRecharge,
|
||||||
|
ForceRechargeAmount: check.ForceRechargeAmount,
|
||||||
|
TriggerType: check.TriggerType,
|
||||||
|
MinAmount: check.MinAmount,
|
||||||
|
MaxAmount: check.MaxAmount,
|
||||||
|
Message: check.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRecharge C4 创建充值订单
|
||||||
|
// POST /api/c/v1/wallet/recharge
|
||||||
|
func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientCreateRechargeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PaymentMethod != constants.RechargeMethodWechat {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.getOrCreateWallet(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
return errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
appID, err := pickAppIDByType(config, req.AppType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rechargeNo := generateClientRechargeNo()
|
||||||
|
recharge := &model.AssetRechargeRecord{
|
||||||
|
UserID: resolved.CustomerID,
|
||||||
|
AssetWalletID: wallet.ID,
|
||||||
|
ResourceType: resolved.ResourceType,
|
||||||
|
ResourceID: resolved.Asset.AssetID,
|
||||||
|
RechargeNo: rechargeNo,
|
||||||
|
Amount: req.Amount,
|
||||||
|
PaymentMethod: constants.RechargeMethodWechat,
|
||||||
|
PaymentConfigID: &config.ID,
|
||||||
|
Status: constants.RechargeStatusPending,
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
OperatorType: constants.OperatorTypePersonalCustomer,
|
||||||
|
Generation: resolved.Generation,
|
||||||
|
}
|
||||||
|
if err := h.rechargeStore.Create(resolved.SkipPermissionCtx, recharge); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := wechat.NewRedisCache(h.redis)
|
||||||
|
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败")
|
||||||
|
}
|
||||||
|
paymentService := wechat.NewPaymentService(paymentApp, h.logger)
|
||||||
|
payResult, err := paymentService.CreateJSAPIOrder(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
recharge.RechargeNo,
|
||||||
|
"资产钱包充值",
|
||||||
|
openID,
|
||||||
|
int(req.Amount),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payConfig := buildClientRechargePayConfig(appID, payResult)
|
||||||
|
resp := &dto.ClientRechargeResponse{
|
||||||
|
Recharge: dto.ClientRechargeResult{
|
||||||
|
RechargeID: recharge.ID,
|
||||||
|
RechargeNo: recharge.RechargeNo,
|
||||||
|
Amount: recharge.Amount,
|
||||||
|
Status: recharge.Status,
|
||||||
|
},
|
||||||
|
PayConfig: payConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRechargeList C5 充值记录列表
|
||||||
|
// GET /api/c/v1/wallet/recharges
|
||||||
|
func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientRechargeListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.WithContext(resolved.SkipPermissionCtx).
|
||||||
|
Model(&model.AssetRechargeRecord{}).
|
||||||
|
Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation)
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []*model.AssetRechargeRecord
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&records).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.ClientRechargeListItem, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, dto.ClientRechargeListItem{
|
||||||
|
RechargeID: record.ID,
|
||||||
|
RechargeNo: record.RechargeNo,
|
||||||
|
Amount: record.Amount,
|
||||||
|
Status: record.Status,
|
||||||
|
PaymentMethod: record.PaymentMethod,
|
||||||
|
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
||||||
|
AutoPurchaseStatus: record.AutoPurchaseStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
||||||
|
func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, error) {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(c.Query("identifier"))
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType)
|
||||||
|
if mapErr != nil {
|
||||||
|
return nil, mapErr
|
||||||
|
}
|
||||||
|
|
||||||
|
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, genErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resolvedWalletAssetContext{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Identifier: identifier,
|
||||||
|
Asset: assetInfo,
|
||||||
|
Generation: generation,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
SkipPermissionCtx: skipPermissionCtx,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
|
||||||
|
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.Generation, nil
|
||||||
|
case "device":
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.Generation, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) {
|
||||||
|
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err == nil {
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopIDTag := uint(0)
|
||||||
|
if resolved.Asset.ShopID != nil {
|
||||||
|
shopIDTag = *resolved.Asset.ShopID
|
||||||
|
}
|
||||||
|
|
||||||
|
newWallet := &model.AssetWallet{
|
||||||
|
ResourceType: resolved.ResourceType,
|
||||||
|
ResourceID: resolved.Asset.AssetID,
|
||||||
|
Balance: 0,
|
||||||
|
FrozenBalance: 0,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: constants.AssetWalletStatusNormal,
|
||||||
|
Version: 0,
|
||||||
|
ShopIDTag: shopIDTag,
|
||||||
|
}
|
||||||
|
if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||||
|
}
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) {
|
||||||
|
list, err := h.openIDStore.ListByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" {
|
||||||
|
return item.OpenID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New(errors.CodeOpenIDNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapAssetTypeToWalletResource(assetType string) (string, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
return constants.AssetWalletResourceTypeIotCard, nil
|
||||||
|
case "device":
|
||||||
|
return constants.AssetWalletResourceTypeDevice, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalTime(value string) (*time.Time, error) {
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if v == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
t, err := time.Parse(layout, v)
|
||||||
|
if err == nil {
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
|
||||||
|
switch appType {
|
||||||
|
case "official_account":
|
||||||
|
if strings.TrimSpace(config.OaAppID) == "" {
|
||||||
|
return "", errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
return config.OaAppID, nil
|
||||||
|
case "miniapp":
|
||||||
|
if strings.TrimSpace(config.MiniappAppID) == "" {
|
||||||
|
return "", errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
return config.MiniappAppID, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateClientRechargeNo() string {
|
||||||
|
timestamp := time.Now().Format("20060102150405")
|
||||||
|
randomNum := rand.Intn(1000000)
|
||||||
|
return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig {
|
||||||
|
resp := dto.ClientRechargePayConfig{AppID: appID}
|
||||||
|
if result == nil || result.PayConfig == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg, ok := result.PayConfig.(map[string]any); ok {
|
||||||
|
resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp")
|
||||||
|
resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str")
|
||||||
|
resp.PackageVal = getStringFromAnyMap(cfg, "package")
|
||||||
|
resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type")
|
||||||
|
resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign")
|
||||||
|
if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" {
|
||||||
|
resp.AppID = appIDVal
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg, ok := result.PayConfig.(map[string]string); ok {
|
||||||
|
resp.Timestamp = cfg["timeStamp"]
|
||||||
|
if resp.Timestamp == "" {
|
||||||
|
resp.Timestamp = cfg["timestamp"]
|
||||||
|
}
|
||||||
|
resp.NonceStr = cfg["nonceStr"]
|
||||||
|
if resp.NonceStr == "" {
|
||||||
|
resp.NonceStr = cfg["nonce_str"]
|
||||||
|
}
|
||||||
|
resp.PackageVal = cfg["package"]
|
||||||
|
resp.SignType = cfg["signType"]
|
||||||
|
if resp.SignType == "" {
|
||||||
|
resp.SignType = cfg["sign_type"]
|
||||||
|
}
|
||||||
|
resp.PaySign = cfg["paySign"]
|
||||||
|
if resp.PaySign == "" {
|
||||||
|
resp.PaySign = cfg["pay_sign"]
|
||||||
|
}
|
||||||
|
if cfg["appId"] != "" {
|
||||||
|
resp.AppID = cfg["appId"]
|
||||||
|
} else if cfg["app_id"] != "" {
|
||||||
|
resp.AppID = cfg["app_id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringFromAnyMap(m map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
val, ok := m[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
case fmt.Stringer:
|
||||||
|
text := v.String()
|
||||||
|
if text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
text := fmt.Sprintf("%v", v)
|
||||||
|
if text != "" && text != "<nil>" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
@@ -25,45 +24,6 @@ func NewPersonalCustomerHandler(service *personal_customer.Service, logger *zap.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendCodeRequest 发送验证码请求
|
|
||||||
type SendCodeRequest struct {
|
|
||||||
Phone string `json:"phone" validate:"required,len=11"` // 手机号(11位)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendCode 发送验证码
|
|
||||||
// POST /api/c/v1/login/send-code
|
|
||||||
func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error {
|
|
||||||
var req SendCodeRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送验证码
|
|
||||||
if err := h.service.SendVerificationCode(c.Context(), req.Phone); err != nil {
|
|
||||||
h.logger.Error("发送验证码失败",
|
|
||||||
zap.String("phone", req.Phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "发送验证码失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, fiber.Map{
|
|
||||||
"message": "验证码已发送",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginRequest 登录请求
|
|
||||||
type LoginRequest struct {
|
|
||||||
Phone string `json:"phone" validate:"required,len=11"` // 手机号(11位)
|
|
||||||
Code string `json:"code" validate:"required,len=6"` // 验证码(6位)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginResponse 登录响应
|
|
||||||
type LoginResponse struct {
|
|
||||||
Token string `json:"token"` // 访问令牌
|
|
||||||
Customer *PersonalCustomerDTO `json:"customer"` // 客户信息
|
|
||||||
}
|
|
||||||
|
|
||||||
// PersonalCustomerDTO 个人客户 DTO
|
// PersonalCustomerDTO 个人客户 DTO
|
||||||
type PersonalCustomerDTO struct {
|
type PersonalCustomerDTO struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
@@ -74,87 +34,6 @@ type PersonalCustomerDTO struct {
|
|||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 登录(手机号 + 验证码)
|
|
||||||
// POST /api/c/v1/login
|
|
||||||
func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
|
|
||||||
var req LoginRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录
|
|
||||||
token, customer, err := h.service.LoginByPhone(c.Context(), req.Phone, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("登录失败",
|
|
||||||
zap.String("phone", req.Phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "登录失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构造响应
|
|
||||||
// 注意:Phone 字段已从 PersonalCustomer 模型移除,需要从 PersonalCustomerPhone 表查询
|
|
||||||
resp := &LoginResponse{
|
|
||||||
Token: token,
|
|
||||||
Customer: &PersonalCustomerDTO{
|
|
||||||
ID: customer.ID,
|
|
||||||
Phone: req.Phone, // 使用请求中的手机号(临时方案)
|
|
||||||
Nickname: customer.Nickname,
|
|
||||||
AvatarURL: customer.AvatarURL,
|
|
||||||
WxOpenID: customer.WxOpenID,
|
|
||||||
Status: customer.Status,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WechatOAuthLogin 微信 OAuth 登录
|
|
||||||
// POST /api/c/v1/wechat/auth
|
|
||||||
func (h *PersonalCustomerHandler) WechatOAuthLogin(c *fiber.Ctx) error {
|
|
||||||
var req dto.WechatOAuthRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.WechatOAuthLogin(c.Context(), req.Code)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("微信 OAuth 登录失败",
|
|
||||||
zap.String("code", req.Code),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWechat 绑定微信
|
|
||||||
// POST /api/c/v1/bind-wechat
|
|
||||||
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
|
|
||||||
var req dto.WechatOAuthRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID, ok := c.Locals("customer_id").(uint)
|
|
||||||
if !ok {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.BindWechatWithCode(c.Context(), customerID, req.Code); err != nil {
|
|
||||||
h.logger.Error("绑定微信失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, fiber.Map{
|
|
||||||
"message": "绑定成功",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfileRequest 更新个人资料请求
|
// UpdateProfileRequest 更新个人资料请求
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Nickname string `json:"nickname"` // 昵称
|
Nickname string `json:"nickname"` // 昵称
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
package h5
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/auth"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
|
||||||
"github.com/go-playground/validator/v10"
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthHandler H5认证处理器
|
|
||||||
type AuthHandler struct {
|
|
||||||
authService *auth.Service
|
|
||||||
validator *validator.Validate
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthHandler 创建H5认证处理器
|
|
||||||
func NewAuthHandler(authService *auth.Service, validator *validator.Validate) *AuthHandler {
|
|
||||||
return &AuthHandler{
|
|
||||||
authService: authService,
|
|
||||||
validator: validator,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login H5登录
|
|
||||||
func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
|
||||||
var req dto.LoginRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.validator.Struct(&req); err != nil {
|
|
||||||
logger.GetAppLogger().Warn("参数验证失败",
|
|
||||||
zap.String("path", c.Path()),
|
|
||||||
zap.String("method", c.Method()),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.New(errors.CodeInvalidParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientIP := c.IP()
|
|
||||||
ctx := c.UserContext()
|
|
||||||
|
|
||||||
resp, err := h.authService.Login(ctx, &req, clientIP)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout H5登出
|
|
||||||
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
|
|
||||||
auth := c.Get("Authorization")
|
|
||||||
accessToken := ""
|
|
||||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
|
||||||
accessToken = auth[7:]
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshToken := ""
|
|
||||||
var req dto.RefreshTokenRequest
|
|
||||||
if err := c.BodyParser(&req); err == nil {
|
|
||||||
refreshToken = req.RefreshToken
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
|
|
||||||
if err := h.authService.Logout(ctx, accessToken, refreshToken); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshToken 刷新访问令牌
|
|
||||||
func (h *AuthHandler) RefreshToken(c *fiber.Ctx) error {
|
|
||||||
var req dto.RefreshTokenRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.validator.Struct(&req); err != nil {
|
|
||||||
logger.GetAppLogger().Warn("参数验证失败",
|
|
||||||
zap.String("path", c.Path()),
|
|
||||||
zap.String("method", c.Method()),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.New(errors.CodeInvalidParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
|
|
||||||
newAccessToken, err := h.authService.RefreshToken(ctx, req.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &dto.RefreshTokenResponse{
|
|
||||||
AccessToken: newAccessToken,
|
|
||||||
ExpiresIn: 86400,
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMe 获取当前用户信息
|
|
||||||
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
|
||||||
userID := middleware.GetUserIDFromContext(c.UserContext())
|
|
||||||
if userID == 0 {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
|
|
||||||
userInfo, permissions, err := h.authService.GetCurrentUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"user": userInfo,
|
|
||||||
"permissions": permissions,
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword 修改密码
|
|
||||||
func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error {
|
|
||||||
userID := middleware.GetUserIDFromContext(c.UserContext())
|
|
||||||
if userID == 0 {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
var req dto.ChangePasswordRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.validator.Struct(&req); err != nil {
|
|
||||||
logger.GetAppLogger().Warn("参数验证失败",
|
|
||||||
zap.String("path", c.Path()),
|
|
||||||
zap.String("method", c.Method()),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.New(errors.CodeInvalidParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
|
|
||||||
if err := h.authService.ChangePassword(ctx, userID, req.OldPassword, req.NewPassword); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package h5
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
enterpriseDeviceService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EnterpriseDeviceHandler struct {
|
|
||||||
service *enterpriseDeviceService.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEnterpriseDeviceHandler(service *enterpriseDeviceService.Service) *EnterpriseDeviceHandler {
|
|
||||||
return &EnterpriseDeviceHandler{service: service}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *EnterpriseDeviceHandler) ListDevices(c *fiber.Ctx) error {
|
|
||||||
var req dto.H5EnterpriseDeviceListReq
|
|
||||||
if err := c.QueryParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceReq := &dto.EnterpriseDeviceListReq{
|
|
||||||
Page: req.Page,
|
|
||||||
PageSize: req.PageSize,
|
|
||||||
VirtualNo: req.VirtualNo,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.ListDevicesForEnterprise(c.UserContext(), serviceReq)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.SuccessWithPagination(c, result.List, result.Total, req.Page, req.PageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *EnterpriseDeviceHandler) GetDeviceDetail(c *fiber.Ctx) error {
|
|
||||||
deviceIDStr := c.Params("device_id")
|
|
||||||
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.GetDeviceDetail(c.UserContext(), uint(deviceID))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package h5
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
|
|
||||||
"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/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type OrderHandler struct {
|
|
||||||
service *orderService.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOrderHandler(service *orderService.Service) *OrderHandler {
|
|
||||||
return &OrderHandler{service: service}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *OrderHandler) Create(c *fiber.Ctx) error {
|
|
||||||
var req dto.CreateOrderRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.PaymentMethod != model.PaymentMethodWallet {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "H5端只支持钱包支付")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
|
||||||
|
|
||||||
var buyerType string
|
|
||||||
var buyerID uint
|
|
||||||
|
|
||||||
switch userType {
|
|
||||||
case constants.UserTypeAgent:
|
|
||||||
buyerType = model.BuyerTypeAgent
|
|
||||||
buyerID = middleware.GetShopIDFromContext(ctx)
|
|
||||||
case constants.UserTypeEnterprise:
|
|
||||||
return errors.New(errors.CodeForbidden, "企业账号不支持在线购买")
|
|
||||||
case constants.UserTypePersonalCustomer:
|
|
||||||
buyerType = model.BuyerTypePersonal
|
|
||||||
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
default:
|
|
||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := h.service.CreateH5Order(ctx, &req, buyerType, buyerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, order)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *OrderHandler) Get(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
order, err := h.service.Get(c.UserContext(), uint(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, order)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *OrderHandler) List(c *fiber.Ctx) error {
|
|
||||||
var req dto.OrderListRequest
|
|
||||||
if err := c.QueryParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
|
||||||
|
|
||||||
var buyerType string
|
|
||||||
var buyerID uint
|
|
||||||
|
|
||||||
switch userType {
|
|
||||||
case constants.UserTypeAgent:
|
|
||||||
buyerType = model.BuyerTypeAgent
|
|
||||||
buyerID = middleware.GetShopIDFromContext(ctx)
|
|
||||||
case constants.UserTypePersonalCustomer:
|
|
||||||
buyerType = model.BuyerTypePersonal
|
|
||||||
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
default:
|
|
||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, orders)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *OrderHandler) WalletPay(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
|
||||||
|
|
||||||
var buyerType string
|
|
||||||
var buyerID uint
|
|
||||||
|
|
||||||
switch userType {
|
|
||||||
case constants.UserTypeAgent:
|
|
||||||
buyerType = model.BuyerTypeAgent
|
|
||||||
buyerID = middleware.GetShopIDFromContext(ctx)
|
|
||||||
case constants.UserTypePersonalCustomer:
|
|
||||||
buyerType = model.BuyerTypePersonal
|
|
||||||
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
default:
|
|
||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.WalletPay(ctx, uint(id), buyerType, buyerID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WechatPayJSAPI 微信 JSAPI 支付
|
|
||||||
// POST /api/h5/orders/:id/wechat-pay/jsapi
|
|
||||||
func (h *OrderHandler) WechatPayJSAPI(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
var req dto.WechatPayJSAPIRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
|
||||||
|
|
||||||
var buyerType string
|
|
||||||
var buyerID uint
|
|
||||||
|
|
||||||
switch userType {
|
|
||||||
case constants.UserTypeAgent:
|
|
||||||
buyerType = model.BuyerTypeAgent
|
|
||||||
buyerID = middleware.GetShopIDFromContext(ctx)
|
|
||||||
case constants.UserTypePersonalCustomer:
|
|
||||||
buyerType = model.BuyerTypePersonal
|
|
||||||
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
default:
|
|
||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.WechatPayJSAPI(ctx, uint(id), req.OpenID, buyerType, buyerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WechatPayH5 微信 H5 支付
|
|
||||||
// POST /api/h5/orders/:id/wechat-pay/h5
|
|
||||||
func (h *OrderHandler) WechatPayH5(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
var req dto.WechatPayH5Request
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
|
||||||
|
|
||||||
var buyerType string
|
|
||||||
var buyerID uint
|
|
||||||
|
|
||||||
switch userType {
|
|
||||||
case constants.UserTypeAgent:
|
|
||||||
buyerType = model.BuyerTypeAgent
|
|
||||||
buyerID = middleware.GetShopIDFromContext(ctx)
|
|
||||||
case constants.UserTypePersonalCustomer:
|
|
||||||
buyerType = model.BuyerTypePersonal
|
|
||||||
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
default:
|
|
||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.WechatPayH5(ctx, uint(id), &req.SceneInfo, buyerType, buyerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package h5
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
||||||
packageService "github.com/break/junhong_cmp_fiber/internal/service/package"
|
|
||||||
"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/response"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PackageUsageHandler H5 端套餐使用情况 Handler
|
|
||||||
type PackageUsageHandler struct {
|
|
||||||
db *gorm.DB
|
|
||||||
customerViewService *packageService.CustomerViewService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPackageUsageHandler 创建 H5 端套餐使用情况 Handler
|
|
||||||
func NewPackageUsageHandler(db *gorm.DB, customerViewService *packageService.CustomerViewService) *PackageUsageHandler {
|
|
||||||
return &PackageUsageHandler{
|
|
||||||
db: db,
|
|
||||||
customerViewService: customerViewService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMyUsage 任务 15.2-15.5: 获取我的套餐使用情况
|
|
||||||
// GET /api/h5/packages/my-usage
|
|
||||||
func (h *PackageUsageHandler) GetMyUsage(c *fiber.Ctx) error {
|
|
||||||
ctx := c.UserContext()
|
|
||||||
|
|
||||||
// 任务 15.3: 从 JWT 上下文中提取用户信息
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
|
||||||
|
|
||||||
var carrierType string
|
|
||||||
var carrierID uint
|
|
||||||
|
|
||||||
// 根据用户类型获取载体信息
|
|
||||||
switch userType {
|
|
||||||
case constants.UserTypePersonalCustomer:
|
|
||||||
// 个人客户:查询其订单关联的 IoT 卡或设备
|
|
||||||
customerID := middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
if customerID == 0 {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "未找到客户信息")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询该客户的套餐使用记录,获取载体信息
|
|
||||||
var usage model.PackageUsage
|
|
||||||
err := h.db.WithContext(ctx).
|
|
||||||
Joins("JOIN tb_order ON tb_order.id = tb_package_usage.order_id").
|
|
||||||
Where("tb_order.buyer_type = ? AND tb_order.buyer_id = ?", model.BuyerTypePersonal, customerID).
|
|
||||||
Where("tb_package_usage.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
|
||||||
Order("tb_package_usage.activated_at DESC").
|
|
||||||
First(&usage).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return errors.New(errors.CodeNotFound, "未找到套餐使用记录")
|
|
||||||
}
|
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确定载体类型和 ID
|
|
||||||
if usage.IotCardID > 0 {
|
|
||||||
carrierType = "iot_card"
|
|
||||||
carrierID = usage.IotCardID
|
|
||||||
} else if usage.DeviceID > 0 {
|
|
||||||
carrierType = "device"
|
|
||||||
carrierID = usage.DeviceID
|
|
||||||
} else {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "套餐使用记录未关联卡或设备")
|
|
||||||
}
|
|
||||||
|
|
||||||
case constants.UserTypeAgent, constants.UserTypeEnterprise:
|
|
||||||
// 代理和企业用户暂不支持通过此接口查询
|
|
||||||
// 他们应该使用后台管理接口查询指定卡/设备的套餐情况
|
|
||||||
return errors.New(errors.CodeForbidden, "此接口仅供个人客户使用")
|
|
||||||
|
|
||||||
default:
|
|
||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务 15.4: 调用 CustomerViewService.GetMyUsage 获取流量数据
|
|
||||||
usageData, err := h.customerViewService.GetMyUsage(ctx, carrierType, carrierID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务 15.5: 返回 PackageUsageCustomerViewResponse 响应
|
|
||||||
return response.Success(c, usageData)
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package h5
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RechargeHandler 充值订单处理器
|
|
||||||
// 提供充值订单的创建、预检、查询等接口
|
|
||||||
type RechargeHandler struct {
|
|
||||||
service *rechargeService.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRechargeHandler 创建充值订单处理器实例
|
|
||||||
// 参数:
|
|
||||||
// - service: 充值服务
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// - *RechargeHandler: 充值订单处理器实例
|
|
||||||
func NewRechargeHandler(service *rechargeService.Service) *RechargeHandler {
|
|
||||||
return &RechargeHandler{service: service}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create 创建充值订单
|
|
||||||
// POST /api/h5/wallets/recharge
|
|
||||||
// 请求参数:
|
|
||||||
// - resource_type: 资源类型(iot_card/device)
|
|
||||||
// - resource_id: 资源ID(卡ID或设备ID)
|
|
||||||
// - amount: 充值金额(分)
|
|
||||||
// - payment_method: 支付方式(wechat/alipay)
|
|
||||||
//
|
|
||||||
// 响应:
|
|
||||||
// - 成功: 返回充值订单信息(订单ID、订单号、金额、状态等)
|
|
||||||
// - 失败: 返回错误信息
|
|
||||||
func (h *RechargeHandler) Create(c *fiber.Ctx) error {
|
|
||||||
var req dto.CreateRechargeRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
// 获取个人客户ID作为用户ID
|
|
||||||
userID := middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
if userID == 0 {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "用户未登录")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.Create(ctx, &req, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RechargeCheck 充值预检
|
|
||||||
// GET /api/h5/wallets/recharge-check
|
|
||||||
// 请求参数:
|
|
||||||
// - resource_type: 资源类型(iot_card/device)
|
|
||||||
// - resource_id: 资源ID(卡ID或设备ID)
|
|
||||||
//
|
|
||||||
// 响应:
|
|
||||||
// - 成功: 返回预检信息(是否需要强充、强充金额、最小/最大充值金额等)
|
|
||||||
// - 失败: 返回错误信息
|
|
||||||
func (h *RechargeHandler) RechargeCheck(c *fiber.Ctx) error {
|
|
||||||
var req dto.RechargeCheckRequest
|
|
||||||
if err := c.QueryParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证必填参数
|
|
||||||
if req.ResourceType == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "资源类型不能为空")
|
|
||||||
}
|
|
||||||
if req.ResourceID == 0 {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "资源ID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
result, err := h.service.GetRechargeCheck(ctx, req.ResourceType, req.ResourceID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为 DTO 响应
|
|
||||||
resp := &dto.RechargeCheckResponse{
|
|
||||||
NeedForceRecharge: result.NeedForceRecharge,
|
|
||||||
ForceRechargeAmount: result.ForceRechargeAmount,
|
|
||||||
TriggerType: result.TriggerType,
|
|
||||||
MinAmount: result.MinAmount,
|
|
||||||
MaxAmount: result.MaxAmount,
|
|
||||||
CurrentAccumulated: result.CurrentAccumulated,
|
|
||||||
Threshold: result.Threshold,
|
|
||||||
Message: result.Message,
|
|
||||||
FirstCommissionPaid: result.FirstCommissionPaid,
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List 查询充值订单列表
|
|
||||||
// GET /api/h5/wallets/recharges
|
|
||||||
// 请求参数:
|
|
||||||
// - page: 页码(从1开始,默认1)
|
|
||||||
// - page_size: 每页数量(默认20,最大100)
|
|
||||||
// - wallet_id: 钱包ID筛选(可选)
|
|
||||||
// - status: 状态筛选(可选,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
|
|
||||||
// - start_time: 开始时间筛选(可选)
|
|
||||||
// - end_time: 结束时间筛选(可选)
|
|
||||||
//
|
|
||||||
// 响应:
|
|
||||||
// - 成功: 返回充值订单列表(分页数据、总记录数、总页数)
|
|
||||||
// - 失败: 返回错误信息
|
|
||||||
func (h *RechargeHandler) List(c *fiber.Ctx) error {
|
|
||||||
var req dto.RechargeListRequest
|
|
||||||
if err := c.QueryParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
// 获取个人客户ID作为用户ID
|
|
||||||
userID := middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
if userID == 0 {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "用户未登录")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.List(ctx, &req, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 查询充值订单详情
|
|
||||||
// GET /api/h5/wallets/recharges/:id
|
|
||||||
// 路径参数:
|
|
||||||
// - id: 充值订单ID
|
|
||||||
//
|
|
||||||
// 响应:
|
|
||||||
// - 成功: 返回充值订单详情(订单ID、订单号、金额、状态、支付信息等)
|
|
||||||
// - 失败: 返回错误信息
|
|
||||||
func (h *RechargeHandler) Get(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的充值订单ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.UserContext()
|
|
||||||
// 获取个人客户ID作为用户ID
|
|
||||||
userID := middleware.GetCustomerIDFromContext(ctx)
|
|
||||||
if userID == 0 {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "用户未登录")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.GetByID(ctx, uint(id), userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,37 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||||
|
"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/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PersonalAuthMiddleware 个人客户认证中间件
|
// PersonalAuthMiddleware 个人客户认证中间件
|
||||||
type PersonalAuthMiddleware struct {
|
type PersonalAuthMiddleware struct {
|
||||||
jwtManager *auth.JWTManager
|
jwtManager *auth.JWTManager
|
||||||
|
redis *redis.Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPersonalAuthMiddleware 创建个人客户认证中间件
|
// NewPersonalAuthMiddleware 创建个人客户认证中间件
|
||||||
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, logger *zap.Logger) *PersonalAuthMiddleware {
|
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, rdb *redis.Client, logger *zap.Logger) *PersonalAuthMiddleware {
|
||||||
return &PersonalAuthMiddleware{
|
return &PersonalAuthMiddleware{
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
|
redis: rdb,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate 认证中间件
|
// Authenticate 认证中间件
|
||||||
|
// JWT + Redis 双重校验:先验证 JWT 签名和有效期,再检查 Redis 中 token 是否存在
|
||||||
func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
// 从 Authorization header 获取 token
|
|
||||||
authHeader := c.Get("Authorization")
|
authHeader := c.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
m.logger.Warn("个人客户认证失败:缺少 Authorization header",
|
m.logger.Warn("个人客户认证失败:缺少 Authorization header",
|
||||||
@@ -36,7 +41,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
|||||||
return errors.New(errors.CodeUnauthorized, "未提供认证令牌")
|
return errors.New(errors.CodeUnauthorized, "未提供认证令牌")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Bearer 前缀
|
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
m.logger.Warn("个人客户认证失败:Authorization header 格式错误",
|
m.logger.Warn("个人客户认证失败:Authorization header 格式错误",
|
||||||
@@ -48,7 +52,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
|||||||
|
|
||||||
token := parts[1]
|
token := parts[1]
|
||||||
|
|
||||||
// 验证 token
|
|
||||||
claims, err := m.jwtManager.VerifyPersonalCustomerToken(token)
|
claims, err := m.jwtManager.VerifyPersonalCustomerToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("个人客户认证失败:token 验证失败",
|
m.logger.Warn("个人客户认证失败:token 验证失败",
|
||||||
@@ -58,12 +61,35 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
|||||||
return errors.New(errors.CodeUnauthorized, "认证令牌无效或已过期")
|
return errors.New(errors.CodeUnauthorized, "认证令牌无效或已过期")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将客户信息存储到 context 中
|
// Redis 有效性检查:token 必须在 Redis 中存在才视为有效
|
||||||
|
// 支持服务端主动失效(封禁/强制下线/退出登录)
|
||||||
|
redisKey := constants.RedisPersonalCustomerTokenKey(claims.CustomerID)
|
||||||
|
storedToken, redisErr := m.redis.Get(context.Background(), redisKey).Result()
|
||||||
|
if redisErr == redis.Nil {
|
||||||
|
m.logger.Warn("个人客户认证失败:token 已被服务端失效",
|
||||||
|
zap.Uint("customer_id", claims.CustomerID),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
)
|
||||||
|
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
|
||||||
|
}
|
||||||
|
if redisErr != nil {
|
||||||
|
m.logger.Error("个人客户认证:Redis 查询异常",
|
||||||
|
zap.Uint("customer_id", claims.CustomerID),
|
||||||
|
zap.Error(redisErr),
|
||||||
|
)
|
||||||
|
return errors.New(errors.CodeUnauthorized, "认证服务异常,请稍后重试")
|
||||||
|
}
|
||||||
|
// 比对 Redis 中存储的 token 与当前请求 token 是否一致
|
||||||
|
if storedToken != token {
|
||||||
|
m.logger.Warn("个人客户认证失败:token 不匹配(可能已在其他设备登录)",
|
||||||
|
zap.Uint("customer_id", claims.CustomerID),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
)
|
||||||
|
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
|
||||||
|
}
|
||||||
|
|
||||||
c.Locals("customer_id", claims.CustomerID)
|
c.Locals("customer_id", claims.CustomerID)
|
||||||
c.Locals("customer_phone", claims.Phone)
|
c.Locals("customer_phone", claims.Phone)
|
||||||
|
|
||||||
// 设置 SkipOwnerFilter 标记,跳过 B 端数据权限过滤
|
|
||||||
// 个人客户不参与 RBAC 权限体系,不需要 Owner 过滤
|
|
||||||
c.Locals("skip_owner_filter", true)
|
c.Locals("skip_owner_filter", true)
|
||||||
|
|
||||||
m.logger.Debug("个人客户认证成功",
|
m.logger.Debug("个人客户认证成功",
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,6 +78,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"`
|
||||||
@@ -84,6 +86,13 @@ type AssetRechargeRecord struct {
|
|||||||
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
|
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||||
|
OperatorType string `gorm:"column:operator_type;type:varchar(20);not null;default:'admin_user';comment:操作人类型" json:"operator_type"`
|
||||||
|
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||||
|
LinkedPackageIDs datatypes.JSON `gorm:"column:linked_package_ids;type:jsonb;default:'[]';comment:强充关联套餐ID列表" json:"linked_package_ids,omitempty"`
|
||||||
|
LinkedOrderType string `gorm:"column:linked_order_type;type:varchar(20);comment:关联订单类型" json:"linked_order_type,omitempty"`
|
||||||
|
LinkedCarrierType string `gorm:"column:linked_carrier_type;type:varchar(20);comment:关联载体类型" json:"linked_carrier_type,omitempty"`
|
||||||
|
LinkedCarrierID *uint `gorm:"column:linked_carrier_id;type:bigint;comment:关联载体ID" json:"linked_carrier_id,omitempty"`
|
||||||
|
AutoPurchaseStatus string `gorm:"column:auto_purchase_status;type:varchar(20);default:'';comment:强充自动代购状态(pending-待处理 success-成功 failed-失败)" json:"auto_purchase_status,omitempty"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import (
|
|||||||
|
|
||||||
type Carrier struct {
|
type Carrier struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
BaseModel `gorm:"embedded"`
|
BaseModel `gorm:"embedded"`
|
||||||
CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码" json:"carrier_code"`
|
CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码" json:"carrier_code"`
|
||||||
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称" json:"carrier_name"`
|
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称" json:"carrier_name"`
|
||||||
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
|
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
|
||||||
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
|
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
|
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
|
||||||
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)" json:"billing_day"`
|
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)" json:"billing_day"`
|
||||||
|
RealnameLinkType string `gorm:"column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口" json:"realname_link_type"`
|
||||||
|
RealnameLinkTemplate string `gorm:"column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL" json:"realname_link_template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Device struct {
|
|||||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
|
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
|
||||||
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
|
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
|
||||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
||||||
|
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
|
||||||
|
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
56
internal/model/dto/agent_recharge_dto.go
Normal file
56
internal/model/dto/agent_recharge_dto.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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:"操作密码"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentOfflinePayParams 确认线下充值聚合参数 (用于文档生成)
|
||||||
|
type AgentOfflinePayParams struct {
|
||||||
|
IDReq
|
||||||
|
AgentOfflinePayRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:"充值记录列表"`
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type CreateCarrierRequest struct {
|
type CreateCarrierRequest struct {
|
||||||
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
|
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
|
||||||
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
|
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
|
||||||
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
||||||
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
|
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
|
||||||
|
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
|
||||||
|
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL,支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCarrierRequest struct {
|
type UpdateCarrierRequest struct {
|
||||||
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
|
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
|
||||||
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
|
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
|
||||||
|
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
|
||||||
|
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL,支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CarrierListRequest struct {
|
type CarrierListRequest struct {
|
||||||
@@ -25,14 +29,16 @@ type UpdateCarrierStatusRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CarrierResponse struct {
|
type CarrierResponse struct {
|
||||||
ID uint `json:"id" description:"运营商ID"`
|
ID uint `json:"id" description:"运营商ID"`
|
||||||
CarrierCode string `json:"carrier_code" description:"运营商编码"`
|
CarrierCode string `json:"carrier_code" description:"运营商编码"`
|
||||||
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||||
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
||||||
Description string `json:"description" description:"运营商描述"`
|
Description string `json:"description" description:"运营商描述"`
|
||||||
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
|
RealnameLinkType string `json:"realname_link_type" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
|
||||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
RealnameLinkTemplate string `json:"realname_link_template" description:"实名链接模板URL"`
|
||||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCarrierParams struct {
|
type UpdateCarrierParams struct {
|
||||||
|
|||||||
181
internal/model/dto/client_asset_dto.go
Normal file
181
internal/model/dto/client_asset_dto.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// B1 资产信息
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AssetInfoRequest B1 资产信息请求
|
||||||
|
type AssetInfoRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetInfoResponse B1 资产信息响应
|
||||||
|
// 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit)
|
||||||
|
type AssetInfoResponse struct {
|
||||||
|
// === 基础信息(通用) ===
|
||||||
|
AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"`
|
||||||
|
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||||
|
Identifier string `json:"identifier" description:"资产标识符"`
|
||||||
|
VirtualNo string `json:"virtual_no" description:"虚拟号"`
|
||||||
|
Status int `json:"status" description:"状态(1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||||
|
RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"`
|
||||||
|
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||||
|
Generation string `json:"generation" description:"世代"`
|
||||||
|
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`
|
||||||
|
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||||
|
|
||||||
|
// === 套餐信息(通用) ===
|
||||||
|
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||||
|
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按虚流量比例换算"`
|
||||||
|
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按虚流量比例换算"`
|
||||||
|
PackageRemainMB float64 `json:"package_remain_mb" description:"当前剩余虚流量(MB),已按虚流量比例换算"`
|
||||||
|
|
||||||
|
// === 设备专属字段(asset_type=device 时有效) ===
|
||||||
|
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
|
||||||
|
IMEI string `json:"imei,omitempty" description:"设备IMEI"`
|
||||||
|
SN string `json:"sn,omitempty" description:"设备序列号"`
|
||||||
|
DeviceModel string `json:"device_model,omitempty" description:"设备型号"`
|
||||||
|
DeviceType string `json:"device_type,omitempty" description:"设备类型"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty" description:"制造商"`
|
||||||
|
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
|
||||||
|
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定卡数量"`
|
||||||
|
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡列表(含每张卡的ICCID/MSISDN/网络状态/实名状态/插槽位置)"`
|
||||||
|
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态(none:无, stop:停机保护, start:开机保护)"`
|
||||||
|
|
||||||
|
// === 卡专属字段(asset_type=card 时有效) ===
|
||||||
|
ICCID string `json:"iccid,omitempty" description:"卡ICCID"`
|
||||||
|
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
|
||||||
|
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
|
||||||
|
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型(CMCC/CUCC/CTCC/CBN)"`
|
||||||
|
NetworkStatus int `json:"network_status,omitempty" description:"网络状态(0:停机, 1:开机)"`
|
||||||
|
ActivationStatus int `json:"activation_status,omitempty" description:"激活状态(0:未激活, 1:已激活)"`
|
||||||
|
CardCategory string `json:"card_category,omitempty" description:"卡业务类型(normal:普通卡, industry:行业卡)"`
|
||||||
|
|
||||||
|
// === 卡绑定设备信息(asset_type=card 且绑定了设备时有效) ===
|
||||||
|
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID"`
|
||||||
|
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号"`
|
||||||
|
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称"`
|
||||||
|
|
||||||
|
// === 设备实时状态(来自 Gateway,同步接口对接后自动填充,当前返回 null) ===
|
||||||
|
DeviceRealtime *DeviceRealtimeInfo `json:"device_realtime,omitempty" description:"设备实时状态(Gateway 同步接口对接后填充,当前为 null)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRealtimeInfo 设备实时状态信息
|
||||||
|
// 全量映射 Gateway DeviceInfoDetail 结构,所有字段均为可选
|
||||||
|
// 当前 Gateway 同步接口尚未对接,预留结构待后续填充
|
||||||
|
type DeviceRealtimeInfo struct {
|
||||||
|
// === 设备状态 ===
|
||||||
|
OnlineStatus *int64 `json:"online_status,omitempty" description:"在线状态(1:在线, 2:离线)"`
|
||||||
|
BatteryLevel *int64 `json:"battery_level,omitempty" description:"电池电量百分比"`
|
||||||
|
Status *int64 `json:"status,omitempty" description:"设备状态(1:正常, 0:禁用)"`
|
||||||
|
RunTime *string `json:"run_time,omitempty" description:"设备本次开机运行时间(秒)"`
|
||||||
|
ConnectTime *string `json:"connect_time,omitempty" description:"设备本次联网时间(秒)"`
|
||||||
|
LastOnlineTime *string `json:"last_online_time,omitempty" description:"设备最后在线时间"`
|
||||||
|
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
|
||||||
|
|
||||||
|
// === 信号相关 ===
|
||||||
|
Rsrp *int64 `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
|
||||||
|
Rsrq *int64 `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
|
||||||
|
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
|
||||||
|
Sinr *int64 `json:"sinr,omitempty" description:"信噪比(dB)"`
|
||||||
|
|
||||||
|
// === WiFi 相关 ===
|
||||||
|
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`
|
||||||
|
WifiEnabled *bool `json:"wifi_enabled,omitempty" description:"WiFi开关状态"`
|
||||||
|
WifiPassword *string `json:"wifi_password,omitempty" description:"WiFi密码"`
|
||||||
|
|
||||||
|
// === 网络相关 ===
|
||||||
|
IPAddress *string `json:"ip_address,omitempty" description:"IP地址"`
|
||||||
|
WANIP *string `json:"wan_ip,omitempty" description:"基站分配IPv4地址"`
|
||||||
|
LANIP *string `json:"lan_ip,omitempty" description:"局域网网关IP地址"`
|
||||||
|
MACAddress *string `json:"mac_address,omitempty" description:"MAC地址"`
|
||||||
|
|
||||||
|
// === 流量与速率 ===
|
||||||
|
DailyUsage *string `json:"daily_usage,omitempty" description:"日使用流量(字节)"`
|
||||||
|
DLStats *string `json:"dl_stats,omitempty" description:"本次开机下载流量(字节)"`
|
||||||
|
ULStats *string `json:"ul_stats,omitempty" description:"本次开机上传流量(字节)"`
|
||||||
|
LimitSpeed *int64 `json:"limit_speed,omitempty" description:"限速速率(KB/s)"`
|
||||||
|
|
||||||
|
// === 设备属性 ===
|
||||||
|
CurrentIccid *string `json:"current_iccid,omitempty" description:"当前使用的ICCID"`
|
||||||
|
MaxClients *int64 `json:"max_clients,omitempty" description:"最大连接客户端数"`
|
||||||
|
SoftwareVersion *string `json:"software_version,omitempty" description:"软件版本号"`
|
||||||
|
SwitchMode *int `json:"switch_mode,omitempty" description:"切卡模式(0:自动, 1:手动)"`
|
||||||
|
SyncInterval *int64 `json:"sync_interval,omitempty" description:"信息上报周期(秒)"`
|
||||||
|
|
||||||
|
// === Gateway 原始标识字段 ===
|
||||||
|
DeviceID *string `json:"device_id,omitempty" description:"Gateway设备ID(IMEI/SN)"`
|
||||||
|
DeviceName *string `json:"device_name,omitempty" description:"Gateway返回的设备名称"`
|
||||||
|
DeviceType *string `json:"device_type,omitempty" description:"Gateway返回的设备型号"`
|
||||||
|
Imei *string `json:"imei,omitempty" description:"Gateway返回的IMEI号"`
|
||||||
|
Imsi *string `json:"imsi,omitempty" description:"Gateway返回的IMSI"`
|
||||||
|
CreatedAt *int64 `json:"created_at,omitempty" description:"Gateway创建时间(Unix时间戳)"`
|
||||||
|
UpdatedAt *int64 `json:"updated_at,omitempty" description:"Gateway更新时间(Unix时间戳)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// B2 资产可购套餐列表
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AssetPackageListRequest B2 资产可购套餐列表请求
|
||||||
|
type AssetPackageListRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientPackageItem B2 客户端套餐项
|
||||||
|
type ClientPackageItem struct {
|
||||||
|
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||||
|
PackageName string `json:"package_name" description:"套餐名称"`
|
||||||
|
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
|
||||||
|
RetailPrice int64 `json:"retail_price" description:"零售价(分)"`
|
||||||
|
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||||
|
ValidityDays int `json:"validity_days" description:"有效天数"`
|
||||||
|
IsAddon bool `json:"is_addon" description:"是否加油包"`
|
||||||
|
DataAllowance int64 `json:"data_allowance" description:"流量额度"`
|
||||||
|
DataUnit string `json:"data_unit" description:"流量单位"`
|
||||||
|
Description string `json:"description" description:"套餐说明"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetPackageListResponse B2 资产可购套餐列表响应
|
||||||
|
type AssetPackageListResponse struct {
|
||||||
|
Packages []ClientPackageItem `json:"packages" description:"套餐列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// B3 资产套餐历史
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AssetPackageHistoryRequest B3 资产套餐历史请求
|
||||||
|
type AssetPackageHistoryRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
PackageType *string `json:"package_type" query:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
|
||||||
|
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=4" minimum:"0" maximum:"4" description:"套餐状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)"`
|
||||||
|
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetPackageHistoryResponse B3 资产套餐历史响应
|
||||||
|
type AssetPackageHistoryResponse struct {
|
||||||
|
List []AssetPackageResponse `json:"list" description:"套餐历史列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// B4 资产刷新
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AssetRefreshRequest B4 资产刷新请求
|
||||||
|
type AssetRefreshRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetRefreshResponse B4 资产刷新响应
|
||||||
|
type AssetRefreshResponse struct {
|
||||||
|
RefreshType string `json:"refresh_type" description:"刷新类型 (card:卡, device:设备)"`
|
||||||
|
Accepted bool `json:"accepted" description:"是否已受理"`
|
||||||
|
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
|
||||||
|
}
|
||||||
103
internal/model/dto/client_auth_dto.go
Normal file
103
internal/model/dto/client_auth_dto.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A1 资产验证
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// VerifyAssetRequest A1 资产验证请求
|
||||||
|
type VerifyAssetRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAssetResponse A1 资产验证响应
|
||||||
|
type VerifyAssetResponse struct {
|
||||||
|
AssetToken string `json:"asset_token" description:"资产令牌(5分钟有效)"`
|
||||||
|
ExpiresIn int `json:"expires_in" description:"过期时间(秒)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A2 公众号登录
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// WechatLoginRequest A2 公众号登录请求
|
||||||
|
type WechatLoginRequest struct {
|
||||||
|
Code string `json:"code" validate:"required" required:"true" description:"微信OAuth授权码"`
|
||||||
|
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatLoginResponse A2/A3 登录统一响应
|
||||||
|
type WechatLoginResponse struct {
|
||||||
|
Token string `json:"token" description:"登录JWT令牌"`
|
||||||
|
NeedBindPhone bool `json:"need_bind_phone" description:"是否需要绑定手机号"`
|
||||||
|
IsNewUser bool `json:"is_new_user" description:"是否新创建用户"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A3 小程序登录
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// MiniappLoginRequest A3 小程序登录请求
|
||||||
|
type MiniappLoginRequest struct {
|
||||||
|
Code string `json:"code" validate:"required" required:"true" description:"小程序登录凭证"`
|
||||||
|
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
|
||||||
|
Nickname string `json:"nickname" description:"用户昵称(前端授权后传入)"`
|
||||||
|
AvatarURL string `json:"avatar_url" description:"用户头像URL(前端授权后传入)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A4 发送验证码
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientSendCodeRequest A4 发送验证码请求
|
||||||
|
type ClientSendCodeRequest struct {
|
||||||
|
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
|
||||||
|
Scene string `json:"scene" validate:"required,oneof=bind_phone change_phone_old change_phone_new" required:"true" description:"业务场景 (bind_phone:绑定手机号, change_phone_old:换绑旧手机, change_phone_new:换绑新手机)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientSendCodeResponse A4 发送验证码响应
|
||||||
|
type ClientSendCodeResponse struct {
|
||||||
|
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A5 绑定手机号
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// BindPhoneRequest A5 绑定手机号请求
|
||||||
|
type BindPhoneRequest struct {
|
||||||
|
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
|
||||||
|
Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"验证码"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindPhoneResponse A5 绑定手机号响应
|
||||||
|
type BindPhoneResponse struct {
|
||||||
|
Phone string `json:"phone" description:"已绑定手机号"`
|
||||||
|
BoundAt string `json:"bound_at" description:"绑定时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A6 换绑手机号
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ChangePhoneRequest A6 换绑手机号请求
|
||||||
|
type ChangePhoneRequest struct {
|
||||||
|
OldPhone string `json:"old_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"旧手机号"`
|
||||||
|
OldCode string `json:"old_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"旧手机号验证码"`
|
||||||
|
NewPhone string `json:"new_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"新手机号"`
|
||||||
|
NewCode string `json:"new_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"新手机号验证码"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePhoneResponse A6 换绑手机号响应
|
||||||
|
type ChangePhoneResponse struct {
|
||||||
|
Phone string `json:"phone" description:"换绑后手机号"`
|
||||||
|
ChangedAt string `json:"changed_at" description:"换绑时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// A7 退出登录
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// LogoutResponse A7 退出登录响应
|
||||||
|
type LogoutResponse struct {
|
||||||
|
Success bool `json:"success" description:"是否成功"`
|
||||||
|
}
|
||||||
113
internal/model/dto/client_order_dto.go
Normal file
113
internal/model/dto/client_order_dto.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D1 客户端创建订单
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientCreateOrderRequest D1 客户端创建订单请求
|
||||||
|
type ClientCreateOrderRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"`
|
||||||
|
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientCreateOrderResponse D1 客户端创建订单响应
|
||||||
|
type ClientCreateOrderResponse struct {
|
||||||
|
OrderType string `json:"order_type" description:"订单类型 (package:套餐订单, recharge:充值订单)"`
|
||||||
|
Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息"`
|
||||||
|
Recharge *ClientRechargeInfo `json:"recharge,omitempty" description:"充值订单信息"`
|
||||||
|
PayConfig *ClientPayConfig `json:"pay_config" description:"支付配置"`
|
||||||
|
LinkedPackageInfo *LinkedPackageInfo `json:"linked_package_info,omitempty" description:"关联套餐信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOrderInfo D1 套餐订单信息
|
||||||
|
type ClientOrderInfo struct {
|
||||||
|
OrderID uint `json:"order_id" description:"订单ID"`
|
||||||
|
OrderNo string `json:"order_no" description:"订单号"`
|
||||||
|
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||||
|
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargeInfo D1 充值订单信息
|
||||||
|
type ClientRechargeInfo struct {
|
||||||
|
RechargeID uint `json:"recharge_id" description:"充值ID"`
|
||||||
|
RechargeNo string `json:"recharge_no" description:"充值单号"`
|
||||||
|
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||||
|
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
|
||||||
|
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientPayConfig D1 支付配置
|
||||||
|
type ClientPayConfig struct {
|
||||||
|
AppID string `json:"app_id" description:"应用ID"`
|
||||||
|
Timestamp string `json:"timestamp" description:"时间戳"`
|
||||||
|
NonceStr string `json:"nonce_str" description:"随机字符串"`
|
||||||
|
PackageVal string `json:"package" description:"预支付参数"`
|
||||||
|
SignType string `json:"sign_type" description:"签名类型"`
|
||||||
|
PaySign string `json:"pay_sign" description:"支付签名"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedPackageInfo D1 关联套餐信息
|
||||||
|
type LinkedPackageInfo struct {
|
||||||
|
PackageNames []string `json:"package_names" description:"套餐名称列表"`
|
||||||
|
TotalPackageAmount int64 `json:"total_package_amount" description:"套餐总金额(分)"`
|
||||||
|
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
|
||||||
|
WalletCredit int64 `json:"wallet_credit" description:"钱包抵扣金额(分)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D2 客户端订单列表
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientOrderListRequest D2 客户端订单列表请求
|
||||||
|
type ClientOrderListRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
|
||||||
|
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOrderListItem D2 客户端订单列表项
|
||||||
|
type ClientOrderListItem struct {
|
||||||
|
OrderID uint `json:"order_id" description:"订单ID"`
|
||||||
|
OrderNo string `json:"order_no" description:"订单号"`
|
||||||
|
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||||
|
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
PackageNames []string `json:"package_names" description:"套餐名称列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOrderListResponse D2 客户端订单列表响应
|
||||||
|
type ClientOrderListResponse struct {
|
||||||
|
List []ClientOrderListItem `json:"list" description:"订单列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D3 客户端订单详情
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientOrderDetailResponse D3 客户端订单详情响应
|
||||||
|
type ClientOrderDetailResponse struct {
|
||||||
|
OrderID uint `json:"order_id" description:"订单ID"`
|
||||||
|
OrderNo string `json:"order_no" description:"订单号"`
|
||||||
|
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||||
|
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
|
||||||
|
PaymentMethod string `json:"payment_method" description:"支付方式"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
PaidAt *string `json:"paid_at,omitempty" description:"支付时间"`
|
||||||
|
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
|
||||||
|
Packages []ClientOrderPackageItem `json:"packages" description:"订单套餐列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOrderPackageItem D3 订单套餐项
|
||||||
|
type ClientOrderPackageItem struct {
|
||||||
|
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||||
|
PackageName string `json:"package_name" description:"套餐名称"`
|
||||||
|
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
|
||||||
|
Price int64 `json:"price" description:"单价(分)"`
|
||||||
|
Quantity int `json:"quantity" description:"数量"`
|
||||||
|
}
|
||||||
104
internal/model/dto/client_realname_device_dto.go
Normal file
104
internal/model/dto/client_realname_device_dto.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// E1 实名链接获取
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// RealnimeLinkRequest E1 实名链接请求
|
||||||
|
type RealnimeLinkRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" maxLength:"30" description:"物联网卡ICCID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealnimeLinkResponse E1 实名链接响应
|
||||||
|
type RealnimeLinkResponse struct {
|
||||||
|
RealnameMode string `json:"realname_mode" description:"实名模式 (none:无需实名, template:模板实名, gateway:网关实名)"`
|
||||||
|
RealnameURL string `json:"realname_url" description:"实名链接"`
|
||||||
|
CardInfo CardInfoBrief `json:"card_info" description:"卡片简要信息"`
|
||||||
|
ExpireAt *string `json:"expire_at,omitempty" description:"过期时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardInfoBrief E1 卡片简要信息
|
||||||
|
type CardInfoBrief struct {
|
||||||
|
ICCID string `json:"iccid" description:"物联网卡ICCID"`
|
||||||
|
MSISDN string `json:"msisdn" description:"手机号"`
|
||||||
|
VirtualNo string `json:"virtual_no" description:"虚拟号"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// F1 设备卡列表
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// DeviceCardListRequest F1 设备卡列表请求
|
||||||
|
type DeviceCardListRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceCardItem F1 设备卡项
|
||||||
|
type DeviceCardItem struct {
|
||||||
|
CardID uint `json:"card_id" description:"卡ID"`
|
||||||
|
ICCID string `json:"iccid" description:"物联网卡ICCID"`
|
||||||
|
MSISDN string `json:"msisdn" description:"手机号"`
|
||||||
|
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||||
|
NetworkStatus string `json:"network_status" description:"网络状态"`
|
||||||
|
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
|
||||||
|
SlotPosition int `json:"slot_position" description:"插槽位置"`
|
||||||
|
IsActive bool `json:"is_active" description:"是否当前激活卡"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceCardListResponse F1 设备卡列表响应
|
||||||
|
type DeviceCardListResponse struct {
|
||||||
|
Cards []DeviceCardItem `json:"cards" description:"设备卡列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// F2 设备重启
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// DeviceRebootRequest F2 设备重启请求
|
||||||
|
type DeviceRebootRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceOperationResponse F2/F3/F4 设备操作响应
|
||||||
|
type DeviceOperationResponse struct {
|
||||||
|
Accepted bool `json:"accepted" description:"是否已受理"`
|
||||||
|
RequestID string `json:"request_id,omitempty" description:"请求ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// F3 恢复出厂设置
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// DeviceFactoryResetRequest F3 恢复出厂设置请求
|
||||||
|
type DeviceFactoryResetRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// F4 设备WiFi配置
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// DeviceWifiRequest F4 设备WiFi配置请求
|
||||||
|
type DeviceWifiRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi名称"`
|
||||||
|
Password string `json:"password" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"WiFi密码"`
|
||||||
|
Enabled bool `json:"enabled" validate:"required" required:"true" description:"是否启用WiFi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// F5 设备切卡
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// DeviceSwitchCardRequest F5 设备切卡请求
|
||||||
|
type DeviceSwitchCardRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
TargetICCID string `json:"target_iccid" validate:"required,min=1,max=30" required:"true" minLength:"1" maxLength:"30" description:"目标ICCID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceSwitchCardResponse F5 设备切卡响应
|
||||||
|
type DeviceSwitchCardResponse struct {
|
||||||
|
Accepted bool `json:"accepted" description:"是否已受理"`
|
||||||
|
TargetICCID string `json:"target_iccid" description:"目标ICCID"`
|
||||||
|
}
|
||||||
138
internal/model/dto/client_wallet_dto.go
Normal file
138
internal/model/dto/client_wallet_dto.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// C1 钱包详情
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// WalletDetailRequest C1 钱包详情请求
|
||||||
|
type WalletDetailRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletDetailResponse C1 钱包详情响应
|
||||||
|
type WalletDetailResponse struct {
|
||||||
|
WalletID uint `json:"wallet_id" description:"钱包ID"`
|
||||||
|
ResourceType string `json:"resource_type" description:"资源类型 (iot_card:物联网卡, device:设备)"`
|
||||||
|
ResourceID uint `json:"resource_id" description:"资源ID"`
|
||||||
|
Balance int64 `json:"balance" description:"可用余额(分)"`
|
||||||
|
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// C2 钱包流水列表
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// WalletTransactionListRequest C2 钱包流水列表请求
|
||||||
|
type WalletTransactionListRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
TransactionType string `json:"transaction_type" query:"transaction_type" validate:"omitempty,max=50" maxLength:"50" description:"流水类型"`
|
||||||
|
StartTime string `json:"start_time" query:"start_time" validate:"omitempty,max=32" maxLength:"32" description:"开始时间"`
|
||||||
|
EndTime string `json:"end_time" query:"end_time" validate:"omitempty,max=32" maxLength:"32" description:"结束时间"`
|
||||||
|
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletTransactionItem C2 钱包流水项
|
||||||
|
type WalletTransactionItem struct {
|
||||||
|
TransactionID uint `json:"transaction_id" description:"流水ID"`
|
||||||
|
Type string `json:"type" description:"流水类型"`
|
||||||
|
Amount int64 `json:"amount" description:"变动金额(分)"`
|
||||||
|
BalanceAfter int64 `json:"balance_after" description:"变动后余额(分)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
Remark string `json:"remark" description:"备注"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletTransactionListResponse C2 钱包流水列表响应
|
||||||
|
type WalletTransactionListResponse struct {
|
||||||
|
List []WalletTransactionItem `json:"list" description:"流水列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// C3 充值前校验
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientRechargeCheckRequest C3 充值前校验请求
|
||||||
|
type ClientRechargeCheckRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargeCheckResponse C3 充值前校验响应
|
||||||
|
type ClientRechargeCheckResponse struct {
|
||||||
|
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"`
|
||||||
|
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
|
||||||
|
TriggerType string `json:"trigger_type" description:"触发类型"`
|
||||||
|
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
|
||||||
|
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
|
||||||
|
Message string `json:"message" description:"提示信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// C4 创建充值订单
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientCreateRechargeRequest C4 创建充值订单请求
|
||||||
|
type ClientCreateRechargeRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
Amount int64 `json:"amount" validate:"required,min=100,max=10000000" required:"true" minimum:"100" maximum:"10000000" description:"充值金额(分)"`
|
||||||
|
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat" required:"true" description:"支付方式 (wechat:微信支付)"`
|
||||||
|
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargeResponse C4 创建充值订单响应
|
||||||
|
type ClientRechargeResponse struct {
|
||||||
|
Recharge ClientRechargeResult `json:"recharge" description:"充值信息"`
|
||||||
|
PayConfig ClientRechargePayConfig `json:"pay_config" description:"支付配置"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargeResult C4 充值信息
|
||||||
|
type ClientRechargeResult struct {
|
||||||
|
RechargeID uint `json:"recharge_id" description:"充值ID"`
|
||||||
|
RechargeNo string `json:"recharge_no" description:"充值单号"`
|
||||||
|
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||||
|
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargePayConfig C4 支付配置
|
||||||
|
type ClientRechargePayConfig struct {
|
||||||
|
AppID string `json:"app_id" description:"应用ID"`
|
||||||
|
Timestamp string `json:"timestamp" description:"时间戳"`
|
||||||
|
NonceStr string `json:"nonce_str" description:"随机字符串"`
|
||||||
|
PackageVal string `json:"package" description:"预支付参数"`
|
||||||
|
SignType string `json:"sign_type" description:"签名类型"`
|
||||||
|
PaySign string `json:"pay_sign" description:"支付签名"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// C5 充值记录列表
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ClientRechargeListRequest C5 充值记录列表请求
|
||||||
|
type ClientRechargeListRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||||
|
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"充值状态 (0:待支付, 1:已支付, 2:已关闭)"`
|
||||||
|
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargeListItem C5 充值记录项
|
||||||
|
type ClientRechargeListItem struct {
|
||||||
|
RechargeID uint `json:"recharge_id" description:"充值ID"`
|
||||||
|
RechargeNo string `json:"recharge_no" description:"充值单号"`
|
||||||
|
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||||
|
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
|
||||||
|
PaymentMethod string `json:"payment_method" description:"支付方式"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientRechargeListResponse C5 充值记录列表响应
|
||||||
|
type ClientRechargeListResponse struct {
|
||||||
|
List []ClientRechargeListItem `json:"list" description:"充值记录列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
}
|
||||||
104
internal/model/dto/exchange_dto.go
Normal file
104
internal/model/dto/exchange_dto.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type CreateExchangeRequest struct {
|
||||||
|
OldAssetType string `json:"old_asset_type" validate:"required,oneof=iot_card device" required:"true" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
||||||
|
OldIdentifier string `json:"old_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"旧资产标识符(ICCID/虚拟号/IMEI/SN)"`
|
||||||
|
ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"换货原因"`
|
||||||
|
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeListRequest 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:"每页数量"`
|
||||||
|
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符搜索(旧资产/新资产标识符模糊匹配)"`
|
||||||
|
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
|
||||||
|
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeShipRequest struct {
|
||||||
|
ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"`
|
||||||
|
ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"`
|
||||||
|
NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产标识符(ICCID/虚拟号/IMEI/SN)"`
|
||||||
|
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeCancelRequest struct {
|
||||||
|
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"取消备注"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientShippingInfoRequest struct {
|
||||||
|
RecipientName string `json:"recipient_name" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"收件人姓名"`
|
||||||
|
RecipientPhone string `json:"recipient_phone" validate:"required,min=1,max=20" required:"true" minLength:"1" maxLength:"20" description:"收件人电话"`
|
||||||
|
RecipientAddress string `json:"recipient_address" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"收货地址"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientExchangePendingRequest struct {
|
||||||
|
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID/虚拟号/IMEI/SN)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeIDRequest struct {
|
||||||
|
ID uint `path:"id" required:"true" description:"换货单ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeShipParams struct {
|
||||||
|
ID uint `path:"id" required:"true" description:"换货单ID"`
|
||||||
|
ExchangeShipRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeCancelParams struct {
|
||||||
|
ID uint `path:"id" required:"true" description:"换货单ID"`
|
||||||
|
ExchangeCancelRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientShippingInfoParams struct {
|
||||||
|
ID uint `path:"id" required:"true" description:"换货单ID"`
|
||||||
|
ClientShippingInfoRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeOrderResponse struct {
|
||||||
|
ID uint `json:"id" description:"换货单ID"`
|
||||||
|
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
||||||
|
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
||||||
|
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
|
||||||
|
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产标识符"`
|
||||||
|
NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"`
|
||||||
|
NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"`
|
||||||
|
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产标识符"`
|
||||||
|
RecipientName string `json:"recipient_name" description:"收件人姓名"`
|
||||||
|
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
|
||||||
|
RecipientAddress string `json:"recipient_address" description:"收货地址"`
|
||||||
|
ExpressCompany string `json:"express_company" description:"快递公司"`
|
||||||
|
ExpressNo string `json:"express_no" description:"快递单号"`
|
||||||
|
MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"`
|
||||||
|
MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"`
|
||||||
|
MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"`
|
||||||
|
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
|
||||||
|
Remark *string `json:"remark,omitempty" description:"备注"`
|
||||||
|
Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
||||||
|
StatusText string `json:"status_text" description:"换货状态文本"`
|
||||||
|
ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"`
|
||||||
|
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"`
|
||||||
|
Creator uint `json:"creator" description:"创建人ID"`
|
||||||
|
Updater uint `json:"updater" description:"更新人ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeListResponse struct {
|
||||||
|
List []*ExchangeOrderResponse `json:"list" description:"换货单列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"当前页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientExchangePendingResponse struct {
|
||||||
|
ID uint `json:"id" description:"换货单ID"`
|
||||||
|
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
||||||
|
Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
||||||
|
StatusText string `json:"status_text" description:"换货状态文本"`
|
||||||
|
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
|
||||||
|
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||||
|
}
|
||||||
@@ -56,6 +56,11 @@ type UpdatePackageShelfStatusRequest struct {
|
|||||||
ShelfStatus int `json:"shelf_status" validate:"required,oneof=1 2" required:"true" description:"上架状态 (1:上架, 2:下架)"`
|
ShelfStatus int `json:"shelf_status" validate:"required,oneof=1 2" required:"true" description:"上架状态 (1:上架, 2:下架)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRetailPriceRequest 更新零售价请求
|
||||||
|
type UpdateRetailPriceRequest struct {
|
||||||
|
RetailPrice int64 `json:"retail_price" validate:"required,min=0" required:"true" minimum:"0" description:"零售价(单位:分)"`
|
||||||
|
}
|
||||||
|
|
||||||
// CommissionTierInfo 返佣梯度信息
|
// CommissionTierInfo 返佣梯度信息
|
||||||
type CommissionTierInfo struct {
|
type CommissionTierInfo struct {
|
||||||
CurrentRate string `json:"current_rate" description:"当前返佣比例"`
|
CurrentRate string `json:"current_rate" description:"当前返佣比例"`
|
||||||
@@ -83,6 +88,7 @@ type PackageResponse struct {
|
|||||||
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
RetailPrice *int64 `json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
|
||||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||||
@@ -110,6 +116,12 @@ type UpdatePackageShelfStatusParams struct {
|
|||||||
UpdatePackageShelfStatusRequest
|
UpdatePackageShelfStatusRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRetailPriceParams 更新零售价聚合参数
|
||||||
|
type UpdateRetailPriceParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdateRetailPriceRequest
|
||||||
|
}
|
||||||
|
|
||||||
// PackagePageResult 套餐分页结果
|
// PackagePageResult 套餐分页结果
|
||||||
type PackagePageResult struct {
|
type PackagePageResult struct {
|
||||||
List []*PackageResponse `json:"list" description:"套餐列表"`
|
List []*PackageResponse `json:"list" description:"套餐列表"`
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ type BatchUpdateCostPriceRequest struct {
|
|||||||
|
|
||||||
// BatchUpdateCostPriceResponse 批量调价响应
|
// BatchUpdateCostPriceResponse 批量调价响应
|
||||||
type BatchUpdateCostPriceResponse struct {
|
type BatchUpdateCostPriceResponse struct {
|
||||||
UpdatedCount int `json:"updated_count" description:"更新数量"`
|
UpdatedCount int `json:"updated_count" description:"更新数量"`
|
||||||
AffectedIDs []uint `json:"affected_ids" description:"受影响的分配ID列表"`
|
AffectedIDs []uint `json:"affected_ids" description:"受影响的分配ID列表"`
|
||||||
|
Skipped []BatchPricingSkipped `json:"skipped,omitempty" description:"跳过的记录"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchPricingSkipped 批量调价跳过记录
|
||||||
|
type BatchPricingSkipped struct {
|
||||||
|
AllocationID uint `json:"allocation_id" description:"分配ID"`
|
||||||
|
Reason string `json:"reason" description:"跳过原因"`
|
||||||
}
|
}
|
||||||
|
|||||||
192
internal/model/dto/wechat_config_dto.go
Normal file
192
internal/model/dto/wechat_config_dto.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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:"富友支付回调地址"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWechatConfigParams 更新微信参数配置聚合参数 (用于文档生成)
|
||||||
|
type UpdateWechatConfigParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdateWechatConfigRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
65
internal/model/exchange_order.go
Normal file
65
internal/model/exchange_order.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExchangeOrder 换货单模型
|
||||||
|
// 承载客户端换货的完整生命周期:后台发起 → 客户端填写收货信息 → 后台发货 → 确认完成(含可选全量迁移) → 旧资产可转新
|
||||||
|
// 状态机:1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成,1/2 时可取消 → 5-已取消
|
||||||
|
type ExchangeOrder struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel `gorm:"embedded"`
|
||||||
|
|
||||||
|
// 单号
|
||||||
|
ExchangeNo string `gorm:"column:exchange_no;type:varchar(50);not null;uniqueIndex:idx_exchange_order_no,where:deleted_at IS NULL;comment:换货单号(EXC+日期+随机数)" json:"exchange_no"`
|
||||||
|
|
||||||
|
// 旧资产快照
|
||||||
|
OldAssetType string `gorm:"column:old_asset_type;type:varchar(20);not null;comment:旧资产类型(iot_card/device)" json:"old_asset_type"`
|
||||||
|
OldAssetID uint `gorm:"column:old_asset_id;not null;index:idx_exchange_order_old_asset;comment:旧资产ID" json:"old_asset_id"`
|
||||||
|
OldAssetIdentifier string `gorm:"column:old_asset_identifier;type:varchar(100);not null;comment:旧资产标识符(ICCID/虚拟号)" json:"old_asset_identifier"`
|
||||||
|
|
||||||
|
// 新资产快照(发货时填写)
|
||||||
|
NewAssetType string `gorm:"column:new_asset_type;type:varchar(20);comment:新资产类型(iot_card/device)" json:"new_asset_type"`
|
||||||
|
NewAssetID *uint `gorm:"column:new_asset_id;comment:新资产ID" json:"new_asset_id,omitempty"`
|
||||||
|
NewAssetIdentifier string `gorm:"column:new_asset_identifier;type:varchar(100);comment:新资产标识符(ICCID/虚拟号)" json:"new_asset_identifier"`
|
||||||
|
|
||||||
|
// 收货信息(客户端填写)
|
||||||
|
RecipientName string `gorm:"column:recipient_name;type:varchar(50);comment:收件人姓名" json:"recipient_name"`
|
||||||
|
RecipientPhone string `gorm:"column:recipient_phone;type:varchar(20);comment:收件人电话" json:"recipient_phone"`
|
||||||
|
RecipientAddress string `gorm:"column:recipient_address;type:text;comment:收货地址" json:"recipient_address"`
|
||||||
|
|
||||||
|
// 物流信息(后台发货时填写)
|
||||||
|
ExpressCompany string `gorm:"column:express_company;type:varchar(100);comment:快递公司" json:"express_company"`
|
||||||
|
ExpressNo string `gorm:"column:express_no;type:varchar(100);comment:快递单号" json:"express_no"`
|
||||||
|
|
||||||
|
// 迁移相关
|
||||||
|
MigrateData bool `gorm:"column:migrate_data;type:boolean;default:false;comment:是否执行全量迁移" json:"migrate_data"`
|
||||||
|
MigrationCompleted bool `gorm:"column:migration_completed;type:boolean;default:false;comment:迁移是否已完成" json:"migration_completed"`
|
||||||
|
MigrationBalance int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移转移金额(分)" json:"migration_balance"`
|
||||||
|
|
||||||
|
// 业务信息
|
||||||
|
ExchangeReason string `gorm:"column:exchange_reason;type:varchar(100);not null;comment:换货原因" json:"exchange_reason"`
|
||||||
|
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
|
||||||
|
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_exchange_order_status;comment:换货状态 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消" json:"status"`
|
||||||
|
|
||||||
|
// 多租户
|
||||||
|
ShopID *uint `gorm:"column:shop_id;index;comment:所属店铺ID" json:"shop_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (ExchangeOrder) TableName() string {
|
||||||
|
return "tb_exchange_order"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateExchangeNo 生成换货单号
|
||||||
|
// 格式:EXC + 年月日时分秒 + 6位随机数,如 EXC20260319143052123456
|
||||||
|
func GenerateExchangeNo() string {
|
||||||
|
now := time.Now()
|
||||||
|
randomNum := rand.Intn(1000000)
|
||||||
|
return fmt.Sprintf("EXC%s%06d", now.Format("20060102150405"), randomNum)
|
||||||
|
}
|
||||||
@@ -48,6 +48,8 @@ type IotCard struct {
|
|||||||
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
|
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
|
||||||
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
|
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
|
||||||
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
|
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
|
||||||
|
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
|
||||||
|
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||||
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
|
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
|
||||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
|
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ type Order struct {
|
|||||||
SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"`
|
SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"`
|
||||||
SeriesID *uint `gorm:"column:series_id;index;comment:系列ID(用于查询分配配置)" json:"series_id,omitempty"`
|
SeriesID *uint `gorm:"column:series_id;index;comment:系列ID(用于查询分配配置)" json:"series_id,omitempty"`
|
||||||
|
|
||||||
|
// 订单来源和世代
|
||||||
|
Source string `gorm:"column:source;type:varchar(20);not null;default:'admin';comment:订单来源 admin-后台 client-客户端" json:"source"`
|
||||||
|
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||||
|
|
||||||
// 代购信息
|
// 代购信息
|
||||||
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`
|
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`
|
||||||
|
|
||||||
@@ -55,6 +59,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 指定表名
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ type PackageUsage struct {
|
|||||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
|
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
|
||||||
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
|
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
|
||||||
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
|
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
|
||||||
|
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// 手机号、ICCID、设备号通过关联表存储
|
// 手机号、ICCID、设备号通过关联表存储
|
||||||
type PersonalCustomer struct {
|
type PersonalCustomer struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL;not null;comment:微信OpenID(唯一标识)" json:"wx_open_id"`
|
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);index:idx_personal_customer_wx_open_id;not null;comment:微信OpenID(唯一标识)" json:"wx_open_id"`
|
||||||
WxUnionID string `gorm:"column:wx_union_id;type:varchar(100);index;not null;comment:微信UnionID" json:"wx_union_id"`
|
WxUnionID string `gorm:"column:wx_union_id;type:varchar(100);index;not null;comment:微信UnionID" json:"wx_union_id"`
|
||||||
Nickname string `gorm:"column:nickname;type:varchar(100);comment:微信昵称" json:"nickname"`
|
Nickname string `gorm:"column:nickname;type:varchar(100);comment:微信昵称" json:"nickname"`
|
||||||
AvatarURL string `gorm:"column:avatar_url;type:varchar(500);comment:微信头像URL" json:"avatar_url"`
|
AvatarURL string `gorm:"column:avatar_url;type:varchar(500);comment:微信头像URL" json:"avatar_url"`
|
||||||
|
|||||||
23
internal/model/personal_customer_openid.go
Normal file
23
internal/model/personal_customer_openid.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersonalCustomerOpenID 个人客户 OpenID 关联模型
|
||||||
|
// 保存客户在不同微信应用(公众号/小程序)下的 OpenID 记录
|
||||||
|
// 同一客户可在多个 AppID 下拥有不同的 OpenID
|
||||||
|
// 唯一约束:UNIQUE(app_id, open_id) WHERE deleted_at IS NULL
|
||||||
|
type PersonalCustomerOpenID struct {
|
||||||
|
gorm.Model
|
||||||
|
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;index:idx_pco_customer_id;comment:关联个人客户ID" json:"customer_id"`
|
||||||
|
AppID string `gorm:"column:app_id;type:varchar(100);not null;comment:微信应用标识(公众号或小程序AppID)" json:"app_id"`
|
||||||
|
OpenID string `gorm:"column:open_id;type:varchar(100);not null;comment:当前应用下的OpenID" json:"open_id"`
|
||||||
|
UnionID string `gorm:"column:union_id;type:varchar(100);not null;default:'';comment:微信开放平台统一标识(可选)" json:"union_id"`
|
||||||
|
AppType string `gorm:"column:app_type;type:varchar(20);not null;default:'';comment:应用类型(official_account/miniapp)" json:"app_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (PersonalCustomerOpenID) TableName() string {
|
||||||
|
return "tb_personal_customer_openid"
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type ShopPackageAllocation struct {
|
|||||||
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
|
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||||
|
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.Device != nil {
|
if handlers.Device != nil {
|
||||||
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
|
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.AssetLifecycle != nil {
|
||||||
|
registerAssetLifecycleRoutes(authGroup, handlers.AssetLifecycle, doc, basePath)
|
||||||
|
}
|
||||||
if handlers.AssetAllocationRecord != nil {
|
if handlers.AssetAllocationRecord != nil {
|
||||||
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
|
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
|
||||||
}
|
}
|
||||||
@@ -89,6 +92,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.AdminOrder != nil {
|
if handlers.AdminOrder != nil {
|
||||||
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
|
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.AdminExchange != nil {
|
||||||
|
registerAdminExchangeRoutes(authGroup, handlers.AdminExchange, doc, basePath)
|
||||||
|
}
|
||||||
if handlers.PollingConfig != nil {
|
if handlers.PollingConfig != nil {
|
||||||
registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath)
|
registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath)
|
||||||
}
|
}
|
||||||
@@ -110,4 +116,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.AgentOfflinePayParams),
|
||||||
|
Output: new(dto.AgentRechargeResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
28
internal/routes/asset_lifecycle.go
Normal file
28
internal/routes/asset_lifecycle.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerAssetLifecycleRoutes 注册资产手动停用路由
|
||||||
|
func registerAssetLifecycleRoutes(router fiber.Router, handler *admin.AssetLifecycleHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
Register(router, doc, basePath, "PATCH", "/iot-cards/:id/deactivate", handler.DeactivateIotCard, RouteSpec{
|
||||||
|
Summary: "手动停用IoT卡",
|
||||||
|
Tags: []string{"IoT卡管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "PATCH", "/devices/:id/deactivate", handler.DeactivateDevice, RouteSpec{
|
||||||
|
Summary: "手动停用设备",
|
||||||
|
Tags: []string{"设备管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
66
internal/routes/exchange.go
Normal file
66
internal/routes/exchange.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/openapi"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
Register(router, doc, basePath, "POST", "/exchanges", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建换货单",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.CreateExchangeRequest),
|
||||||
|
Output: new(dto.ExchangeOrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "GET", "/exchanges", handler.List, RouteSpec{
|
||||||
|
Summary: "获取换货单列表",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.ExchangeListRequest),
|
||||||
|
Output: new(dto.ExchangeListResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "GET", "/exchanges/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "获取换货单详情",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.ExchangeIDRequest),
|
||||||
|
Output: new(dto.ExchangeOrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/exchanges/:id/ship", handler.Ship, RouteSpec{
|
||||||
|
Summary: "换货发货",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.ExchangeShipParams),
|
||||||
|
Output: new(dto.ExchangeOrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/exchanges/:id/complete", handler.Complete, RouteSpec{
|
||||||
|
Summary: "确认换货完成",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.ExchangeIDRequest),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/exchanges/:id/cancel", handler.Cancel, RouteSpec{
|
||||||
|
Summary: "取消换货",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.ExchangeCancelParams),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/exchanges/:id/renew", handler.Renew, RouteSpec{
|
||||||
|
Summary: "旧资产转新",
|
||||||
|
Tags: []string{"换货管理"},
|
||||||
|
Input: new(dto.ExchangeIDRequest),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegisterH5Routes 注册H5相关路由
|
|
||||||
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
|
|
||||||
// 认证路由已迁移到 /api/auth,参见 RegisterAuthRoutes
|
|
||||||
authGroup := router.Group("", middlewares.H5Auth)
|
|
||||||
|
|
||||||
if handlers.H5Order != nil {
|
|
||||||
registerH5OrderRoutes(authGroup, handlers.H5Order, doc, basePath)
|
|
||||||
}
|
|
||||||
if handlers.H5Recharge != nil {
|
|
||||||
registerH5RechargeRoutes(authGroup, handlers.H5Recharge, doc, basePath)
|
|
||||||
}
|
|
||||||
if handlers.EnterpriseDeviceH5 != nil {
|
|
||||||
registerH5EnterpriseDeviceRoutes(authGroup, handlers.EnterpriseDeviceH5, doc, basePath)
|
|
||||||
}
|
|
||||||
if handlers.H5PackageUsage != nil {
|
|
||||||
registerH5PackageUsageRoutes(authGroup, handlers.H5PackageUsage, doc, basePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func registerH5EnterpriseDeviceRoutes(router fiber.Router, handler *h5.EnterpriseDeviceHandler, doc *openapi.Generator, basePath string) {
|
|
||||||
devices := router.Group("/devices")
|
|
||||||
groupPath := basePath + "/devices"
|
|
||||||
|
|
||||||
Register(devices, doc, groupPath, "GET", "", handler.ListDevices, RouteSpec{
|
|
||||||
Summary: "企业设备列表(H5)",
|
|
||||||
Tags: []string{"H5-企业设备"},
|
|
||||||
Input: new(dto.H5EnterpriseDeviceListReq),
|
|
||||||
Output: new(dto.EnterpriseDeviceListResp),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(devices, doc, groupPath, "GET", "/:device_id", handler.GetDeviceDetail, RouteSpec{
|
|
||||||
Summary: "获取设备详情(H5)",
|
|
||||||
Tags: []string{"H5-企业设备"},
|
|
||||||
Input: new(dto.DeviceDetailReq),
|
|
||||||
Output: new(dto.EnterpriseDeviceDetailResp),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerH5PackageUsageRoutes 注册 H5 端套餐使用情况路由
|
|
||||||
func registerH5PackageUsageRoutes(router fiber.Router, handler *h5.PackageUsageHandler, doc *openapi.Generator, basePath string) {
|
|
||||||
packages := router.Group("/packages")
|
|
||||||
groupPath := basePath + "/packages"
|
|
||||||
|
|
||||||
Register(packages, doc, groupPath, "GET", "/my-usage", handler.GetMyUsage, RouteSpec{
|
|
||||||
Summary: "获取我的套餐使用情况",
|
|
||||||
Tags: []string{"H5-套餐"},
|
|
||||||
Input: nil,
|
|
||||||
Output: new(dto.PackageUsageCustomerViewResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
@@ -53,57 +52,6 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerH5OrderRoutes 注册H5订单路由
|
|
||||||
func registerH5OrderRoutes(router fiber.Router, handler *h5.OrderHandler, doc *openapi.Generator, basePath string) {
|
|
||||||
Register(router, doc, basePath, "POST", "/orders", handler.Create, RouteSpec{
|
|
||||||
Summary: "创建订单",
|
|
||||||
Tags: []string{"H5 订单"},
|
|
||||||
Input: new(dto.CreateOrderRequest),
|
|
||||||
Output: new(dto.OrderResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{
|
|
||||||
Summary: "获取订单列表",
|
|
||||||
Tags: []string{"H5 订单"},
|
|
||||||
Input: new(dto.OrderListRequest),
|
|
||||||
Output: new(dto.OrderListResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{
|
|
||||||
Summary: "获取订单详情",
|
|
||||||
Tags: []string{"H5 订单"},
|
|
||||||
Input: new(dto.GetOrderRequest),
|
|
||||||
Output: new(dto.OrderResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "POST", "/orders/:id/wallet-pay", handler.WalletPay, RouteSpec{
|
|
||||||
Summary: "钱包支付",
|
|
||||||
Tags: []string{"H5 订单"},
|
|
||||||
Input: new(dto.CancelOrderRequest),
|
|
||||||
Output: nil,
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/jsapi", handler.WechatPayJSAPI, RouteSpec{
|
|
||||||
Summary: "微信 JSAPI 支付",
|
|
||||||
Tags: []string{"H5 订单"},
|
|
||||||
Input: new(dto.WechatPayJSAPIParams),
|
|
||||||
Output: new(dto.WechatPayJSAPIResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/h5", handler.WechatPayH5, RouteSpec{
|
|
||||||
Summary: "微信 H5 支付",
|
|
||||||
Tags: []string{"H5 订单"},
|
|
||||||
Input: new(dto.WechatPayH5Params),
|
|
||||||
Output: new(dto.WechatPayH5Response),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerPaymentCallbackRoutes 注册支付回调路由
|
// registerPaymentCallbackRoutes 注册支付回调路由
|
||||||
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
|
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
|
||||||
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
|
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
|
||||||
@@ -121,4 +69,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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,4 +67,12 @@ func registerPackageRoutes(router fiber.Router, handler *admin.PackageHandler, d
|
|||||||
Output: nil,
|
Output: nil,
|
||||||
Auth: true,
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "PATCH", "/:id/retail-price", handler.UpdateRetailPrice, RouteSpec{
|
||||||
|
Summary: "修改零售价(代理)",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.UpdateRetailPriceParams),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,54 +12,79 @@ import (
|
|||||||
|
|
||||||
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
||||||
// 路由挂载在 /api/c/v1 下
|
// 路由挂载在 /api/c/v1 下
|
||||||
|
//
|
||||||
|
// 重要:Fiber 的 Group.Use() 会在路由表中注册全局 USE 处理器,
|
||||||
|
// 匹配该前缀下的所有请求(不区分 Group 对象)。
|
||||||
|
// 因此公开路由必须在任何 Use() 调用之前注册,利用 Fiber 按注册顺序匹配的机制,
|
||||||
|
// 确保公开路由优先命中并直接返回,不会被后续的认证中间件拦截。
|
||||||
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
|
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
|
||||||
// 公开路由(不需要认证)
|
authBasePath := "/auth"
|
||||||
publicGroup := router.Group("")
|
|
||||||
|
|
||||||
// 发送验证码
|
// === 公开路由(无需认证)===
|
||||||
Register(publicGroup, doc, basePath, "POST", "/login/send-code", handlers.PersonalCustomer.SendCode, RouteSpec{
|
Register(router, doc, basePath, "POST", authBasePath+"/verify-asset", handlers.ClientAuth.VerifyAsset, RouteSpec{
|
||||||
Summary: "发送验证码",
|
Summary: "资产验证",
|
||||||
Description: "向指定手机号发送登录验证码",
|
Tags: []string{"个人客户 - 认证"},
|
||||||
Tags: []string{"个人客户 - 认证"},
|
Auth: false,
|
||||||
Auth: false,
|
Input: &dto.VerifyAssetRequest{},
|
||||||
Input: &apphandler.SendCodeRequest{},
|
Output: &dto.VerifyAssetResponse{},
|
||||||
Output: nil,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 登录
|
Register(router, doc, basePath, "POST", authBasePath+"/wechat-login", handlers.ClientAuth.WechatLogin, RouteSpec{
|
||||||
Register(publicGroup, doc, basePath, "POST", "/login", handlers.PersonalCustomer.Login, RouteSpec{
|
Summary: "公众号登录",
|
||||||
Summary: "手机号登录",
|
Tags: []string{"个人客户 - 认证"},
|
||||||
Description: "使用手机号和验证码登录",
|
Auth: false,
|
||||||
Tags: []string{"个人客户 - 认证"},
|
Input: &dto.WechatLoginRequest{},
|
||||||
Auth: false,
|
Output: &dto.WechatLoginResponse{},
|
||||||
Input: &apphandler.LoginRequest{},
|
|
||||||
Output: &apphandler.LoginResponse{},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 微信 OAuth 登录(公开)
|
Register(router, doc, basePath, "POST", authBasePath+"/miniapp-login", handlers.ClientAuth.MiniappLogin, RouteSpec{
|
||||||
Register(publicGroup, doc, basePath, "POST", "/wechat/auth", handlers.PersonalCustomer.WechatOAuthLogin, RouteSpec{
|
Summary: "小程序登录",
|
||||||
Summary: "微信授权登录",
|
Tags: []string{"个人客户 - 认证"},
|
||||||
Description: "使用微信授权码登录,自动创建或关联用户",
|
Auth: false,
|
||||||
Tags: []string{"个人客户 - 认证"},
|
Input: &dto.MiniappLoginRequest{},
|
||||||
Auth: false,
|
Output: &dto.WechatLoginResponse{},
|
||||||
Input: &dto.WechatOAuthRequest{},
|
})
|
||||||
Output: &dto.WechatOAuthResponse{},
|
|
||||||
|
Register(router, doc, basePath, "POST", authBasePath+"/send-code", handlers.ClientAuth.SendCode, RouteSpec{
|
||||||
|
Summary: "发送验证码",
|
||||||
|
Tags: []string{"个人客户 - 认证"},
|
||||||
|
Auth: false,
|
||||||
|
Input: &dto.ClientSendCodeRequest{},
|
||||||
|
Output: &dto.ClientSendCodeResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
// === 需要认证的 auth 路由 ===
|
||||||
|
authProtectedGroup := router.Group(authBasePath)
|
||||||
|
authProtectedGroup.Use(personalAuthMiddleware.Authenticate())
|
||||||
|
|
||||||
|
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{
|
||||||
|
Summary: "绑定手机号",
|
||||||
|
Tags: []string{"个人客户 - 认证"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.BindPhoneRequest{},
|
||||||
|
Output: &dto.BindPhoneResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/change-phone", handlers.ClientAuth.ChangePhone, RouteSpec{
|
||||||
|
Summary: "更换手机号",
|
||||||
|
Tags: []string{"个人客户 - 认证"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ChangePhoneRequest{},
|
||||||
|
Output: &dto.ChangePhoneResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/logout", handlers.ClientAuth.Logout, RouteSpec{
|
||||||
|
Summary: "退出登录",
|
||||||
|
Tags: []string{"个人客户 - 认证"},
|
||||||
|
Auth: true,
|
||||||
|
Input: nil,
|
||||||
|
Output: &dto.LogoutResponse{},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 需要认证的路由
|
// 需要认证的路由
|
||||||
authGroup := router.Group("")
|
authGroup := router.Group("")
|
||||||
authGroup.Use(personalAuthMiddleware.Authenticate())
|
authGroup.Use(personalAuthMiddleware.Authenticate())
|
||||||
|
|
||||||
// 绑定微信
|
|
||||||
Register(authGroup, doc, basePath, "POST", "/bind-wechat", handlers.PersonalCustomer.BindWechat, RouteSpec{
|
|
||||||
Summary: "绑定微信",
|
|
||||||
Description: "绑定微信账号到当前个人客户",
|
|
||||||
Tags: []string{"个人客户 - 账户"},
|
|
||||||
Auth: true,
|
|
||||||
Input: &dto.WechatOAuthRequest{},
|
|
||||||
Output: nil,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取个人资料
|
// 获取个人资料
|
||||||
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{
|
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{
|
||||||
Summary: "获取个人资料",
|
Summary: "获取个人资料",
|
||||||
@@ -79,4 +104,164 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
|
|||||||
Input: &apphandler.UpdateProfileRequest{},
|
Input: &apphandler.UpdateProfileRequest{},
|
||||||
Output: nil,
|
Output: nil,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/asset/info", handlers.ClientAsset.GetAssetInfo, RouteSpec{
|
||||||
|
Summary: "资产信息",
|
||||||
|
Tags: []string{"个人客户 - 资产"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.AssetInfoRequest{},
|
||||||
|
Output: &dto.AssetInfoResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/asset/packages", handlers.ClientAsset.GetAvailablePackages, RouteSpec{
|
||||||
|
Summary: "资产可购套餐列表",
|
||||||
|
Tags: []string{"个人客户 - 资产"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.AssetPackageListRequest{},
|
||||||
|
Output: &dto.AssetPackageListResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/asset/package-history", handlers.ClientAsset.GetPackageHistory, RouteSpec{
|
||||||
|
Summary: "资产套餐历史",
|
||||||
|
Tags: []string{"个人客户 - 资产"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.AssetPackageHistoryRequest{},
|
||||||
|
Output: &dto.AssetPackageHistoryResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/asset/refresh", handlers.ClientAsset.RefreshAsset, RouteSpec{
|
||||||
|
Summary: "资产刷新",
|
||||||
|
Tags: []string{"个人客户 - 资产"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.AssetRefreshRequest{},
|
||||||
|
Output: &dto.AssetRefreshResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/wallet/detail", handlers.ClientWallet.GetWalletDetail, RouteSpec{
|
||||||
|
Summary: "钱包详情",
|
||||||
|
Tags: []string{"个人客户 - 钱包"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.WalletDetailRequest{},
|
||||||
|
Output: &dto.WalletDetailResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/wallet/transactions", handlers.ClientWallet.GetWalletTransactions, RouteSpec{
|
||||||
|
Summary: "钱包流水列表",
|
||||||
|
Tags: []string{"个人客户 - 钱包"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.WalletTransactionListRequest{},
|
||||||
|
Output: &dto.WalletTransactionListResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/wallet/recharge-check", handlers.ClientWallet.GetRechargeCheck, RouteSpec{
|
||||||
|
Summary: "充值前校验",
|
||||||
|
Tags: []string{"个人客户 - 钱包"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientRechargeCheckRequest{},
|
||||||
|
Output: &dto.ClientRechargeCheckResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/wallet/recharge", handlers.ClientWallet.CreateRecharge, RouteSpec{
|
||||||
|
Summary: "创建充值订单",
|
||||||
|
Tags: []string{"个人客户 - 钱包"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientCreateRechargeRequest{},
|
||||||
|
Output: &dto.ClientRechargeResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/wallet/recharges", handlers.ClientWallet.GetRechargeList, RouteSpec{
|
||||||
|
Summary: "充值记录列表",
|
||||||
|
Tags: []string{"个人客户 - 钱包"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientRechargeListRequest{},
|
||||||
|
Output: &dto.ClientRechargeListResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/orders/create", handlers.ClientOrder.CreateOrder, RouteSpec{
|
||||||
|
Summary: "创建订单",
|
||||||
|
Tags: []string{"个人客户 - 订单"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientCreateOrderRequest{},
|
||||||
|
Output: &dto.ClientCreateOrderResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/orders", handlers.ClientOrder.ListOrders, RouteSpec{
|
||||||
|
Summary: "订单列表",
|
||||||
|
Tags: []string{"个人客户 - 订单"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientOrderListRequest{},
|
||||||
|
Output: &dto.ClientOrderListResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/orders/:id", handlers.ClientOrder.GetOrderDetail, RouteSpec{
|
||||||
|
Summary: "订单详情",
|
||||||
|
Tags: []string{"个人客户 - 订单"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.IDReq{},
|
||||||
|
Output: &dto.ClientOrderDetailResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/exchange/pending", handlers.ClientExchange.GetPending, RouteSpec{
|
||||||
|
Summary: "查询待处理换货单",
|
||||||
|
Tags: []string{"个人客户 - 换货"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientExchangePendingRequest{},
|
||||||
|
Output: &dto.ClientExchangePendingResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/exchange/:id/shipping-info", handlers.ClientExchange.SubmitShippingInfo, RouteSpec{
|
||||||
|
Summary: "提交收货信息",
|
||||||
|
Tags: []string{"个人客户 - 换货"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.ClientShippingInfoParams{},
|
||||||
|
Output: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/realname/link", handlers.ClientRealname.GetRealnameLink, RouteSpec{
|
||||||
|
Summary: "获取实名认证链接",
|
||||||
|
Tags: []string{"个人客户 - 实名"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.RealnimeLinkRequest{},
|
||||||
|
Output: &dto.RealnimeLinkResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "GET", "/device/cards", handlers.ClientDevice.GetDeviceCards, RouteSpec{
|
||||||
|
Summary: "获取设备卡列表",
|
||||||
|
Tags: []string{"个人客户 - 设备"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.DeviceCardListRequest{},
|
||||||
|
Output: &dto.DeviceCardListResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/device/reboot", handlers.ClientDevice.RebootDevice, RouteSpec{
|
||||||
|
Summary: "设备重启",
|
||||||
|
Tags: []string{"个人客户 - 设备"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.DeviceRebootRequest{},
|
||||||
|
Output: &dto.DeviceOperationResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/device/factory-reset", handlers.ClientDevice.FactoryResetDevice, RouteSpec{
|
||||||
|
Summary: "恢复出厂设置",
|
||||||
|
Tags: []string{"个人客户 - 设备"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.DeviceFactoryResetRequest{},
|
||||||
|
Output: &dto.DeviceOperationResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/device/wifi", handlers.ClientDevice.SetWiFi, RouteSpec{
|
||||||
|
Summary: "设备WiFi配置",
|
||||||
|
Tags: []string{"个人客户 - 设备"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.DeviceWifiRequest{},
|
||||||
|
Output: &dto.DeviceOperationResponse{},
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(authGroup, doc, basePath, "POST", "/device/switch-card", handlers.ClientDevice.SwitchCard, RouteSpec{
|
||||||
|
Summary: "设备切卡",
|
||||||
|
Tags: []string{"个人客户 - 设备"},
|
||||||
|
Auth: true,
|
||||||
|
Input: &dto.DeviceSwitchCardRequest{},
|
||||||
|
Output: &dto.DeviceSwitchCardResponse{},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerH5RechargeRoutes 注册H5充值路由
|
|
||||||
func registerH5RechargeRoutes(router fiber.Router, handler *h5.RechargeHandler, doc *openapi.Generator, basePath string) {
|
|
||||||
Register(router, doc, basePath, "POST", "/wallets/recharge", handler.Create, RouteSpec{
|
|
||||||
Summary: "创建充值订单",
|
|
||||||
Tags: []string{"H5 充值"},
|
|
||||||
Input: new(dto.CreateRechargeRequest),
|
|
||||||
Output: new(dto.RechargeResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "GET", "/wallets/recharge-check", handler.RechargeCheck, RouteSpec{
|
|
||||||
Summary: "充值预检",
|
|
||||||
Tags: []string{"H5 充值"},
|
|
||||||
Input: new(dto.RechargeCheckRequest),
|
|
||||||
Output: new(dto.RechargeCheckResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "GET", "/wallets/recharges", handler.List, RouteSpec{
|
|
||||||
Summary: "获取充值订单列表",
|
|
||||||
Tags: []string{"H5 充值"},
|
|
||||||
Input: new(dto.RechargeListRequest),
|
|
||||||
Output: new(dto.RechargeListResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
Register(router, doc, basePath, "GET", "/wallets/recharges/:id", handler.Get, RouteSpec{
|
|
||||||
Summary: "获取充值订单详情",
|
|
||||||
Tags: []string{"H5 充值"},
|
|
||||||
Input: new(dto.GetRechargeRequest),
|
|
||||||
Output: new(dto.RechargeResponse),
|
|
||||||
Auth: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
|
|||||||
adminGroup := app.Group("/api/admin")
|
adminGroup := app.Group("/api/admin")
|
||||||
RegisterAdminRoutes(adminGroup, handlers, middlewares, doc, "/api/admin")
|
RegisterAdminRoutes(adminGroup, handlers, middlewares, doc, "/api/admin")
|
||||||
|
|
||||||
// 4. H5 域 (挂载在 /api/h5)
|
// 4. 个人客户路由 (挂载在 /api/c/v1)
|
||||||
h5Group := app.Group("/api/h5")
|
|
||||||
RegisterH5Routes(h5Group, handlers, middlewares, doc, "/api/h5")
|
|
||||||
|
|
||||||
// 5. 个人客户路由 (挂载在 /api/c/v1)
|
|
||||||
personalGroup := app.Group("/api/c/v1")
|
personalGroup := app.Group("/api/c/v1")
|
||||||
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
|
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
|
||||||
|
|
||||||
// 6. 支付回调路由 (挂载在 /api/callback,无需认证)
|
// 5. 支付回调路由 (挂载在 /api/callback,无需认证)
|
||||||
if handlers.PaymentCallback != nil {
|
if handlers.PaymentCallback != nil {
|
||||||
callbackGroup := app.Group("/api/callback")
|
callbackGroup := app.Group("/api/callback")
|
||||||
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
|
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
|
||||||
|
|||||||
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.UpdateWechatConfigParams),
|
||||||
|
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
|
||||||
|
}
|
||||||
88
internal/service/asset/lifecycle_service.go
Normal file
88
internal/service/asset/lifecycle_service.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package asset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
stderrors "errors"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var deactivatableAssetStatuses = []int{constants.AssetStatusInStock, constants.AssetStatusSold}
|
||||||
|
|
||||||
|
// LifecycleService 资产生命周期服务
|
||||||
|
type LifecycleService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLifecycleService 创建资产生命周期服务
|
||||||
|
func NewLifecycleService(db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore) *LifecycleService {
|
||||||
|
return &LifecycleService{
|
||||||
|
db: db,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateIotCard 手动停用 IoT 卡
|
||||||
|
func (s *LifecycleService) DeactivateIotCard(ctx context.Context, id uint) error {
|
||||||
|
card, err := s.iotCardStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New(errors.CodeIotCardNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canDeactivateAsset(card.AssetStatus) {
|
||||||
|
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||||
|
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
|
||||||
|
Update("asset_status", constants.AssetStatusDeactivated)
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateDevice 手动停用设备
|
||||||
|
func (s *LifecycleService) DeactivateDevice(ctx context.Context, id uint) error {
|
||||||
|
device, err := s.deviceStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New(errors.CodeNotFound, "设备不存在")
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canDeactivateAsset(device.AssetStatus) {
|
||||||
|
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s.db.WithContext(ctx).Model(&model.Device{}).
|
||||||
|
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
|
||||||
|
Update("asset_status", constants.AssetStatusDeactivated)
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canDeactivateAsset(assetStatus int) bool {
|
||||||
|
return assetStatus == constants.AssetStatusInStock || assetStatus == constants.AssetStatusSold
|
||||||
|
}
|
||||||
@@ -41,6 +41,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Status: constants.StatusEnabled,
|
Status: constants.StatusEnabled,
|
||||||
}
|
}
|
||||||
|
if req.RealnameLinkType != nil {
|
||||||
|
carrier.RealnameLinkType = *req.RealnameLinkType
|
||||||
|
}
|
||||||
|
if req.RealnameLinkTemplate != nil {
|
||||||
|
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
|
||||||
|
}
|
||||||
carrier.Creator = currentUserID
|
carrier.Creator = currentUserID
|
||||||
|
|
||||||
if err := s.carrierStore.Create(ctx, carrier); err != nil {
|
if err := s.carrierStore.Create(ctx, carrier); err != nil {
|
||||||
@@ -81,6 +87,15 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
|
|||||||
if req.Description != nil {
|
if req.Description != nil {
|
||||||
carrier.Description = *req.Description
|
carrier.Description = *req.Description
|
||||||
}
|
}
|
||||||
|
if req.RealnameLinkType != nil {
|
||||||
|
carrier.RealnameLinkType = *req.RealnameLinkType
|
||||||
|
}
|
||||||
|
if req.RealnameLinkTemplate != nil {
|
||||||
|
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
|
||||||
|
}
|
||||||
|
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
|
||||||
|
}
|
||||||
carrier.Updater = currentUserID
|
carrier.Updater = currentUserID
|
||||||
|
|
||||||
if err := s.carrierStore.Update(ctx, carrier); err != nil {
|
if err := s.carrierStore.Update(ctx, carrier); err != nil {
|
||||||
@@ -169,13 +184,15 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
|||||||
|
|
||||||
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
|
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
|
||||||
return &dto.CarrierResponse{
|
return &dto.CarrierResponse{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
CarrierCode: c.CarrierCode,
|
CarrierCode: c.CarrierCode,
|
||||||
CarrierName: c.CarrierName,
|
CarrierName: c.CarrierName,
|
||||||
CarrierType: c.CarrierType,
|
CarrierType: c.CarrierType,
|
||||||
Description: c.Description,
|
Description: c.Description,
|
||||||
Status: c.Status,
|
RealnameLinkType: c.RealnameLinkType,
|
||||||
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
RealnameLinkTemplate: c.RealnameLinkTemplate,
|
||||||
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
|
Status: c.Status,
|
||||||
|
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
761
internal/service/client_auth/service.go
Normal file
761
internal/service/client_auth/service.go
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
// Package client_auth 提供 C 端认证业务逻辑
|
||||||
|
// 包含资产验证、微信登录、手机号绑定与退出登录等能力
|
||||||
|
package client_auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
||||||
|
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
assetTypeIotCard = "iot_card"
|
||||||
|
assetTypeDevice = "device"
|
||||||
|
|
||||||
|
appTypeOfficialAccount = "official_account"
|
||||||
|
appTypeMiniapp = "miniapp"
|
||||||
|
|
||||||
|
assetTokenExpireSeconds = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
var identifierRegex = regexp.MustCompile(`^[A-Za-z0-9-]{1,50}$`)
|
||||||
|
|
||||||
|
// Service C 端认证服务
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
openidStore *postgres.PersonalCustomerOpenIDStore
|
||||||
|
customerStore *postgres.PersonalCustomerStore
|
||||||
|
deviceBindStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
phoneStore *postgres.PersonalCustomerPhoneStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
wechatConfigService *wechatConfigSvc.Service
|
||||||
|
verificationService *verification.Service
|
||||||
|
jwtManager *auth.JWTManager
|
||||||
|
redis *redis.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
wechatCache kernel.CacheInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建 C 端认证服务实例
|
||||||
|
func New(
|
||||||
|
db *gorm.DB,
|
||||||
|
openidStore *postgres.PersonalCustomerOpenIDStore,
|
||||||
|
customerStore *postgres.PersonalCustomerStore,
|
||||||
|
deviceBindStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
phoneStore *postgres.PersonalCustomerPhoneStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
wechatConfigService *wechatConfigSvc.Service,
|
||||||
|
verificationService *verification.Service,
|
||||||
|
jwtManager *auth.JWTManager,
|
||||||
|
redisClient *redis.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
openidStore: openidStore,
|
||||||
|
customerStore: customerStore,
|
||||||
|
deviceBindStore: deviceBindStore,
|
||||||
|
phoneStore: phoneStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
|
verificationService: verificationService,
|
||||||
|
jwtManager: jwtManager,
|
||||||
|
redis: redisClient,
|
||||||
|
logger: logger,
|
||||||
|
wechatCache: wechat.NewRedisCache(redisClient),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetTokenClaims struct {
|
||||||
|
AssetType string `json:"asset_type"`
|
||||||
|
AssetID uint `json:"asset_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAsset A1 验证资产并签发短期资产令牌
|
||||||
|
func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, clientIP string) (*dto.VerifyAssetResponse, error) {
|
||||||
|
if req == nil || !identifierRegex.MatchString(req.Identifier) {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.checkAssetVerifyRateLimit(ctx, clientIP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
assetType, assetID, err := s.resolveAsset(ctx, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
assetToken, err := s.signAssetToken(assetType, assetID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("签发资产令牌失败", zap.Error(err))
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "签发资产令牌失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.VerifyAssetResponse{
|
||||||
|
AssetToken: assetToken,
|
||||||
|
ExpiresIn: assetTokenExpireSeconds,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatLogin A2 公众号登录
|
||||||
|
func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
assetClaims, err := s.verifyAssetToken(req.AssetToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if wechatConfig == nil {
|
||||||
|
return nil, errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("创建公众号实例失败", zap.Error(err))
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "微信公众号配置不可用")
|
||||||
|
}
|
||||||
|
oaService := wechat.NewOfficialAccountService(oaApp, s.logger)
|
||||||
|
|
||||||
|
userInfo, err := oaService.GetUserInfoDetailed(ctx, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, isNewUser, err := s.loginByOpenID(
|
||||||
|
ctx,
|
||||||
|
assetClaims.AssetType,
|
||||||
|
assetClaims.AssetID,
|
||||||
|
wechatConfig.OaAppID,
|
||||||
|
userInfo.OpenID,
|
||||||
|
userInfo.UnionID,
|
||||||
|
userInfo.Nickname,
|
||||||
|
userInfo.Avatar,
|
||||||
|
appTypeOfficialAccount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, needBindPhone, err := s.issueLoginToken(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("公众号登录成功",
|
||||||
|
zap.Uint("customer_id", customerID),
|
||||||
|
zap.String("client_ip", clientIP),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &dto.WechatLoginResponse{
|
||||||
|
Token: token,
|
||||||
|
NeedBindPhone: needBindPhone,
|
||||||
|
IsNewUser: isNewUser,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniappLogin A3 小程序登录
|
||||||
|
func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
assetClaims, err := s.verifyAssetToken(req.AssetToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if wechatConfig == nil {
|
||||||
|
return nil, errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("创建小程序服务失败", zap.Error(err))
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "小程序配置不可用")
|
||||||
|
}
|
||||||
|
|
||||||
|
openID, unionID, _, err := miniService.Code2Session(ctx, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, isNewUser, err := s.loginByOpenID(
|
||||||
|
ctx,
|
||||||
|
assetClaims.AssetType,
|
||||||
|
assetClaims.AssetID,
|
||||||
|
wechatConfig.MiniappAppID,
|
||||||
|
openID,
|
||||||
|
unionID,
|
||||||
|
req.Nickname,
|
||||||
|
req.AvatarURL,
|
||||||
|
appTypeMiniapp,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, needBindPhone, err := s.issueLoginToken(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("小程序登录成功",
|
||||||
|
zap.Uint("customer_id", customerID),
|
||||||
|
zap.String("client_ip", clientIP),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &dto.WechatLoginResponse{
|
||||||
|
Token: token,
|
||||||
|
NeedBindPhone: needBindPhone,
|
||||||
|
IsNewUser: isNewUser,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCode A4 发送验证码
|
||||||
|
func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, clientIP string) (*dto.ClientSendCodeResponse, error) {
|
||||||
|
if req == nil || req.Phone == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.checkSendCodeRateLimit(ctx, req.Phone, clientIP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.verificationService.SendCode(ctx, req.Phone); err != nil {
|
||||||
|
s.logger.Error("发送验证码失败", zap.String("phone", req.Phone), zap.Error(err))
|
||||||
|
return nil, errors.Wrap(errors.CodeSmsSendFailed, err, "发送验证码失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
cooldownKey := constants.RedisClientSendCodePhoneLimitKey(req.Phone)
|
||||||
|
if err := s.redis.Set(ctx, cooldownKey, "1", 60*time.Second).Err(); err != nil {
|
||||||
|
s.logger.Error("设置验证码冷却键失败", zap.String("phone", req.Phone), zap.Error(err))
|
||||||
|
return nil, errors.Wrap(errors.CodeRedisError, err, "设置验证码冷却失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ClientSendCodeResponse{CooldownSeconds: 60}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindPhone A5 绑定手机号
|
||||||
|
func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
|
||||||
|
return nil, errors.New(errors.CodeAlreadyBoundPhone)
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
|
||||||
|
if existed.CustomerID != customerID {
|
||||||
|
return nil, errors.New(errors.CodePhoneAlreadyBound)
|
||||||
|
}
|
||||||
|
return nil, errors.New(errors.CodeAlreadyBoundPhone)
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := &model.PersonalCustomerPhone{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Phone: req.Phone,
|
||||||
|
IsPrimary: true,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := s.phoneStore.Create(ctx, record); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.BindPhoneResponse{
|
||||||
|
Phone: req.Phone,
|
||||||
|
BoundAt: record.VerifiedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePhone A6 换绑手机号
|
||||||
|
func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeOldPhoneMismatch)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if primary.Phone != req.OldPhone {
|
||||||
|
return nil, errors.New(errors.CodeOldPhoneMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||||
|
}
|
||||||
|
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
|
||||||
|
return nil, errors.New(errors.CodePhoneAlreadyBound)
|
||||||
|
} else if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
|
||||||
|
Where("id = ? AND customer_id = ?", primary.ID, customerID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"phone": req.NewPhone,
|
||||||
|
"verified_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ChangePhoneResponse{
|
||||||
|
Phone: req.NewPhone,
|
||||||
|
ChangedAt: now.Format("2006-01-02 15:04:05"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout A7 退出登录
|
||||||
|
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
|
||||||
|
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||||
|
if err := s.redis.Del(ctx, redisKey).Err(); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeRedisError, err, "退出登录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LogoutResponse{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string) error {
|
||||||
|
if clientIP == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := constants.RedisClientAuthRateLimitIPKey(clientIP)
|
||||||
|
count, err := s.redis.Incr(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, err, "校验资产限流失败")
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
if expErr := s.redis.Expire(ctx, key, 60*time.Second).Err(); expErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, expErr, "设置资产限流过期时间失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 30 {
|
||||||
|
return errors.New(errors.CodeTooManyRequests)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, error) {
|
||||||
|
var card model.IotCard
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("iccid = ?", identifier).
|
||||||
|
First(&card).Error; err == nil {
|
||||||
|
return assetTypeIotCard, card.ID, nil
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var device model.Device
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("virtual_no = ? OR imei = ?", identifier, identifier).
|
||||||
|
First(&device).Error; err == nil {
|
||||||
|
return assetTypeDevice, device.ID, nil
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := &assetTokenClaims{
|
||||||
|
AssetType: assetType,
|
||||||
|
AssetID: assetID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(viper.GetString("jwt.secret_key") + ":asset"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) {
|
||||||
|
if assetToken == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New(errors.CodeInvalidToken)
|
||||||
|
}
|
||||||
|
return []byte(viper.GetString("jwt.secret_key") + ":asset"), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := parsed.Claims.(*assetTokenClaims)
|
||||||
|
if !ok || !parsed.Valid || claims.AssetID == 0 || claims.AssetType == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loginByOpenID(
|
||||||
|
ctx context.Context,
|
||||||
|
assetType string,
|
||||||
|
assetID uint,
|
||||||
|
appID string,
|
||||||
|
openID string,
|
||||||
|
unionID string,
|
||||||
|
nickname string,
|
||||||
|
avatar string,
|
||||||
|
appType string,
|
||||||
|
) (uint, bool, error) {
|
||||||
|
var (
|
||||||
|
customerID uint
|
||||||
|
isNewUser bool
|
||||||
|
)
|
||||||
|
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
|
||||||
|
if findErr != nil {
|
||||||
|
return findErr
|
||||||
|
}
|
||||||
|
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
|
||||||
|
return bindErr
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID = cid
|
||||||
|
isNewUser = created
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return customerID, isNewUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateCustomer 根据 OpenID/UnionID 查找或创建客户
|
||||||
|
func (s *Service) findOrCreateCustomer(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
appID string,
|
||||||
|
openID string,
|
||||||
|
unionID string,
|
||||||
|
nickname string,
|
||||||
|
avatar string,
|
||||||
|
appType string,
|
||||||
|
) (uint, bool, error) {
|
||||||
|
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
|
||||||
|
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
|
||||||
|
|
||||||
|
if existed, err := openidStore.FindByAppIDAndOpenID(ctx, appID, openID); err == nil {
|
||||||
|
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
|
||||||
|
if getErr != nil {
|
||||||
|
if getErr == gorm.ErrRecordNotFound {
|
||||||
|
return 0, false, errors.New(errors.CodeCustomerNotFound)
|
||||||
|
}
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||||
|
}
|
||||||
|
if customer.Status == 0 {
|
||||||
|
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
if nickname != "" && customer.Nickname != nickname {
|
||||||
|
customer.Nickname = nickname
|
||||||
|
}
|
||||||
|
if avatar != "" && customer.AvatarURL != avatar {
|
||||||
|
customer.AvatarURL = avatar
|
||||||
|
}
|
||||||
|
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||||
|
}
|
||||||
|
return customer.ID, false, nil
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if unionID != "" {
|
||||||
|
if existed, err := openidStore.FindByUnionID(ctx, unionID); err == nil {
|
||||||
|
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
|
||||||
|
if getErr != nil {
|
||||||
|
if getErr == gorm.ErrRecordNotFound {
|
||||||
|
return 0, false, errors.New(errors.CodeCustomerNotFound)
|
||||||
|
}
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||||
|
}
|
||||||
|
if customer.Status == 0 {
|
||||||
|
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := &model.PersonalCustomerOpenID{
|
||||||
|
CustomerID: customer.ID,
|
||||||
|
AppID: appID,
|
||||||
|
OpenID: openID,
|
||||||
|
UnionID: unionID,
|
||||||
|
AppType: appType,
|
||||||
|
}
|
||||||
|
if createErr := openidStore.Create(ctx, record); createErr != nil {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if nickname != "" && customer.Nickname != nickname {
|
||||||
|
customer.Nickname = nickname
|
||||||
|
}
|
||||||
|
if avatar != "" && customer.AvatarURL != avatar {
|
||||||
|
customer.AvatarURL = avatar
|
||||||
|
}
|
||||||
|
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return customer.ID, false, nil
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newCustomer := &model.PersonalCustomer{
|
||||||
|
WxOpenID: openID,
|
||||||
|
WxUnionID: unionID,
|
||||||
|
Nickname: nickname,
|
||||||
|
AvatarURL: avatar,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := customerStore.Create(ctx, newCustomer); err != nil {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := &model.PersonalCustomerOpenID{
|
||||||
|
CustomerID: newCustomer.ID,
|
||||||
|
AppID: appID,
|
||||||
|
OpenID: openID,
|
||||||
|
UnionID: unionID,
|
||||||
|
AppType: appType,
|
||||||
|
}
|
||||||
|
if err := openidStore.Create(ctx, record); err != nil {
|
||||||
|
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCustomer.ID, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindAsset 绑定客户与资产关系
|
||||||
|
func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
|
||||||
|
assetKey, err := s.resolveAssetBindingKey(ctx, tx, assetType, assetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var bindCount int64
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&model.PersonalCustomerDevice{}).
|
||||||
|
Where("virtual_no = ?", assetKey).
|
||||||
|
Count(&bindCount).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定关系失败")
|
||||||
|
}
|
||||||
|
firstEverBind := bindCount == 0
|
||||||
|
|
||||||
|
bindStore := postgres.NewPersonalCustomerDeviceStore(tx)
|
||||||
|
exists, err := bindStore.ExistsByCustomerAndDevice(ctx, customerID, assetKey)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
record := &model.PersonalCustomerDevice{
|
||||||
|
CustomerID: customerID,
|
||||||
|
VirtualNo: assetKey,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := bindStore.Create(ctx, record); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstEverBind {
|
||||||
|
if err := s.markAssetAsSold(ctx, tx, assetType, assetID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) {
|
||||||
|
if assetType == assetTypeIotCard {
|
||||||
|
var card model.IotCard
|
||||||
|
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return "", errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
|
||||||
|
}
|
||||||
|
return card.VirtualNo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetType == assetTypeDevice {
|
||||||
|
var device model.Device
|
||||||
|
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return "", errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return "", errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
|
||||||
|
}
|
||||||
|
if device.VirtualNo != "" {
|
||||||
|
return device.VirtualNo, nil
|
||||||
|
}
|
||||||
|
return device.IMEI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
|
||||||
|
if assetType == assetTypeIotCard {
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&model.IotCard{}).
|
||||||
|
Where("id = ? AND asset_status = ?", assetID, 1).
|
||||||
|
Update("asset_status", 2).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetType == assetTypeDevice {
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&model.Device{}).
|
||||||
|
Where("id = ? AND asset_status = ?", assetID, 1).
|
||||||
|
Update("asset_status", 2).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) issueLoginToken(ctx context.Context, customerID uint) (string, bool, error) {
|
||||||
|
token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := s.jwtManager.VerifyPersonalCustomerToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := time.Until(claims.ExpiresAt.Time)
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||||
|
if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil {
|
||||||
|
return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
needBindPhone := false
|
||||||
|
if viper.GetBool("client.require_phone_binding") {
|
||||||
|
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == gorm.ErrRecordNotFound {
|
||||||
|
needBindPhone = true
|
||||||
|
} else if err != nil {
|
||||||
|
return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, needBindPhone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error {
|
||||||
|
phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone)
|
||||||
|
exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, err, "检查手机号冷却失败")
|
||||||
|
}
|
||||||
|
if exists > 0 {
|
||||||
|
return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试")
|
||||||
|
}
|
||||||
|
|
||||||
|
ipKey := constants.RedisClientSendCodeIPHourKey(clientIP)
|
||||||
|
ipCount, err := s.redis.Incr(ctx, ipKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, err, "检查 IP 限流失败")
|
||||||
|
}
|
||||||
|
if ipCount == 1 {
|
||||||
|
if expErr := s.redis.Expire(ctx, ipKey, time.Hour).Err(); expErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, expErr, "设置 IP 限流过期时间失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ipCount > 20 {
|
||||||
|
return errors.New(errors.CodeTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
phoneDayKey := constants.RedisClientSendCodePhoneDayKey(phone)
|
||||||
|
phoneDayCount, err := s.redis.Incr(ctx, phoneDayKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, err, "检查手机号日限流失败")
|
||||||
|
}
|
||||||
|
if phoneDayCount == 1 {
|
||||||
|
nextDay := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour)
|
||||||
|
ttl := time.Until(nextDay)
|
||||||
|
if expErr := s.redis.Expire(ctx, phoneDayKey, ttl).Err(); expErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeRedisError, expErr, "设置手机号日限流过期时间失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if phoneDayCount > 10 {
|
||||||
|
return errors.New(errors.CodeTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
701
internal/service/client_order/service.go
Normal file
701
internal/service/client_order/service.go
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
// Package client_order 提供 C 端订单下单服务。
|
||||||
|
package client_order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||||
|
"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/wechat"
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
clientPurchaseIdempotencyTTL = 5 * time.Minute
|
||||||
|
clientPurchaseLockTTL = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// WechatConfigServiceInterface 微信配置服务接口。
|
||||||
|
type WechatConfigServiceInterface interface {
|
||||||
|
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceRechargeRequirement 强充要求。
|
||||||
|
type ForceRechargeRequirement struct {
|
||||||
|
NeedForceRecharge bool
|
||||||
|
ForceRechargeAmount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 客户端订单服务。
|
||||||
|
type Service struct {
|
||||||
|
assetService *asset.Service
|
||||||
|
purchaseValidationService *purchase_validation.Service
|
||||||
|
orderStore *postgres.OrderStore
|
||||||
|
rechargeRecordStore *postgres.AssetRechargeStore
|
||||||
|
walletStore *postgres.AssetWalletStore
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
openIDStore *postgres.PersonalCustomerOpenIDStore
|
||||||
|
wechatConfigService WechatConfigServiceInterface
|
||||||
|
packageSeriesStore *postgres.PackageSeriesStore
|
||||||
|
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||||
|
redis *redis.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建客户端订单服务。
|
||||||
|
func New(
|
||||||
|
assetService *asset.Service,
|
||||||
|
purchaseValidationService *purchase_validation.Service,
|
||||||
|
orderStore *postgres.OrderStore,
|
||||||
|
rechargeRecordStore *postgres.AssetRechargeStore,
|
||||||
|
walletStore *postgres.AssetWalletStore,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
openIDStore *postgres.PersonalCustomerOpenIDStore,
|
||||||
|
wechatConfigService WechatConfigServiceInterface,
|
||||||
|
packageSeriesStore *postgres.PackageSeriesStore,
|
||||||
|
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||||
|
redisClient *redis.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
assetService: assetService,
|
||||||
|
purchaseValidationService: purchaseValidationService,
|
||||||
|
orderStore: orderStore,
|
||||||
|
rechargeRecordStore: rechargeRecordStore,
|
||||||
|
walletStore: walletStore,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
openIDStore: openIDStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
|
packageSeriesStore: packageSeriesStore,
|
||||||
|
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||||
|
redis: redisClient,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder 创建客户端订单。
|
||||||
|
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if s.redis == nil {
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "Redis 服务未配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := s.assetService.Resolve(skipPermissionCtx, strings.TrimSpace(req.Identifier))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.checkAssetOwnership(skipPermissionCtx, customerID, assetInfo.VirtualNo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
validationResult, err := s.validatePurchase(skipPermissionCtx, assetInfo, req.PackageIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
|
||||||
|
return nil, errors.New(errors.CodeNeedRealname)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeConfig, appID, err := s.resolveWechatConfig(skipPermissionCtx, req.AppType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
openID, err := s.resolveCustomerOpenID(skipPermissionCtx, customerID, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req)
|
||||||
|
redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey)
|
||||||
|
lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
|
||||||
|
lockAcquired, err := s.redis.SetNX(skipPermissionCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("获取客户端购买分布式锁失败,继续尝试幂等标记",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("lock_key", lockKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err == nil && !lockAcquired {
|
||||||
|
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, err := s.redis.SetNX(skipPermissionCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result()
|
||||||
|
if err != nil {
|
||||||
|
if lockAcquired {
|
||||||
|
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败")
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
if lockAcquired {
|
||||||
|
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
|
||||||
|
}
|
||||||
|
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
|
||||||
|
}
|
||||||
|
|
||||||
|
created := false
|
||||||
|
defer func() {
|
||||||
|
if lockAcquired {
|
||||||
|
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
_ = s.redis.Del(skipPermissionCtx, redisKey).Err()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
paymentService, err := s.newPaymentService(activeConfig, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
forceRecharge := s.checkForceRechargeRequirement(skipPermissionCtx, validationResult)
|
||||||
|
if forceRecharge.NeedForceRecharge {
|
||||||
|
return s.createForceRechargeOrder(skipPermissionCtx, customerID, appID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentService, &created)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
|
||||||
|
owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if owned {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) {
|
||||||
|
switch assetInfo.AssetType {
|
||||||
|
case "card":
|
||||||
|
return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs)
|
||||||
|
case constants.ResourceTypeDevice:
|
||||||
|
return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs)
|
||||||
|
default:
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) {
|
||||||
|
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败")
|
||||||
|
}
|
||||||
|
if activeConfig == nil {
|
||||||
|
return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch appType {
|
||||||
|
case "official_account":
|
||||||
|
if activeConfig.OaAppID == "" {
|
||||||
|
return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整")
|
||||||
|
}
|
||||||
|
return activeConfig, activeConfig.OaAppID, nil
|
||||||
|
case "miniapp":
|
||||||
|
if activeConfig.MiniappAppID == "" {
|
||||||
|
return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整")
|
||||||
|
}
|
||||||
|
return activeConfig, activeConfig.MiniappAppID, nil
|
||||||
|
default:
|
||||||
|
return nil, "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) {
|
||||||
|
records, err := s.openIDStore.ListByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.AppID == appID && strings.TrimSpace(record.OpenID) != "" {
|
||||||
|
return record.OpenID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) newPaymentService(wechatConfig *model.WechatConfig, appID string) (*wechat.PaymentService, error) {
|
||||||
|
cache := wechat.NewRedisCache(s.redis)
|
||||||
|
paymentApp, err := wechat.NewPaymentAppFromConfig(wechatConfig, appID, cache, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败")
|
||||||
|
}
|
||||||
|
return wechat.NewPaymentService(paymentApp, s.logger), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createPackageOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
customerID uint,
|
||||||
|
appID string,
|
||||||
|
openID string,
|
||||||
|
validationResult *purchase_validation.PurchaseValidationResult,
|
||||||
|
activeConfig *model.WechatConfig,
|
||||||
|
redisKey string,
|
||||||
|
paymentService *wechat.PaymentService,
|
||||||
|
created *bool,
|
||||||
|
) (*dto.ClientCreateOrderResponse, error) {
|
||||||
|
order, err := s.buildPendingOrder(customerID, validationResult, activeConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.buildOrderItems(ctx, customerID, validationResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.orderStore.Create(ctx, order, items); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
|
||||||
|
*created = true
|
||||||
|
|
||||||
|
description := "套餐购买"
|
||||||
|
if len(items) > 0 && items[0] != nil && items[0].PackageName != "" {
|
||||||
|
description = items[0].PackageName
|
||||||
|
}
|
||||||
|
|
||||||
|
payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ClientCreateOrderResponse{
|
||||||
|
OrderType: "package",
|
||||||
|
Order: &dto.ClientOrderInfo{
|
||||||
|
OrderID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
TotalAmount: order.TotalAmount,
|
||||||
|
PaymentStatus: orderStatusToClientStatus(order.PaymentStatus),
|
||||||
|
CreatedAt: formatClientServiceTime(order.CreatedAt),
|
||||||
|
},
|
||||||
|
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createForceRechargeOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
customerID uint,
|
||||||
|
appID string,
|
||||||
|
openID string,
|
||||||
|
assetInfo *dto.AssetResolveResponse,
|
||||||
|
validationResult *purchase_validation.PurchaseValidationResult,
|
||||||
|
activeConfig *model.WechatConfig,
|
||||||
|
forceRecharge *ForceRechargeRequirement,
|
||||||
|
redisKey string,
|
||||||
|
paymentService *wechat.PaymentService,
|
||||||
|
created *bool,
|
||||||
|
) (*dto.ClientCreateOrderResponse, error) {
|
||||||
|
resourceType, resourceID, err := resolveWalletResource(validationResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化关联套餐失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
carrierID := resourceID
|
||||||
|
recharge := &model.AssetRechargeRecord{
|
||||||
|
UserID: customerID,
|
||||||
|
AssetWalletID: wallet.ID,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
RechargeNo: generateClientRechargeNo(),
|
||||||
|
Amount: forceRecharge.ForceRechargeAmount,
|
||||||
|
PaymentMethod: model.PaymentMethodWechat,
|
||||||
|
PaymentConfigID: &activeConfig.ID,
|
||||||
|
Status: 1,
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
OperatorType: "personal_customer",
|
||||||
|
Generation: resolveGeneration(validationResult),
|
||||||
|
LinkedPackageIDs: datatypes.JSON(linkedPackageIDs),
|
||||||
|
LinkedOrderType: resolveOrderType(validationResult),
|
||||||
|
LinkedCarrierType: assetInfo.AssetType,
|
||||||
|
LinkedCarrierID: &carrierID,
|
||||||
|
AutoPurchaseStatus: constants.AutoPurchaseStatusPending,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.rechargeRecordStore.Create(ctx, recharge); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo)
|
||||||
|
*created = true
|
||||||
|
|
||||||
|
payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ClientCreateOrderResponse{
|
||||||
|
OrderType: "recharge",
|
||||||
|
Recharge: &dto.ClientRechargeInfo{
|
||||||
|
RechargeID: recharge.ID,
|
||||||
|
RechargeNo: recharge.RechargeNo,
|
||||||
|
Amount: recharge.Amount,
|
||||||
|
Status: rechargeStatusToClientStatus(recharge.Status),
|
||||||
|
AutoPurchaseStatus: recharge.AutoPurchaseStatus,
|
||||||
|
},
|
||||||
|
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
|
||||||
|
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig) (*model.Order, error) {
|
||||||
|
orderType := resolveOrderType(result)
|
||||||
|
if orderType == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(constants.OrderExpireTimeout)
|
||||||
|
order := &model.Order{
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: customerID,
|
||||||
|
Updater: customerID,
|
||||||
|
},
|
||||||
|
OrderNo: s.orderStore.GenerateOrderNo(),
|
||||||
|
OrderType: orderType,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: customerID,
|
||||||
|
TotalAmount: result.TotalPrice,
|
||||||
|
PaymentMethod: model.PaymentMethodWechat,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
CommissionStatus: model.CommissionStatusPending,
|
||||||
|
CommissionConfigVersion: 0,
|
||||||
|
Source: constants.OrderSourceClient,
|
||||||
|
Generation: resolveGeneration(result),
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
PaymentConfigID: &activeConfig.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Card != nil {
|
||||||
|
order.IotCardID = &result.Card.ID
|
||||||
|
order.SeriesID = result.Card.SeriesID
|
||||||
|
order.SellerShopID = result.Card.ShopID
|
||||||
|
} else if result.Device != nil {
|
||||||
|
order.DeviceID = &result.Device.ID
|
||||||
|
order.SeriesID = result.Device.SeriesID
|
||||||
|
order.SellerShopID = result.Device.ShopID
|
||||||
|
}
|
||||||
|
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) {
|
||||||
|
sellerShopID := resolveSellerShopID(result)
|
||||||
|
items := make([]*model.OrderItem, 0, len(result.Packages))
|
||||||
|
|
||||||
|
for _, pkg := range result.Packages {
|
||||||
|
if pkg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unitPrice, err := s.purchaseValidationService.GetPurchasePrice(ctx, pkg, sellerShopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, &model.OrderItem{
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: customerID,
|
||||||
|
Updater: customerID,
|
||||||
|
},
|
||||||
|
PackageID: pkg.ID,
|
||||||
|
PackageName: pkg.PackageName,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
Amount: unitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
|
||||||
|
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
|
||||||
|
|
||||||
|
var seriesID *uint
|
||||||
|
var sellerShopID uint
|
||||||
|
if result.Card != nil {
|
||||||
|
seriesID = result.Card.SeriesID
|
||||||
|
if result.Card.ShopID != nil {
|
||||||
|
sellerShopID = *result.Card.ShopID
|
||||||
|
}
|
||||||
|
} else if result.Device != nil {
|
||||||
|
seriesID = result.Device.SeriesID
|
||||||
|
if result.Device.ShopID != nil {
|
||||||
|
sellerShopID = *result.Device.ShopID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if seriesID == nil || *seriesID == 0 {
|
||||||
|
return defaultResult
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err))
|
||||||
|
return defaultResult
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := series.GetOneTimeCommissionConfig()
|
||||||
|
if err != nil || config == nil || !config.Enable {
|
||||||
|
return defaultResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
|
||||||
|
return &ForceRechargeRequirement{
|
||||||
|
NeedForceRecharge: true,
|
||||||
|
ForceRechargeAmount: config.Threshold,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.EnableForceRecharge {
|
||||||
|
amount := config.ForceAmount
|
||||||
|
if amount == 0 {
|
||||||
|
amount = config.Threshold
|
||||||
|
}
|
||||||
|
return &ForceRechargeRequirement{
|
||||||
|
NeedForceRecharge: true,
|
||||||
|
ForceRechargeAmount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sellerShopID > 0 {
|
||||||
|
allocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID)
|
||||||
|
if allocErr == nil && allocation.EnableForceRecharge {
|
||||||
|
amount := allocation.ForceRechargeAmount
|
||||||
|
if amount == 0 {
|
||||||
|
amount = config.Threshold
|
||||||
|
}
|
||||||
|
return &ForceRechargeRequirement{
|
||||||
|
NeedForceRecharge: true,
|
||||||
|
ForceRechargeAmount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markClientPurchaseCreated(ctx context.Context, redisKey string, value string) {
|
||||||
|
if err := s.redis.Set(ctx, redisKey, value, clientPurchaseIdempotencyTTL).Err(); err != nil {
|
||||||
|
s.logger.Warn("设置客户端购买幂等标记失败",
|
||||||
|
zap.String("redis_key", redisKey),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult, forceRecharge *ForceRechargeRequirement) *dto.LinkedPackageInfo {
|
||||||
|
packageNames := make([]string, 0, len(result.Packages))
|
||||||
|
for _, pkg := range result.Packages {
|
||||||
|
if pkg == nil || pkg.PackageName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
packageNames = append(packageNames, pkg.PackageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LinkedPackageInfo{
|
||||||
|
PackageNames: packageNames,
|
||||||
|
TotalPackageAmount: result.TotalPrice,
|
||||||
|
ForceRechargeAmount: forceRecharge.ForceRechargeAmount,
|
||||||
|
WalletCredit: forceRecharge.ForceRechargeAmount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientPayConfig(appID string, payConfig any) *dto.ClientPayConfig {
|
||||||
|
configMap, _ := payConfig.(map[string]any)
|
||||||
|
if configMap == nil {
|
||||||
|
configMap = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ClientPayConfig{
|
||||||
|
AppID: firstNonEmpty(stringFromAny(configMap["appId"]), appID),
|
||||||
|
Timestamp: firstNonEmpty(stringFromAny(configMap["timeStamp"]), stringFromAny(configMap["timestamp"])),
|
||||||
|
NonceStr: stringFromAny(configMap["nonceStr"]),
|
||||||
|
PackageVal: stringFromAny(configMap["package"]),
|
||||||
|
SignType: stringFromAny(configMap["signType"]),
|
||||||
|
PaySign: stringFromAny(configMap["paySign"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWalletResource(result *purchase_validation.PurchaseValidationResult) (string, uint, error) {
|
||||||
|
if result.Card != nil {
|
||||||
|
return constants.AssetWalletResourceTypeIotCard, result.Card.ID, nil
|
||||||
|
}
|
||||||
|
if result.Device != nil {
|
||||||
|
return constants.AssetWalletResourceTypeDevice, result.Device.ID, nil
|
||||||
|
}
|
||||||
|
return "", 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOrderType(result *purchase_validation.PurchaseValidationResult) string {
|
||||||
|
if result.Card != nil {
|
||||||
|
return model.OrderTypeSingleCard
|
||||||
|
}
|
||||||
|
if result.Device != nil {
|
||||||
|
return model.OrderTypeDevice
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGeneration(result *purchase_validation.PurchaseValidationResult) int {
|
||||||
|
if result.Card != nil && result.Card.Generation > 0 {
|
||||||
|
return result.Card.Generation
|
||||||
|
}
|
||||||
|
if result.Device != nil && result.Device.Generation > 0 {
|
||||||
|
return result.Device.Generation
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSellerShopID(result *purchase_validation.PurchaseValidationResult) uint {
|
||||||
|
if result.Card != nil && result.Card.ShopID != nil {
|
||||||
|
return *result.Card.ShopID
|
||||||
|
}
|
||||||
|
if result.Device != nil && result.Device.ShopID != nil {
|
||||||
|
return *result.Device.ShopID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func packagesNeedRealname(packages []*model.Package) bool {
|
||||||
|
for _, pkg := range packages {
|
||||||
|
if pkg != nil && pkg.EnableRealnameActivation {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPackageIDs(packages []*model.Package) []uint {
|
||||||
|
ids := make([]uint, 0, len(packages))
|
||||||
|
for _, pkg := range packages {
|
||||||
|
if pkg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, pkg.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, req *dto.ClientCreateOrderRequest) string {
|
||||||
|
packageIDs := make([]uint, 0, len(req.PackageIDs))
|
||||||
|
packageIDs = append(packageIDs, req.PackageIDs...)
|
||||||
|
slices.Sort(packageIDs)
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(packageIDs))
|
||||||
|
for _, packageID := range packageIDs {
|
||||||
|
parts = append(parts, strconv.FormatUint(uint64(packageID), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, req.AppType, strings.Join(parts, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderStatusToClientStatus(status int) int {
|
||||||
|
switch status {
|
||||||
|
case model.PaymentStatusPending:
|
||||||
|
return 0
|
||||||
|
case model.PaymentStatusPaid:
|
||||||
|
return 1
|
||||||
|
case model.PaymentStatusCancelled:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rechargeStatusToClientStatus(status int) int {
|
||||||
|
switch status {
|
||||||
|
case 1:
|
||||||
|
return 0
|
||||||
|
case 2, 3:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClientServiceTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateClientRechargeNo() string {
|
||||||
|
return fmt.Sprintf("CRCH%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromAny(value any) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"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/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -201,7 +202,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
|
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
|
||||||
if order.IsPurchaseOnBehalf {
|
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
|
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
|
||||||
if order.IsPurchaseOnBehalf {
|
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
// Package customer 提供客户管理的业务逻辑服务
|
|
||||||
// 包含客户信息管理、客户查询等功能
|
|
||||||
package customer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Service 个人客户业务服务
|
|
||||||
type Service struct {
|
|
||||||
customerStore *postgres.PersonalCustomerStore
|
|
||||||
}
|
|
||||||
|
|
||||||
// New 创建个人客户服务
|
|
||||||
func New(customerStore *postgres.PersonalCustomerStore) *Service {
|
|
||||||
return &Service{
|
|
||||||
customerStore: customerStore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create 创建个人客户
|
|
||||||
func (s *Service) Create(ctx context.Context, req *dto.CreatePersonalCustomerRequest) (*model.PersonalCustomer, error) {
|
|
||||||
// 检查手机号唯一性
|
|
||||||
if req.Phone != "" {
|
|
||||||
existing, err := s.customerStore.GetByPhone(ctx, req.Phone)
|
|
||||||
if err == nil && existing != nil {
|
|
||||||
return nil, errors.New(errors.CodeCustomerPhoneExists, "手机号已存在")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建个人客户
|
|
||||||
// 注意:根据新的数据模型,手机号应该存储在 PersonalCustomerPhone 表中
|
|
||||||
// 这里暂时先创建客户记录,手机号的存储后续通过 PersonalCustomerPhoneStore 实现
|
|
||||||
customer := &model.PersonalCustomer{
|
|
||||||
Nickname: req.Nickname,
|
|
||||||
AvatarURL: req.AvatarURL,
|
|
||||||
WxOpenID: req.WxOpenID,
|
|
||||||
WxUnionID: req.WxUnionID,
|
|
||||||
Status: constants.StatusEnabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.customerStore.Create(ctx, customer); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 创建 PersonalCustomerPhone 记录,需要通过 PersonalCustomerPhoneStore 创建手机号关联
|
|
||||||
|
|
||||||
return customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update 更新个人客户信息
|
|
||||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePersonalCustomerRequest) (*model.PersonalCustomer, error) {
|
|
||||||
// 查询客户
|
|
||||||
customer, err := s.customerStore.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 手机号的更新逻辑需要通过 PersonalCustomerPhoneStore 更新或创建手机号记录
|
|
||||||
|
|
||||||
// 更新字段
|
|
||||||
if req.Nickname != nil {
|
|
||||||
customer.Nickname = *req.Nickname
|
|
||||||
}
|
|
||||||
if req.AvatarURL != nil {
|
|
||||||
customer.AvatarURL = *req.AvatarURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.customerStore.Update(ctx, customer); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWeChat 绑定微信信息
|
|
||||||
func (s *Service) BindWeChat(ctx context.Context, id uint, wxOpenID, wxUnionID string) error {
|
|
||||||
customer, err := s.customerStore.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
customer.WxOpenID = wxOpenID
|
|
||||||
customer.WxUnionID = wxUnionID
|
|
||||||
|
|
||||||
return s.customerStore.Update(ctx, customer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID 获取个人客户详情
|
|
||||||
func (s *Service) GetByID(ctx context.Context, id uint) (*model.PersonalCustomer, error) {
|
|
||||||
customer, err := s.customerStore.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
|
|
||||||
}
|
|
||||||
return customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByPhone 根据手机号获取个人客户
|
|
||||||
func (s *Service) GetByPhone(ctx context.Context, phone string) (*model.PersonalCustomer, error) {
|
|
||||||
customer, err := s.customerStore.GetByPhone(ctx, phone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
|
|
||||||
}
|
|
||||||
return customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByWxOpenID 根据微信 OpenID 获取个人客户
|
|
||||||
func (s *Service) GetByWxOpenID(ctx context.Context, wxOpenID string) (*model.PersonalCustomer, error) {
|
|
||||||
customer, err := s.customerStore.GetByWxOpenID(ctx, wxOpenID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
|
|
||||||
}
|
|
||||||
return customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List 查询个人客户列表
|
|
||||||
func (s *Service) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.PersonalCustomer, int64, error) {
|
|
||||||
return s.customerStore.List(ctx, opts, filters)
|
|
||||||
}
|
|
||||||
@@ -973,7 +973,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
|
|||||||
|
|
||||||
// 全部失败时返回 error
|
// 全部失败时返回 error
|
||||||
if successCount == 0 && lastErr != nil {
|
if successCount == 0 && lastErr != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败")
|
return errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
243
internal/service/exchange/migration.go
Normal file
243
internal/service/exchange/migration.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) executeMigration(ctx context.Context, order *model.ExchangeOrder) (int64, error) {
|
||||||
|
var migrationBalance int64
|
||||||
|
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if order.NewAssetID == nil || *order.NewAssetID == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "新资产信息缺失")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, order.OldAssetIdentifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, order.NewAssetIdentifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationBalance, err = s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldAsset.VirtualNo != "" && newAsset.VirtualNo != "" {
|
||||||
|
if err = tx.Model(&model.PersonalCustomerDevice{}).
|
||||||
|
Where("virtual_no = ?", oldAsset.VirtualNo).
|
||||||
|
Updates(map[string]any{"virtual_no": newAsset.VirtualNo, "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新客户绑定关系失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.updateOldAssetStatusWithTx(tx, oldAsset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", order.ID).Updates(map[string]any{
|
||||||
|
"migration_completed": true,
|
||||||
|
"migration_balance": migrationBalance,
|
||||||
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新换货单迁移状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行全量迁移失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrationBalance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
|
||||||
|
var oldWallet model.AssetWallet
|
||||||
|
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).First(&oldWallet).Error; err != nil {
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询旧资产钱包失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newWallet model.AssetWallet
|
||||||
|
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", newAsset.AssetType, newAsset.AssetID).First(&newWallet).Error; err != nil {
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopTag := uint(0)
|
||||||
|
if newAsset.ShopID != nil {
|
||||||
|
shopTag = *newAsset.ShopID
|
||||||
|
}
|
||||||
|
newWallet = model.AssetWallet{ResourceType: newAsset.AssetType, ResourceID: newAsset.AssetID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}
|
||||||
|
if err = tx.WithContext(ctx).Create(&newWallet).Error; err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "创建新资产钱包失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationBalance := oldWallet.Balance
|
||||||
|
if migrationBalance <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeBalance := newWallet.Balance
|
||||||
|
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", oldWallet.ID).Updates(map[string]any{"balance": 0, "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产钱包余额失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", newWallet.ID).Updates(map[string]any{"balance": gorm.Expr("balance + ?", migrationBalance), "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "增加新资产钱包余额失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
refType := "exchange"
|
||||||
|
if err := tx.WithContext(ctx).Create(&model.AssetWalletTransaction{
|
||||||
|
AssetWalletID: newWallet.ID,
|
||||||
|
ResourceType: newAsset.AssetType,
|
||||||
|
ResourceID: newAsset.AssetID,
|
||||||
|
UserID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
TransactionType: "refund",
|
||||||
|
Amount: migrationBalance,
|
||||||
|
BalanceBefore: beforeBalance,
|
||||||
|
BalanceAfter: beforeBalance + migrationBalance,
|
||||||
|
Status: 1,
|
||||||
|
ReferenceType: &refType,
|
||||||
|
ReferenceNo: &order.ExchangeNo,
|
||||||
|
Creator: middleware.GetUserIDFromContext(ctx),
|
||||||
|
ShopIDTag: newWallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: newWallet.EnterpriseIDTag,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "写入迁移钱包流水失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrationBalance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
|
||||||
|
query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||||
|
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||||
|
query = query.Where("iot_card_id = ?", oldAsset.AssetID)
|
||||||
|
} else {
|
||||||
|
query = query.Where("device_id = ?", oldAsset.AssetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var usageIDs []uint
|
||||||
|
if err := query.Pluck("id", &usageIDs).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(usageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{"updated_at": time.Now()}
|
||||||
|
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||||
|
updates["iot_card_id"] = newAsset.AssetID
|
||||||
|
} else {
|
||||||
|
updates["device_id"] = newAsset.AssetID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
|
||||||
|
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||||
|
if oldAsset.Card == nil {
|
||||||
|
return errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.IotCard{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{
|
||||||
|
"accumulated_recharge": oldAsset.Card.AccumulatedRecharge,
|
||||||
|
"first_commission_paid": oldAsset.Card.FirstCommissionPaid,
|
||||||
|
"accumulated_recharge_by_series": oldAsset.Card.AccumulatedRechargeBySeriesJSON,
|
||||||
|
"first_recharge_triggered_by_series": oldAsset.Card.FirstRechargeTriggeredBySeriesJSON,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "复制旧卡累计字段失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldAsset.Device == nil {
|
||||||
|
return errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.Device{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{
|
||||||
|
"accumulated_recharge": oldAsset.Device.AccumulatedRecharge,
|
||||||
|
"first_commission_paid": oldAsset.Device.FirstCommissionPaid,
|
||||||
|
"accumulated_recharge_by_series": oldAsset.Device.AccumulatedRechargeBySeriesJSON,
|
||||||
|
"first_recharge_triggered_by_series": oldAsset.Device.FirstRechargeTriggeredBySeriesJSON,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "复制旧设备累计字段失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) copyResourceTagsWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
|
||||||
|
var tags []*model.ResourceTag
|
||||||
|
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).Find(&tags).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询资源标签失败")
|
||||||
|
}
|
||||||
|
var creator = middleware.GetUserIDFromContext(ctx)
|
||||||
|
for _, item := range tags {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record := &model.ResourceTag{
|
||||||
|
ResourceType: newAsset.AssetType,
|
||||||
|
ResourceID: newAsset.AssetID,
|
||||||
|
TagID: item.TagID,
|
||||||
|
EnterpriseID: item.EnterpriseID,
|
||||||
|
ShopID: item.ShopID,
|
||||||
|
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "resource_type"}, {Name: "resource_id"}, {Name: "tag_id"}}, DoNothing: true}).Create(record).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "复制资源标签失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateOldAssetStatusWithTx(tx *gorm.DB, oldAsset *resolvedExchangeAsset) error {
|
||||||
|
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||||
|
if err := tx.Model(&model.IotCard{}).Where("id = ?", oldAsset.AssetID).Updates(map[string]any{"asset_status": 3, "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新旧卡状态失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.Device{}).Where("id = ?", oldAsset.AssetID).Updates(map[string]any{"asset_status": 3, "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新旧设备状态失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
487
internal/service/exchange/service.go
Normal file
487
internal/service/exchange/service.go
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
exchangeStore *postgres.ExchangeOrderStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
assetWalletStore *postgres.AssetWalletStore
|
||||||
|
assetWalletTransactionStore *postgres.AssetWalletTransactionStore
|
||||||
|
packageUsageStore *postgres.PackageUsageStore
|
||||||
|
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore
|
||||||
|
resourceTagStore *postgres.ResourceTagStore
|
||||||
|
personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(
|
||||||
|
db *gorm.DB,
|
||||||
|
exchangeStore *postgres.ExchangeOrderStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
assetWalletStore *postgres.AssetWalletStore,
|
||||||
|
assetWalletTransactionStore *postgres.AssetWalletTransactionStore,
|
||||||
|
packageUsageStore *postgres.PackageUsageStore,
|
||||||
|
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore,
|
||||||
|
resourceTagStore *postgres.ResourceTagStore,
|
||||||
|
personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
exchangeStore: exchangeStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
assetWalletStore: assetWalletStore,
|
||||||
|
assetWalletTransactionStore: assetWalletTransactionStore,
|
||||||
|
packageUsageStore: packageUsageStore,
|
||||||
|
packageUsageDailyRecordStore: packageUsageDailyRecordStore,
|
||||||
|
resourceTagStore: resourceTagStore,
|
||||||
|
personalCustomerDeviceStore: personalCustomerDeviceStore,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) {
|
||||||
|
asset, err := s.resolveAssetByIdentifier(ctx, req.OldAssetType, req.OldIdentifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
|
||||||
|
return nil, errors.New(errors.CodeExchangeInProgress)
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
creator := middleware.GetUserIDFromContext(ctx)
|
||||||
|
order := &model.ExchangeOrder{
|
||||||
|
ExchangeNo: model.GenerateExchangeNo(),
|
||||||
|
OldAssetType: asset.AssetType,
|
||||||
|
OldAssetID: asset.AssetID,
|
||||||
|
OldAssetIdentifier: asset.Identifier,
|
||||||
|
ExchangeReason: req.ExchangeReason,
|
||||||
|
Remark: req.Remark,
|
||||||
|
Status: constants.ExchangeStatusPendingInfo,
|
||||||
|
MigrationCompleted: false,
|
||||||
|
MigrationBalance: 0,
|
||||||
|
MigrateData: false,
|
||||||
|
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
|
||||||
|
}
|
||||||
|
if shopID > 0 {
|
||||||
|
order.ShopID = &shopID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.exchangeStore.Create(ctx, order); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建换货单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toExchangeOrderResponse(order), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
|
||||||
|
page := req.Page
|
||||||
|
page = max(page, 1)
|
||||||
|
pageSize := req.PageSize
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if pageSize > constants.MaxPageSize {
|
||||||
|
pageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := make(map[string]any)
|
||||||
|
if req.Status != nil {
|
||||||
|
filters["status"] = *req.Status
|
||||||
|
}
|
||||||
|
if req.Identifier != "" {
|
||||||
|
filters["identifier"] = req.Identifier
|
||||||
|
}
|
||||||
|
if req.CreatedAtStart != nil {
|
||||||
|
filters["created_at_start"] = *req.CreatedAtStart
|
||||||
|
}
|
||||||
|
if req.CreatedAtEnd != nil {
|
||||||
|
filters["created_at_end"] = *req.CreatedAtEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, total, err := s.exchangeStore.List(ctx, filters, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]*dto.ExchangeOrderResponse, 0, len(orders))
|
||||||
|
for _, item := range orders {
|
||||||
|
list = append(list, s.toExchangeOrderResponse(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ExchangeListResponse{List: list, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
|
||||||
|
order, err := s.exchangeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeExchangeOrderNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败")
|
||||||
|
}
|
||||||
|
return s.toExchangeOrderResponse(order), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) {
|
||||||
|
order, err := s.exchangeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeExchangeOrderNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||||
|
}
|
||||||
|
if order.Status != constants.ExchangeStatusPendingShip {
|
||||||
|
return nil, errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
newAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, req.NewIdentifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if newAsset.AssetType != order.OldAssetType {
|
||||||
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
|
}
|
||||||
|
if newAsset.AssetStatus != 1 {
|
||||||
|
return nil, errors.New(errors.CodeExchangeNewAssetNotInStock)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
"new_asset_type": newAsset.AssetType,
|
||||||
|
"new_asset_id": newAsset.AssetID,
|
||||||
|
"new_asset_identifier": newAsset.Identifier,
|
||||||
|
"express_company": req.ExpressCompany,
|
||||||
|
"express_no": req.ExpressNo,
|
||||||
|
"migrate_data": req.MigrateData,
|
||||||
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if err = s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, updates); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新换货单发货状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Complete(ctx context.Context, id uint) error {
|
||||||
|
order, err := s.exchangeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeExchangeOrderNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||||
|
}
|
||||||
|
if order.Status != constants.ExchangeStatusShipped {
|
||||||
|
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if order.MigrateData {
|
||||||
|
var migrationBalance int64
|
||||||
|
migrationBalance, err = s.executeMigration(ctx, order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates["migration_completed"] = true
|
||||||
|
updates["migration_balance"] = migrationBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted, updates); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "确认换货完成失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
|
||||||
|
order, err := s.exchangeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeExchangeOrderNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||||
|
}
|
||||||
|
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
|
||||||
|
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if req != nil {
|
||||||
|
updates["remark"] = req.Remark
|
||||||
|
}
|
||||||
|
if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Renew(ctx context.Context, id uint) error {
|
||||||
|
order, err := s.exchangeStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeExchangeOrderNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||||
|
}
|
||||||
|
if order.Status != constants.ExchangeStatusCompleted {
|
||||||
|
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
|
||||||
|
var card model.IotCard
|
||||||
|
if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败")
|
||||||
|
}
|
||||||
|
if card.AssetStatus != 3 {
|
||||||
|
return errors.New(errors.CodeExchangeAssetNotExchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||||
|
"generation": card.Generation + 1,
|
||||||
|
"asset_status": 1,
|
||||||
|
"accumulated_recharge": 0,
|
||||||
|
"first_commission_paid": false,
|
||||||
|
"accumulated_recharge_by_series": "{}",
|
||||||
|
"first_recharge_triggered_by_series": "{}",
|
||||||
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Where("virtual_no = ?", card.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopTag := uint(0)
|
||||||
|
if card.ShopID != nil {
|
||||||
|
shopTag = *card.ShopID
|
||||||
|
}
|
||||||
|
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var device model.Device
|
||||||
|
if err = tx.Where("id = ?", order.OldAssetID).First(&device).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧设备失败")
|
||||||
|
}
|
||||||
|
if device.AssetStatus != 3 {
|
||||||
|
return errors.New(errors.CodeExchangeAssetNotExchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
|
||||||
|
"generation": device.Generation + 1,
|
||||||
|
"asset_status": 1,
|
||||||
|
"accumulated_recharge": 0,
|
||||||
|
"first_commission_paid": false,
|
||||||
|
"accumulated_recharge_by_series": "{}",
|
||||||
|
"first_recharge_triggered_by_series": "{}",
|
||||||
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Where("virtual_no = ?", device.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopTag := uint(0)
|
||||||
|
if device.ShopID != nil {
|
||||||
|
shopTag = *device.ShopID
|
||||||
|
}
|
||||||
|
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
|
||||||
|
asset, err := s.resolveAssetByIdentifier(ctx, "", identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待处理换货单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.ClientExchangePendingResponse{
|
||||||
|
ID: order.ID,
|
||||||
|
ExchangeNo: order.ExchangeNo,
|
||||||
|
Status: order.Status,
|
||||||
|
StatusText: exchangeStatusText(order.Status),
|
||||||
|
ExchangeReason: order.ExchangeReason,
|
||||||
|
CreatedAt: order.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.ClientShippingInfoRequest) error {
|
||||||
|
updates := map[string]any{
|
||||||
|
"recipient_name": req.RecipientName,
|
||||||
|
"recipient_phone": req.RecipientPhone,
|
||||||
|
"recipient_address": req.RecipientAddress,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedExchangeAsset struct {
|
||||||
|
AssetType string
|
||||||
|
AssetID uint
|
||||||
|
Identifier string
|
||||||
|
VirtualNo string
|
||||||
|
AssetStatus int
|
||||||
|
ShopID *uint
|
||||||
|
Card *model.IotCard
|
||||||
|
Device *model.Device
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) {
|
||||||
|
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice {
|
||||||
|
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
|
||||||
|
if err == nil {
|
||||||
|
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
|
||||||
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
|
}
|
||||||
|
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard {
|
||||||
|
var card model.IotCard
|
||||||
|
query := s.db.WithContext(ctx).Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier)
|
||||||
|
query = middleware.ApplyShopFilter(ctx, query)
|
||||||
|
if err := query.First(&card).Error; err == nil {
|
||||||
|
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
|
||||||
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
|
}
|
||||||
|
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
|
||||||
|
if order == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var deletedAt *time.Time
|
||||||
|
if order.DeletedAt.Valid {
|
||||||
|
deletedAt = &order.DeletedAt.Time
|
||||||
|
}
|
||||||
|
return &dto.ExchangeOrderResponse{
|
||||||
|
ID: order.ID,
|
||||||
|
ExchangeNo: order.ExchangeNo,
|
||||||
|
OldAssetType: order.OldAssetType,
|
||||||
|
OldAssetID: order.OldAssetID,
|
||||||
|
OldAssetIdentifier: order.OldAssetIdentifier,
|
||||||
|
NewAssetType: order.NewAssetType,
|
||||||
|
NewAssetID: order.NewAssetID,
|
||||||
|
NewAssetIdentifier: order.NewAssetIdentifier,
|
||||||
|
RecipientName: order.RecipientName,
|
||||||
|
RecipientPhone: order.RecipientPhone,
|
||||||
|
RecipientAddress: order.RecipientAddress,
|
||||||
|
ExpressCompany: order.ExpressCompany,
|
||||||
|
ExpressNo: order.ExpressNo,
|
||||||
|
MigrateData: order.MigrateData,
|
||||||
|
MigrationCompleted: order.MigrationCompleted,
|
||||||
|
MigrationBalance: order.MigrationBalance,
|
||||||
|
ExchangeReason: order.ExchangeReason,
|
||||||
|
Remark: order.Remark,
|
||||||
|
Status: order.Status,
|
||||||
|
StatusText: exchangeStatusText(order.Status),
|
||||||
|
ShopID: order.ShopID,
|
||||||
|
CreatedAt: order.CreatedAt,
|
||||||
|
UpdatedAt: order.UpdatedAt,
|
||||||
|
DeletedAt: deletedAt,
|
||||||
|
Creator: order.Creator,
|
||||||
|
Updater: order.Updater,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeStatusText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case constants.ExchangeStatusPendingInfo:
|
||||||
|
return "待填写信息"
|
||||||
|
case constants.ExchangeStatusPendingShip:
|
||||||
|
return "待发货"
|
||||||
|
case constants.ExchangeStatusShipped:
|
||||||
|
return "已发货待确认"
|
||||||
|
case constants.ExchangeStatusCompleted:
|
||||||
|
return "已完成"
|
||||||
|
case constants.ExchangeStatusCancelled:
|
||||||
|
return "已取消"
|
||||||
|
default:
|
||||||
|
return "未知状态"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,15 +264,18 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.stopCardWithRetry(ctx, card); err != nil {
|
if err := s.stopCardWithRetry(ctx, card); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "调网关停机失败")
|
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||||
"network_status": constants.NetworkStatusOffline,
|
"network_status": constants.NetworkStatusOffline,
|
||||||
"stopped_at": now,
|
"stopped_at": now,
|
||||||
"stop_reason": constants.StopReasonManual,
|
"stop_reason": constants.StopReasonManual,
|
||||||
}).Error
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualStartCard 手动复机单张卡(通过ICCID)
|
// ManualStartCard 手动复机单张卡(通过ICCID)
|
||||||
@@ -300,13 +303,16 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "调网关复机失败")
|
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||||
"network_status": constants.NetworkStatusOnline,
|
"network_status": constants.NetworkStatusOnline,
|
||||||
"resumed_at": now,
|
"resumed_at": now,
|
||||||
"stop_reason": "",
|
"stop_reason": "",
|
||||||
}).Error
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从资产获取当前 generation(用于订单快照)
|
||||||
|
var assetGeneration int
|
||||||
|
if validationResult.Card != nil {
|
||||||
|
assetGeneration = validationResult.Card.Generation
|
||||||
|
} else if validationResult.Device != nil {
|
||||||
|
assetGeneration = validationResult.Device.Generation
|
||||||
|
}
|
||||||
|
if assetGeneration == 0 {
|
||||||
|
assetGeneration = 1
|
||||||
|
}
|
||||||
|
|
||||||
order := &model.Order{
|
order := &model.Order{
|
||||||
BaseModel: model.BaseModel{
|
BaseModel: model.BaseModel{
|
||||||
Creator: userID,
|
Creator: userID,
|
||||||
@@ -550,6 +582,8 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
|||||||
},
|
},
|
||||||
OrderNo: s.orderStore.GenerateOrderNo(),
|
OrderNo: s.orderStore.GenerateOrderNo(),
|
||||||
OrderType: req.OrderType,
|
OrderType: req.OrderType,
|
||||||
|
Source: constants.OrderSourceAdmin,
|
||||||
|
Generation: assetGeneration,
|
||||||
BuyerType: orderBuyerType,
|
BuyerType: orderBuyerType,
|
||||||
BuyerID: orderBuyerID,
|
BuyerID: orderBuyerID,
|
||||||
IotCardID: req.IotCardID,
|
IotCardID: req.IotCardID,
|
||||||
@@ -568,6 +602,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 +810,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 +848,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 +2104,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 +2160,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 +2351,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, "富友小程序支付发起暂未实现")
|
||||||
|
}
|
||||||
|
|||||||
@@ -456,6 +456,42 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRetailPrice 代理修改自己店铺的套餐零售价
|
||||||
|
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
if userType != constants.UserTypeAgent {
|
||||||
|
return errors.New(errors.CodeForbidden, "仅代理用户可修改零售价")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
if shopID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "该套餐未分配给您")
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if retailPrice < allocation.CostPrice {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, retailPrice, currentUserID); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status
|
// updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status
|
||||||
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error {
|
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error {
|
||||||
shopID := middleware.GetShopIDFromContext(ctx)
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
@@ -533,9 +569,9 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
|||||||
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
||||||
if err == nil && allocation != nil {
|
if err == nil && allocation != nil {
|
||||||
resp.CostPrice = allocation.CostPrice
|
resp.CostPrice = allocation.CostPrice
|
||||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
resp.RetailPrice = &allocation.RetailPrice
|
||||||
|
profitMargin := allocation.RetailPrice - allocation.CostPrice
|
||||||
resp.ProfitMargin = &profitMargin
|
resp.ProfitMargin = &profitMargin
|
||||||
// 代理查询时,shelf_status 返回自己分配记录的值,而非平台全局值
|
|
||||||
resp.ShelfStatus = allocation.ShelfStatus
|
resp.ShelfStatus = allocation.ShelfStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,9 +631,9 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
|||||||
if allocationMap != nil {
|
if allocationMap != nil {
|
||||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||||
resp.CostPrice = allocation.CostPrice
|
resp.CostPrice = allocation.CostPrice
|
||||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
resp.RetailPrice = &allocation.RetailPrice
|
||||||
|
profitMargin := allocation.RetailPrice - allocation.CostPrice
|
||||||
resp.ProfitMargin = &profitMargin
|
resp.ProfitMargin = &profitMargin
|
||||||
// 代理查询时,shelf_status 返回自己分配记录的值,而非平台全局值
|
|
||||||
resp.ShelfStatus = allocation.ShelfStatus
|
resp.ShelfStatus = allocation.ShelfStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,169 +1,36 @@
|
|||||||
// Package personal_customer 提供个人客户管理的业务逻辑服务
|
// Package personal_customer 提供个人客户资料管理的业务逻辑服务
|
||||||
// 包含个人客户注册、登录、微信绑定、短信验证等功能
|
|
||||||
package personal_customer
|
package personal_customer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service 个人客户服务
|
// Service 个人客户服务
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *postgres.PersonalCustomerStore
|
store *postgres.PersonalCustomerStore
|
||||||
phoneStore *postgres.PersonalCustomerPhoneStore
|
phoneStore *postgres.PersonalCustomerPhoneStore
|
||||||
verificationService *verification.Service
|
logger *zap.Logger
|
||||||
jwtManager *auth.JWTManager
|
|
||||||
wechatOfficialAccount wechat.OfficialAccountServiceInterface
|
|
||||||
logger *zap.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService 创建个人客户服务实例
|
// NewService 创建个人客户服务实例
|
||||||
func NewService(
|
func NewService(
|
||||||
store *postgres.PersonalCustomerStore,
|
store *postgres.PersonalCustomerStore,
|
||||||
phoneStore *postgres.PersonalCustomerPhoneStore,
|
phoneStore *postgres.PersonalCustomerPhoneStore,
|
||||||
verificationService *verification.Service,
|
|
||||||
jwtManager *auth.JWTManager,
|
|
||||||
wechatOfficialAccount wechat.OfficialAccountServiceInterface,
|
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
store: store,
|
store: store,
|
||||||
phoneStore: phoneStore,
|
phoneStore: phoneStore,
|
||||||
verificationService: verificationService,
|
logger: logger,
|
||||||
jwtManager: jwtManager,
|
|
||||||
wechatOfficialAccount: wechatOfficialAccount,
|
|
||||||
logger: logger,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVerificationCode 发送验证码
|
|
||||||
func (s *Service) SendVerificationCode(ctx context.Context, phone string) error {
|
|
||||||
return s.verificationService.SendCode(ctx, phone)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyCode 验证验证码
|
|
||||||
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
|
|
||||||
return s.verificationService.VerifyCode(ctx, phone, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginByPhone 通过手机号登录
|
|
||||||
// 如果手机号不存在,自动创建新的个人客户
|
|
||||||
// 注意:此方法是临时实现,完整的登录流程应该是先微信授权,再绑定手机号
|
|
||||||
func (s *Service) LoginByPhone(ctx context.Context, phone string, code string) (string, *model.PersonalCustomer, error) {
|
|
||||||
// 验证验证码
|
|
||||||
if err := s.verificationService.VerifyCode(ctx, phone, code); err != nil {
|
|
||||||
s.logger.Warn("验证码验证失败",
|
|
||||||
zap.String("phone", phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找或创建个人客户
|
|
||||||
customer, err := s.store.GetByPhone(ctx, phone)
|
|
||||||
if err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
// 客户不存在,创建新客户
|
|
||||||
// 注意:临时实现,使用空的微信信息(正式应该先微信授权)
|
|
||||||
customer = &model.PersonalCustomer{
|
|
||||||
WxOpenID: "", // 临时为空,后续需绑定微信
|
|
||||||
WxUnionID: "", // 临时为空,后续需绑定微信
|
|
||||||
Status: 1, // 默认启用
|
|
||||||
}
|
|
||||||
if err := s.store.Create(ctx, customer); err != nil {
|
|
||||||
s.logger.Error("创建个人客户失败",
|
|
||||||
zap.String("phone", phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return "", nil, errors.Wrap(errors.CodeInternalError, err, "创建个人客户失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建手机号绑定记录
|
|
||||||
// TODO: 这里需要通过 PersonalCustomerPhoneStore 来创建
|
|
||||||
// 暂时跳过,等待 PersonalCustomerPhoneStore 实现
|
|
||||||
|
|
||||||
s.logger.Info("创建新个人客户",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("phone", phone),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
s.logger.Error("查询个人客户失败",
|
|
||||||
zap.String("phone", phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return "", nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查客户状态
|
|
||||||
if customer.Status == 0 {
|
|
||||||
s.logger.Warn("个人客户已被禁用",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("phone", phone),
|
|
||||||
)
|
|
||||||
return "", nil, errors.New(errors.CodeForbidden, "账号已被禁用")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成 Token(临时传递 phone,后续应该从 Token 中移除 phone 字段)
|
|
||||||
token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, phone)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("生成 Token 失败",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("phone", phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return "", nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("个人客户登录成功",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("phone", phone),
|
|
||||||
)
|
|
||||||
|
|
||||||
return token, customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWechat 绑定微信信息
|
|
||||||
func (s *Service) BindWechat(ctx context.Context, customerID uint, wxOpenID, wxUnionID string) error {
|
|
||||||
// 获取客户
|
|
||||||
customer, err := s.store.GetByID(ctx, customerID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("查询个人客户失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新微信信息
|
|
||||||
customer.WxOpenID = wxOpenID
|
|
||||||
customer.WxUnionID = wxUnionID
|
|
||||||
|
|
||||||
if err := s.store.Update(ctx, customer); err != nil {
|
|
||||||
s.logger.Error("更新微信信息失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "更新微信信息失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("绑定微信信息成功",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.String("wx_open_id", wxOpenID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfile 更新个人资料
|
// UpdateProfile 更新个人资料
|
||||||
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
|
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
|
||||||
customer, err := s.store.GetByID(ctx, customerID)
|
customer, err := s.store.GetByID(ctx, customerID)
|
||||||
@@ -198,20 +65,6 @@ func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfile 获取个人资料
|
|
||||||
func (s *Service) GetProfile(ctx context.Context, customerID uint) (*model.PersonalCustomer, error) {
|
|
||||||
customer, err := s.store.GetByID(ctx, customerID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("查询个人客户失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return customer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProfileWithPhone 获取个人资料(包含主手机号)
|
// GetProfileWithPhone 获取个人资料(包含主手机号)
|
||||||
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
|
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
|
||||||
// 获取客户信息
|
// 获取客户信息
|
||||||
@@ -241,190 +94,3 @@ func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*mo
|
|||||||
|
|
||||||
return customer, phone, nil
|
return customer, phone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WechatOAuthLogin 微信 OAuth 登录
|
|
||||||
// 通过微信授权码登录,如果用户不存在则自动创建
|
|
||||||
func (s *Service) WechatOAuthLogin(ctx context.Context, code string) (*dto.WechatOAuthResponse, error) {
|
|
||||||
// 检查微信服务是否已配置
|
|
||||||
if s.wechatOfficialAccount == nil {
|
|
||||||
s.logger.Error("微信公众号服务未配置")
|
|
||||||
return nil, errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过授权码获取用户详细信息
|
|
||||||
userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("获取微信用户信息失败",
|
|
||||||
zap.String("code", code),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过 OpenID 查找现有客户
|
|
||||||
customer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID)
|
|
||||||
if err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
// 客户不存在,创建新客户
|
|
||||||
customer = &model.PersonalCustomer{
|
|
||||||
WxOpenID: userInfo.OpenID,
|
|
||||||
WxUnionID: userInfo.UnionID,
|
|
||||||
Nickname: userInfo.Nickname,
|
|
||||||
AvatarURL: userInfo.Avatar,
|
|
||||||
Status: 1, // 默认启用
|
|
||||||
}
|
|
||||||
if err := s.store.Create(ctx, customer); err != nil {
|
|
||||||
s.logger.Error("创建微信用户失败",
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建用户失败")
|
|
||||||
}
|
|
||||||
s.logger.Info("通过微信创建新用户",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
s.logger.Error("查询微信用户失败",
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询用户失败")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 客户已存在,更新昵称和头像(如果有变化)
|
|
||||||
needUpdate := false
|
|
||||||
if userInfo.Nickname != "" && customer.Nickname != userInfo.Nickname {
|
|
||||||
customer.Nickname = userInfo.Nickname
|
|
||||||
needUpdate = true
|
|
||||||
}
|
|
||||||
if userInfo.Avatar != "" && customer.AvatarURL != userInfo.Avatar {
|
|
||||||
customer.AvatarURL = userInfo.Avatar
|
|
||||||
needUpdate = true
|
|
||||||
}
|
|
||||||
if needUpdate {
|
|
||||||
if err := s.store.Update(ctx, customer); err != nil {
|
|
||||||
s.logger.Warn("更新微信用户信息失败",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
// 不阻断登录流程
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查客户状态
|
|
||||||
if customer.Status == 0 {
|
|
||||||
s.logger.Warn("微信用户已被禁用",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
)
|
|
||||||
return nil, errors.New(errors.CodeForbidden, "账号已被禁用")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成 JWT Token
|
|
||||||
token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, "")
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("生成 Token 失败",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取主手机号(如果有)
|
|
||||||
phone := ""
|
|
||||||
primaryPhone, err := s.phoneStore.GetPrimaryPhone(ctx, customer.ID)
|
|
||||||
if err == nil {
|
|
||||||
phone = primaryPhone.Phone
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("微信 OAuth 登录成功",
|
|
||||||
zap.Uint("customer_id", customer.ID),
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &dto.WechatOAuthResponse{
|
|
||||||
AccessToken: token,
|
|
||||||
ExpiresIn: 24 * 60 * 60, // 24 小时
|
|
||||||
Customer: &dto.PersonalCustomerResponse{
|
|
||||||
ID: customer.ID,
|
|
||||||
Phone: phone,
|
|
||||||
Nickname: customer.Nickname,
|
|
||||||
AvatarURL: customer.AvatarURL,
|
|
||||||
WxOpenID: customer.WxOpenID,
|
|
||||||
WxUnionID: customer.WxUnionID,
|
|
||||||
Status: customer.Status,
|
|
||||||
CreatedAt: customer.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
||||||
UpdatedAt: customer.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWechatWithCode 通过微信授权码绑定微信
|
|
||||||
// customerID: 当前登录的客户 ID
|
|
||||||
// code: 微信授权码
|
|
||||||
func (s *Service) BindWechatWithCode(ctx context.Context, customerID uint, code string) error {
|
|
||||||
// 检查微信服务是否已配置
|
|
||||||
if s.wechatOfficialAccount == nil {
|
|
||||||
s.logger.Error("微信公众号服务未配置")
|
|
||||||
return errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取客户信息
|
|
||||||
customer, err := s.store.GetByID(ctx, customerID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("查询个人客户失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "查询客户失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取微信用户信息
|
|
||||||
userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("获取微信用户信息失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.String("code", code),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查该 OpenID 是否已被其他用户绑定
|
|
||||||
existingCustomer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID)
|
|
||||||
if err == nil && existingCustomer.ID != customerID {
|
|
||||||
s.logger.Warn("微信账号已被其他用户绑定",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Uint("existing_customer_id", existingCustomer.ID),
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
)
|
|
||||||
return errors.New(errors.CodeConflict, "该微信账号已被其他用户绑定")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新微信信息
|
|
||||||
customer.WxOpenID = userInfo.OpenID
|
|
||||||
customer.WxUnionID = userInfo.UnionID
|
|
||||||
if userInfo.Nickname != "" {
|
|
||||||
customer.Nickname = userInfo.Nickname
|
|
||||||
}
|
|
||||||
if userInfo.Avatar != "" {
|
|
||||||
customer.AvatarURL = userInfo.Avatar
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.Update(ctx, customer); err != nil {
|
|
||||||
s.logger.Error("绑定微信信息失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "绑定微信失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("绑定微信成功",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.String("open_id", userInfo.OpenID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -133,44 +133,57 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sellerShopID > 0 {
|
if sellerShopID > 0 {
|
||||||
// 代理渠道:检查卖家代理的 allocation.shelf_status,不检查 package.shelf_status
|
// 代理渠道:检查上架状态并获取分配记录,使用零售价
|
||||||
if err := s.validateAgentShelfStatus(ctx, sellerShopID, pkgID); err != nil {
|
allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID)
|
||||||
return nil, 0, err
|
if allocErr != nil {
|
||||||
|
return nil, 0, allocErr
|
||||||
}
|
}
|
||||||
|
// 零售价低于成本价时视为不可购买,防止亏损售卖
|
||||||
|
if allocation.RetailPrice < allocation.CostPrice {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐价格配置异常,暂不可购买")
|
||||||
|
}
|
||||||
|
totalPrice += allocation.RetailPrice
|
||||||
} else {
|
} else {
|
||||||
// 平台自营渠道:检查 package.shelf_status
|
|
||||||
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||||
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
||||||
}
|
}
|
||||||
|
totalPrice += pkg.SuggestedRetailPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
packages = append(packages, pkg)
|
packages = append(packages, pkg)
|
||||||
totalPrice += pkg.SuggestedRetailPrice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages, totalPrice, nil
|
return packages, totalPrice, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAgentShelfStatus 校验卖家代理的分配记录上架状态
|
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
|
||||||
func (s *Service) validateAgentShelfStatus(ctx context.Context, sellerShopID, packageID uint) error {
|
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
|
||||||
// 使用不带数据权限过滤的查询,避免 buyer ctx 的权限限制干扰系统级校验
|
|
||||||
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
|
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return errors.New(errors.CodeInvalidParam, "套餐已下架")
|
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
||||||
}
|
}
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if allocation.ShelfStatus != constants.ShelfStatusOn {
|
if allocation.ShelfStatus != constants.ShelfStatusOn {
|
||||||
return errors.New(errors.CodeInvalidParam, "套餐已下架")
|
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return allocation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, buyerType string) int64 {
|
// GetPurchasePrice 获取购买价格
|
||||||
return pkg.SuggestedRetailPrice
|
// 代理渠道(sellerShopID > 0)返回 allocation.RetailPrice,平台渠道返回 Package.SuggestedRetailPrice
|
||||||
|
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, sellerShopID uint) (int64, error) {
|
||||||
|
if sellerShopID > 0 {
|
||||||
|
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, pkg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
|
||||||
|
}
|
||||||
|
return allocation.RetailPrice, nil
|
||||||
|
}
|
||||||
|
return pkg.SuggestedRetailPrice, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateAdminOfflineCardPurchase 后台 offline 订单专用卡验证
|
// ValidateAdminOfflineCardPurchase 后台 offline 订单专用卡验证
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -284,18 +306,17 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
|
|||||||
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
|
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// 6.1 更新充值订单状态(带状态检查,实现乐观锁)
|
// 6.1 更新充值订单状态(带状态检查,使用事务内 tx 确保原子性)
|
||||||
oldStatus := constants.RechargeStatusPending
|
oldStatus := constants.RechargeStatusPending
|
||||||
if err := s.assetRechargeStore.UpdateStatusWithOptimisticLock(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
|
if err := s.assetRechargeStore.UpdateStatusWithOptimisticLockDB(ctx, tx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// 状态已变更,幂等处理
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6.2 更新支付信息
|
// 6.2 更新支付信息(使用事务内 tx)
|
||||||
if err := s.assetRechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
|
if err := s.assetRechargeStore.UpdatePaymentInfoWithDB(ctx, tx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Service struct {
|
|||||||
shopRoleStore *postgres.ShopRoleStore
|
shopRoleStore *postgres.ShopRoleStore
|
||||||
roleStore *postgres.RoleStore
|
roleStore *postgres.RoleStore
|
||||||
accountRoleStore *postgres.AccountRoleStore
|
accountRoleStore *postgres.AccountRoleStore
|
||||||
|
agentWalletStore *postgres.AgentWalletStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -28,6 +29,7 @@ func New(
|
|||||||
shopRoleStore *postgres.ShopRoleStore,
|
shopRoleStore *postgres.ShopRoleStore,
|
||||||
roleStore *postgres.RoleStore,
|
roleStore *postgres.RoleStore,
|
||||||
accountRoleStore *postgres.AccountRoleStore,
|
accountRoleStore *postgres.AccountRoleStore,
|
||||||
|
agentWalletStore *postgres.AgentWalletStore,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
shopStore: shopStore,
|
shopStore: shopStore,
|
||||||
@@ -35,6 +37,7 @@ func New(
|
|||||||
shopRoleStore: shopRoleStore,
|
shopRoleStore: shopRoleStore,
|
||||||
roleStore: roleStore,
|
roleStore: roleStore,
|
||||||
accountRoleStore: accountRoleStore,
|
accountRoleStore: accountRoleStore,
|
||||||
|
agentWalletStore: agentWalletStore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +150,32 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopRequest) (*dto.
|
|||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "设置店铺默认角色失败")
|
return nil, errors.Wrap(errors.CodeInternalError, err, "设置店铺默认角色失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化店铺代理钱包:主钱包 + 分佣钱包
|
||||||
|
// 新店铺必须有两个钱包才能参与充值和分佣体系
|
||||||
|
wallets := []*model.AgentWallet{
|
||||||
|
{
|
||||||
|
ShopID: shop.ID,
|
||||||
|
WalletType: constants.AgentWalletTypeMain,
|
||||||
|
Balance: 0,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: constants.AgentWalletStatusNormal,
|
||||||
|
ShopIDTag: shop.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ShopID: shop.ID,
|
||||||
|
WalletType: constants.AgentWalletTypeCommission,
|
||||||
|
Balance: 0,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: constants.AgentWalletStatusNormal,
|
||||||
|
ShopIDTag: shop.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, wallet := range wallets {
|
||||||
|
if err := s.agentWalletStore.Create(ctx, wallet); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "初始化店铺钱包失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &dto.ShopResponse{
|
return &dto.ShopResponse{
|
||||||
ID: shop.ID,
|
ID: shop.ID,
|
||||||
ShopName: shop.ShopName,
|
ShopName: shop.ShopName,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user