合并七月迭代分支

This commit is contained in:
2026-08-18 14:53:29 +08:00
2237 changed files with 91363 additions and 290515 deletions

View File

@@ -1,581 +0,0 @@
# 数据持久化与异步任务处理集成 - 使用指南
**功能编号**: 002-gorm-postgres-asynq
**更新日期**: 2025-11-13
---
## 快速开始
详细的快速开始指南请参考:[Quick Start Guide](../../specs/002-gorm-postgres-asynq/quickstart.md)
本文档提供核心使用场景和最佳实践。
---
## 核心使用场景
### 1. 数据库 CRUD 操作
#### 创建用户
```bash
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-H "token: your_token" \
-d '{
"username": "testuser",
"email": "test@example.com",
"password": "password123"
}'
```
#### 查询用户
```bash
# 根据 ID 查询
curl http://localhost:8080/api/v1/users/1 \
-H "token: your_token"
# 列表查询(分页)
curl "http://localhost:8080/api/v1/users?page=1&page_size=20" \
-H "token: your_token"
```
#### 更新用户
```bash
curl -X PUT http://localhost:8080/api/v1/users/1 \
-H "Content-Type: application/json" \
-H "token: your_token" \
-d '{
"email": "newemail@example.com",
"status": "inactive"
}'
```
#### 删除用户(软删除)
```bash
curl -X DELETE http://localhost:8080/api/v1/users/1 \
-H "token: your_token"
```
### 2. 异步任务提交
#### 发送邮件任务
```bash
curl -X POST http://localhost:8080/api/v1/tasks/email \
-H "Content-Type: application/json" \
-H "token: your_token" \
-d '{
"to": "user@example.com",
"subject": "欢迎",
"body": "欢迎使用君鸿卡管系统"
}'
```
#### 数据同步任务
```bash
curl -X POST http://localhost:8080/api/v1/tasks/sync \
-H "Content-Type: application/json" \
-H "token: your_token" \
-d '{
"sync_type": "sim_status",
"start_date": "2025-11-01",
"end_date": "2025-11-13",
"priority": "critical"
}'
```
### 3. 代码中使用
#### Service 层使用数据库
```go
package user
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
)
type Service struct {
store *postgres.Store
logger *zap.Logger
}
// 创建用户
func (s *Service) CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
user := &model.User{
Username: req.Username,
Email: req.Email,
Password: hashPassword(req.Password),
Status: constants.UserStatusActive,
}
if err := s.store.User.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// 事务处理
func (s *Service) CreateOrderWithUser(ctx context.Context, req *CreateOrderRequest) error {
return s.store.Transaction(ctx, func(tx *postgres.Store) error {
// 创建订单
order := &model.Order{...}
if err := tx.Order.Create(ctx, order); err != nil {
return err
}
// 更新用户统计
user, _ := tx.User.GetByID(ctx, req.UserID)
user.OrderCount++
if err := tx.User.Update(ctx, user); err != nil {
return err
}
return nil // 提交事务
})
}
```
#### Service 层提交异步任务
```go
package email
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
type Service struct {
queueClient *queue.Client
logger *zap.Logger
}
// 发送欢迎邮件
func (s *Service) SendWelcomeEmail(ctx context.Context, userID uint, email string) error {
payload := &task.EmailPayload{
RequestID: fmt.Sprintf("welcome-%d", userID),
To: email,
Subject: "欢迎加入",
Body: "感谢您注册我们的服务!",
}
payloadBytes, _ := json.Marshal(payload)
return s.queueClient.EnqueueTask(
ctx,
constants.TaskTypeEmailSend,
payloadBytes,
asynq.Queue(constants.QueueDefault),
asynq.MaxRetry(constants.DefaultRetryMax),
)
}
```
---
## 配置管理
### 环境配置文件
```
configs/
├── config.yaml # 默认配置
├── config.dev.yaml # 开发环境
├── config.staging.yaml # 预发布环境
└── config.prod.yaml # 生产环境
```
### 切换环境
```bash
# 开发环境
export CONFIG_ENV=dev
go run cmd/api/main.go
# 生产环境
export CONFIG_ENV=prod
export DB_PASSWORD=secure_password # 使用环境变量覆盖密码
go run cmd/api/main.go
```
### 数据库配置
```yaml
database:
host: localhost
port: 5432
user: postgres
password: password # 生产环境使用 ${DB_PASSWORD}
dbname: junhong_cmp
sslmode: disable # 生产环境使用 require
max_open_conns: 25
max_idle_conns: 10
conn_max_lifetime: 5m
```
### 队列配置
```yaml
queue:
concurrency: 10 # Worker 并发数
queues:
critical: 6 # 高优先级60%
default: 3 # 默认优先级30%
low: 1 # 低优先级10%
retry_max: 5
timeout: 10m
```
---
## 数据库迁移
### 使用迁移脚本
```bash
# 赋予执行权限
chmod +x scripts/migrate.sh
# 向上迁移(应用所有迁移)
./scripts/migrate.sh up
# 回滚最后一次迁移
./scripts/migrate.sh down 1
# 查看当前版本
./scripts/migrate.sh version
# 创建新迁移
./scripts/migrate.sh create add_new_table
```
### 创建迁移文件
```bash
# 1. 创建迁移
./scripts/migrate.sh create add_sim_card_table
# 2. 编辑生成的文件
# migrations/000002_add_sim_card_table.up.sql
# migrations/000002_add_sim_card_table.down.sql
# 3. 执行迁移
./scripts/migrate.sh up
```
---
## 监控与调试
### 健康检查
```bash
curl http://localhost:8080/health
```
响应示例:
```json
{
"status": "healthy",
"timestamp": "2025-11-13T12:00:00+08:00",
"services": {
"postgres": {
"status": "up",
"open_conns": 5,
"in_use": 2,
"idle": 3
},
"redis": {
"status": "up",
"total_conns": 10,
"idle_conns": 7
}
}
}
```
### 查看任务队列状态
#### 使用 asynqmon推荐
```bash
# 安装
go install github.com/hibiken/asynqmon@latest
# 启动监控面板
asynqmon --redis-addr=localhost:6379
# 访问 http://localhost:8080
```
#### 使用 Redis CLI
```bash
# 查看所有队列
redis-cli KEYS "asynq:*"
# 查看 default 队列长度
redis-cli LLEN "asynq:{default}:pending"
# 查看任务详情
redis-cli HGETALL "asynq:task:{task_id}"
```
### 查看日志
```bash
# 实时查看应用日志
tail -f logs/app.log | jq .
# 过滤错误日志
tail -f logs/app.log | jq 'select(.level == "error")'
# 查看访问日志
tail -f logs/access.log | jq .
# 过滤慢查询(> 100ms
tail -f logs/app.log | jq 'select(.duration_ms > 100)'
```
---
## 性能调优
### 数据库连接池
根据服务器资源调整:
```yaml
database:
max_open_conns: 50 # 增大以支持更多并发
max_idle_conns: 20 # 保持足够的空闲连接
conn_max_lifetime: 5m # 定期回收连接
```
**计算公式**
```
max_open_conns = (可用内存 / 10MB) * 0.7
```
### Worker 并发数
根据任务类型调整:
```yaml
queue:
concurrency: 20 # I/O 密集型CPU 核心数 × 2
# concurrency: 8 # CPU 密集型CPU 核心数
```
### 队列优先级
根据业务需求调整:
```yaml
queue:
queues:
critical: 8 # 提高关键任务权重
default: 2
low: 1
```
---
## 故障排查
### 问题 1: 数据库连接失败
**错误**: `dial tcp 127.0.0.1:5432: connect: connection refused`
**解决方案**:
```bash
# 1. 检查 PostgreSQL 是否运行
docker ps | grep postgres
# 2. 检查端口占用
lsof -i :5432
# 3. 重启 PostgreSQL
docker restart postgres-dev
```
### 问题 2: Worker 无法连接 Redis
**错误**: `dial tcp 127.0.0.1:6379: connect: connection refused`
**解决方案**:
```bash
# 1. 检查 Redis 是否运行
docker ps | grep redis
# 2. 测试连接
redis-cli ping
# 3. 重启 Redis
docker restart redis-dev
```
### 问题 3: 任务一直重试
**原因**: 任务处理函数返回错误
**解决方案**:
1. 检查 Worker 日志:`tail -f logs/app.log | jq 'select(.level == "error")'`
2. 使用 asynqmon 查看失败详情
3. 检查任务幂等性实现
4. 验证 Redis 锁键是否正确设置
### 问题 4: 数据库迁移失败
**错误**: `Dirty database version 1. Fix and force version.`
**解决方案**:
```bash
# 1. 强制设置版本
export DATABASE_URL="postgresql://user:password@localhost:5432/dbname?sslmode=disable"
migrate -path migrations -database "$DATABASE_URL" force 1
# 2. 重新运行迁移
./scripts/migrate.sh up
```
---
## 最佳实践
### 1. 数据库操作
- ✅ 使用 GORM 的参数化查询(自动防 SQL 注入)
- ✅ 事务尽量快(< 50ms避免长事务锁表
- ✅ 批量操作使用 `CreateInBatches()` 提高性能
- ✅ 列表查询实现分页(默认 20 条,最大 100 条)
- ❌ 避免使用 `db.Raw()` 拼接 SQL
### 2. 异步任务
- ✅ 任务处理函数必须幂等
- ✅ 使用 Redis 锁或数据库唯一约束防重复执行
- ✅ 关键任务使用 `critical` 队列
- ✅ 设置合理的超时时间
- ❌ 避免在任务中执行长时间阻塞操作
### 3. 错误处理
- ✅ Service 层转换为业务错误码
- ✅ Handler 层使用统一响应格式
- ✅ 记录详细的错误日志
- ❌ 避免滥用 panic
### 4. 日志记录
- ✅ 使用结构化日志Zap
- ✅ 日志消息使用中文
- ✅ 敏感信息不输出到日志(如密码)
- ✅ 记录关键操作(创建、更新、删除)
---
## 部署建议
### Docker Compose 部署
```yaml
version: '3.8'
services:
postgres:
image: postgres:14
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: junhong_cmp
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
api:
build: .
command: ./bin/api
ports:
- "8080:8080"
depends_on:
- postgres
- redis
environment:
- CONFIG_ENV=prod
- DB_PASSWORD=${DB_PASSWORD}
worker:
build: .
command: ./bin/worker
depends_on:
- postgres
- redis
environment:
- CONFIG_ENV=prod
- DB_PASSWORD=${DB_PASSWORD}
volumes:
postgres_data:
```
### 生产环境检查清单
- [ ] 使用环境变量存储敏感信息
- [ ] 数据库启用 SSL 连接
- [ ] 配置连接池参数
- [ ] 启用访问日志和错误日志
- [ ] 配置日志轮转(防止磁盘满)
- [ ] 设置健康检查端点
- [ ] 配置优雅关闭SIGTERM
- [ ] 准备数据库备份策略
- [ ] 配置监控和告警
---
## 参考文档
- [功能总结](./功能总结.md) - 功能概述和技术要点
- [架构说明](./架构说明.md) - 系统架构和设计决策
- [Quick Start Guide](../../specs/002-gorm-postgres-asynq/quickstart.md) - 详细的快速开始指南
- [Data Model](../../specs/002-gorm-postgres-asynq/data-model.md) - 数据模型定义
- [Research](../../specs/002-gorm-postgres-asynq/research.md) - 技术研究和决策
---
## 常见问题FAQ
**Q: 如何添加新的数据库表?**
A: 使用 `./scripts/migrate.sh create table_name` 创建迁移文件,编辑 SQL然后运行 `./scripts/migrate.sh up`
**Q: 任务失败后会怎样?**
A: 根据配置自动重试(默认 5 次指数退避。5 次后仍失败会进入死信队列,可在 asynqmon 中查看。
**Q: 如何保证任务幂等性?**
A: 使用 Redis 锁或数据库唯一约束。参考 `internal/task/email.go` 中的实现。
**Q: 如何扩展 Worker**
A: 启动多个 Worker 进程(不同机器或容器),连接同一个 Redis。Asynq 自动负载均衡。
**Q: 如何监控任务执行情况?**
A: 使用 asynqmon Web UI 或通过 Redis CLI 查看队列状态。
---
**文档维护**: 如使用方法有变更,请同步更新本文档。

View File

@@ -1,403 +0,0 @@
# 数据持久化与异步任务处理集成 - 功能总结
**功能编号**: 002-gorm-postgres-asynq
**完成日期**: 2025-11-13
**技术栈**: Go 1.25.4 + GORM + PostgreSQL + Asynq + Redis
---
## 功能概述
本功能为君鸿卡管系统集成了 GORM ORM、PostgreSQL 数据库和 Asynq 异步任务队列,提供了完整的数据持久化和异步任务处理能力。系统采用双服务架构(API 服务 + Worker 服务),实现了可靠的数据存储、异步任务执行和生产级监控。
### 主要能力
1. **数据持久化** (User Story 1 - P1)
- GORM + PostgreSQL 集成
- 完整的 CRUD 操作
- 事务支持
- 数据库迁移管理
- 软删除支持
2. **异步任务处理** (User Story 2 - P2)
- Asynq 任务队列集成
- 任务提交与后台执行
- 自动重试机制 (最大 5 次,指数退避)
- 幂等性保障
- 多优先级队列 (critical, default, low)
3. **监控与运维** (User Story 3 - P3)
- 健康检查接口 (PostgreSQL + Redis)
- 连接池状态监控
- 优雅关闭
- 慢查询日志
---
## 核心实现
### 1. 数据库架构
#### 连接初始化 (`pkg/database/postgres.go`)
```go
// 关键特性:
- 使用 GORM v2 + PostgreSQL 驱动
- 连接池配置: MaxOpenConns=25, MaxIdleConns=10, ConnMaxLifetime=5m
- 预编译语句缓存 (PrepareStmt)
- 集成 Zap 日志
- 连接验证与重试逻辑
```
#### 数据模型设计 (`internal/model/`)
- **BaseModel**: 统一基础模型,包含 ID、CreatedAt、UpdatedAt、DeletedAt (软删除)
- **User**: 用户模型,支持用户名唯一、邮箱唯一、状态管理
- **Order**: 订单模型,外键关联用户,支持状态流转
- **DTO**: 请求/响应数据传输对象,实现前后端解耦
#### 数据访问层 (`internal/store/postgres/`)
- **UserStore**: 用户数据访问 (Create, GetByID, GetByUsername, List, Update, Delete)
- **OrderStore**: 订单数据访问 (Create, GetByID, ListByUserID, Update, Delete)
- **Transaction**: 事务封装,支持自动提交/回滚
#### 数据库迁移 (`migrations/`)
- 使用 golang-migrate 管理 SQL 迁移文件
- 支持版本控制、前滚/回滚
- 包含触发器自动更新 updated_at 字段
### 2. 异步任务架构
#### 任务队列客户端 (`pkg/queue/client.go`)
```go
// 功能:
- 任务提交到 Asynq
- 支持任务优先级配置
- 任务提交日志记录
- 支持自定义重试策略
```
#### 任务队列服务器 (`pkg/queue/server.go`)
```go
// 功能:
- Worker 服务器配置
- 并发控制 (默认 10 worker)
- 队列权重分配 (critical:60%, default:30%, low:10%)
- 错误处理器集成
```
#### 任务处理器 (`internal/task/`)
**邮件任务** (`email.go`):
- Redis 幂等性锁 (SetNX + 24h 过期)
- 支持发送欢迎邮件、密码重置邮件等
- 任务失败自动重试
**数据同步任务** (`sync.go`):
- 批量数据同步 (默认批量大小 100)
- 数据库状态机幂等性
- 支持按日期范围同步
**SIM 卡状态同步** (`sim.go`):
- 批量 ICCID 状态查询
- 支持强制同步模式
- 高优先级队列处理
#### 任务提交服务 (`internal/service/`)
**邮件服务** (`email/service.go`):
- SendWelcomeEmail: 发送欢迎邮件
- SendPasswordResetEmail: 发送密码重置邮件
- SendNotificationEmail: 发送通知邮件
**同步服务** (`sync/service.go`):
- SyncSIMStatus: 同步 SIM 卡状态
- SyncData: 通用数据同步
- SyncFlowUsage: 同步流量数据
- SyncBatchSIMStatus: 批量同步
### 3. 双服务架构
#### API 服务 (`cmd/api/main.go`)
```
职责:
- HTTP API 请求处理
- 用户/订单 CRUD 接口
- 任务提交接口
- 健康检查接口
启动流程:
1. 加载配置 (Viper)
2. 初始化日志 (Zap + Lumberjack)
3. 连接 PostgreSQL
4. 连接 Redis
5. 初始化 Asynq 客户端
6. 注册路由和中间件
7. 启动 Fiber HTTP 服务器
8. 监听优雅关闭信号
```
#### Worker 服务 (`cmd/worker/main.go`)
```
职责:
- 后台任务执行
- 任务处理器注册
- 任务重试管理
启动流程:
1. 加载配置
2. 初始化日志
3. 连接 PostgreSQL
4. 连接 Redis
5. 创建 Asynq Server
6. 注册任务处理器
7. 启动 Worker
8. 监听优雅关闭信号 (等待任务完成,超时 30s)
```
---
## 技术要点
### 1. 幂等性保障
**问题**: 系统重启或任务重试时,避免重复执行
**解决方案**:
- **Redis 锁**: 使用 `SetNX` + 过期时间实现分布式锁
```go
key := constants.RedisTaskLockKey(requestID)
if exists, _ := rdb.Exists(ctx, key).Result(); exists > 0 {
return nil // 跳过已处理的任务
}
// 执行任务...
rdb.SetEx(ctx, key, "1", 24*time.Hour)
```
- **数据库唯一约束**: 业务主键 (如 order_id) 设置唯一索引
- **状态机**: 检查记录状态,仅处理特定状态的任务
### 2. 连接池优化
**PostgreSQL 连接池**:
```yaml
max_open_conns: 25 # 最大连接数
max_idle_conns: 10 # 最大空闲连接
conn_max_lifetime: 5m # 连接最大生命周期
```
**计算公式**:
```
MaxOpenConns = (可用内存 / 10MB) * 0.7
```
**Redis 连接池**:
```yaml
pool_size: 10 # 连接池大小
min_idle_conns: 5 # 最小空闲连接
```
### 3. 错误处理
**分层错误处理**:
- **Store 层**: 返回 GORM 原始错误
- **Service 层**: 转换为业务错误码 (使用 `pkg/errors/`)
- **Handler 层**: 统一响应格式 (使用 `pkg/response/`)
**示例**:
```go
// Service 层
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errors.CodeNotFound, "用户不存在")
}
// Handler 层
return response.Error(c, errors.CodeNotFound, "用户不存在")
```
### 4. 任务重试策略
**配置**:
```yaml
queue:
retry_max: 5 # 最大重试次数
timeout: 10m # 任务超时时间
```
**重试延迟** (指数退避):
```
第 1 次: 1s
第 2 次: 2s
第 3 次: 4s
第 4 次: 8s
第 5 次: 16s
```
### 5. 数据库迁移
**工具**: golang-migrate
**优势**:
- 版本控制: 每个迁移有唯一版本号
- 可回滚: 每个迁移包含 up/down 脚本
- 团队协作: 迁移文件可 code review
**使用**:
```bash
# 向上迁移
./scripts/migrate.sh up
# 回滚最后一次迁移
./scripts/migrate.sh down 1
# 创建新迁移
./scripts/migrate.sh create add_sim_table
```
### 6. 监控与可观测性
**健康检查** (`/health`):
```json
{
"status": "healthy",
"timestamp": "2025-11-13T12:00:00+08:00",
"services": {
"postgres": {
"status": "up",
"open_conns": 5,
"in_use": 2,
"idle": 3
},
"redis": {
"status": "up",
"total_conns": 10,
"idle_conns": 7
}
}
}
```
**日志监控**:
- 访问日志: 所有 HTTP 请求 (包含请求/响应体)
- 慢查询日志: 数据库查询 > 100ms
- 任务执行日志: 任务提交、执行、失败日志
---
## 性能指标
根据 Constitution 性能要求,系统达到以下指标:
| 指标 | 目标 | 实际 |
|------|------|------|
| API 响应时间 P95 | < 200ms | ✓ |
| API 响应时间 P99 | < 500ms | ✓ |
| 数据库查询时间 | < 50ms | ✓ |
| 任务处理速率 | >= 100 tasks/s | ✓ |
| 任务提交延迟 | < 100ms | ✓ |
| 数据持久化可靠性 | >= 99.99% | ✓ |
| 系统启动时间 | < 10s | ✓ |
---
## 安全特性
1. **SQL 注入防护**: GORM 自动使用预编译语句
2. **密码存储**: bcrypt 哈希加密
3. **敏感信息保护**: 密码字段不返回给客户端 (`json:"-"`)
4. **配置安全**: 生产环境密码使用环境变量
---
## 部署架构
```
┌─────────────┐ ┌─────────────┐
│ Nginx │ ──────> │ API 服务 │
│ (负载均衡) │ │ (Fiber:8080)│
└─────────────┘ └──────┬──────┘
┌──────────┼──────────┐
│ │ │
┌─────▼────┐ ┌──▼───────┐ ┌▼────────┐
│PostgreSQL│ │ Redis │ │ Worker │
│ (主库) │ │(任务队列) │ │(后台任务)│
└──────────┘ └──────────┘ └─────────┘
```
**扩展方案**:
- API 服务: 水平扩展多实例
- Worker 服务: 水平扩展多实例 (Asynq 自动负载均衡)
- PostgreSQL: 主从复制 + 读写分离
- Redis: 哨兵模式或集群模式
---
## 依赖版本
```
go.mod:
- Go 1.25.4
- gorm.io/gorm v1.25.5
- gorm.io/driver/postgres v1.5.4
- github.com/hibiken/asynq v0.24.1
- github.com/gofiber/fiber/v2 v2.52.0
- github.com/redis/go-redis/v9 v9.3.1
- go.uber.org/zap v1.26.0
- github.com/spf13/viper v1.18.2
- gopkg.in/natefinch/lumberjack.v2 v2.2.1
```
---
## 已知限制
1. **数据库密码**: 当前配置文件中明文存储,生产环境应使用环境变量或密钥管理服务
2. **任务监控**: 未集成 Prometheus 指标,建议后续添加
3. **分布式追踪**: 未集成 OpenTelemetry,建议后续添加
4. **数据库连接池**: 固定配置,未根据负载动态调整
---
## 后续优化建议
1. **性能优化**:
- 添加 Redis 缓存层 (减少数据库查询)
- 实现查询结果分页缓存
- 优化慢查询 (添加索引)
2. **可靠性提升**:
- PostgreSQL 主从复制
- Redis 哨兵模式
- 任务队列死信队列处理
3. **监控增强**:
- 集成 Prometheus + Grafana
- 添加告警规则 (数据库连接数、任务失败率)
- 分布式追踪 (OpenTelemetry)
4. **安全加固**:
- 使用 Vault 管理密钥
- API 接口添加 HTTPS
- 数据库连接启用 SSL
---
## 参考文档
- [架构说明](./架构说明.md)
- [使用指南](./使用指南.md)
- [Quick Start Guide](../../specs/002-gorm-postgres-asynq/quickstart.md)
- [项目 Constitution](../../.specify/memory/constitution.md)
---
**文档维护**: 如功能有重大更新,请同步更新本文档。

View File

@@ -1,352 +0,0 @@
# 数据持久化与异步任务处理集成 - 架构说明
**功能编号**: 002-gorm-postgres-asynq
**更新日期**: 2025-11-13
---
## 系统架构概览
```
┌─────────────────┐
│ Load Balancer │
│ (Nginx) │
└────────┬────────┘
┌────────────────┼────────────────┐
│ │ │
┌─────────▼────────┐ ┌───▼──────────┐ ┌──▼─────────┐
│ API Server 1 │ │ API Server 2 │ │ API N │
│ (Fiber:8080) │ │(Fiber:8080) │ │(Fiber:8080)│
└─────────┬────────┘ └───┬──────────┘ └──┬─────────┘
│ │ │
└──────────────┼───────────────┘
┌──────────────┼──────────────┐
│ │ │
┌─────▼────┐ ┌───▼───────┐ ┌───▼─────────┐
│PostgreSQL│ │ Redis │ │Worker Cluster│
│ (Primary)│ │ (Queue) │ │ (Asynq) │
└────┬─────┘ └───────────┘ └─────────────┘
┌──────▼──────┐
│ PostgreSQL │
│ (Replica) │
└─────────────┘
```
---
## 双服务架构
### API 服务 (cmd/api/)
**职责**:
- HTTP 请求处理
- 业务逻辑执行
- 数据库 CRUD 操作
- 任务提交到队列
**特点**:
- 无状态设计,支持水平扩展
- RESTful API 设计
- 统一错误处理和响应格式
- 集成认证、限流、日志中间件
### Worker 服务 (cmd/worker/)
**职责**:
- 从队列消费任务
- 执行后台异步任务
- 任务重试管理
- 幂等性保障
**特点**:
- 多实例部署,自动负载均衡
- 支持多优先级队列
- 优雅关闭(等待任务完成)
- 可配置并发数
---
## 分层架构
### Handler 层 (internal/handler/)
**职责**: HTTP 请求处理
```
- 请求参数验证
- 调用 Service 层
- 响应封装
- 错误处理
```
**设计原则**:
- 不包含业务逻辑
- 薄层设计
- 统一使用 pkg/response/
### Service 层 (internal/service/)
**职责**: 业务逻辑
```
- 业务规则实现
- 跨模块协调
- 事务管理
- 错误转换
```
**设计原则**:
- 可复用的业务逻辑
- 支持依赖注入
- 使用 pkg/errors/ 错误码
### Store 层 (internal/store/)
**职责**: 数据访问
```
- CRUD 操作
- 查询构建
- 事务封装
- 数据库交互
```
**设计原则**:
- 只返回 GORM 原始错误
- 不包含业务逻辑
- 支持事务传递
### Model 层 (internal/model/)
**职责**: 数据模型定义
```
- 实体定义
- DTO 定义
- 验证规则
```
---
## 数据流
### CRUD 操作流程
```
HTTP Request
Handler (参数验证)
Service (业务逻辑)
Store (数据访问)
PostgreSQL
Store (返回数据)
Service (转换)
Handler (响应)
HTTP Response
```
### 异步任务流程
```
HTTP Request (任务提交)
Handler
Service (构造 Payload)
Queue Client (Asynq)
Redis (持久化)
Worker (消费任务)
Task Handler (执行任务)
PostgreSQL/外部服务
```
---
## 核心设计决策
### 1. 为什么使用 GORM?
**优势**:
- Go 生态最成熟的 ORM
- 自动参数化查询(防 SQL 注入)
- 预编译语句缓存
- 软删除支持
- 钩子函数支持
### 2. 为什么使用 golang-migrate?
**理由**:
- 版本控制: 每个迁移有版本号
- 可回滚: up/down 脚本
- 团队协作: 迁移文件可 review
- 生产安全: 明确的 SQL 语句
**不用 GORM AutoMigrate**:
- 无法回滚
- 无法删除列
- 生产环境风险高
### 3. 为什么使用 Asynq?
**优势**:
- 基于 Redis无需额外中间件
- 任务持久化(系统重启自动恢复)
- 自动重试(指数退避)
- Web UI 监控asynqmon
- 分布式锁支持
---
## 关键技术实现
### 幂等性设计
**方案 1: Redis 锁**
```go
key := constants.RedisTaskLockKey(requestID)
if exists, _ := rdb.SetNX(ctx, key, "1", 24*time.Hour).Result(); !exists {
return nil // 跳过重复任务
}
```
**方案 2: 数据库唯一约束**
```sql
CREATE UNIQUE INDEX idx_order_id ON tb_order(order_id);
```
**方案 3: 状态机**
```go
if order.Status != "pending" {
return nil // 状态不匹配,跳过
}
```
### 事务管理
```go
func (s *Store) Transaction(ctx context.Context, fn func(*Store) error) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txStore := &Store{db: tx, logger: s.logger}
return fn(txStore)
})
}
```
### 连接池配置
**PostgreSQL**:
- MaxOpenConns: 25最大连接
- MaxIdleConns: 10空闲连接
- ConnMaxLifetime: 5m连接生命周期
**Redis**:
- PoolSize: 10
- MinIdleConns: 5
---
## 扩展性设计
### 水平扩展
**API 服务**:
- 无状态设计
- 通过负载均衡器分发请求
- 自动扩缩容K8s HPA
**Worker 服务**:
- 多实例连接同一 Redis
- Asynq 自动负载均衡
- 按队列权重分配任务
### 数据库扩展
**读写分离**:
```
Primary (写) → Replica (读)
```
**分库分表**:
- 按业务模块垂直分库
- 按数据量水平分表
---
## 监控与可观测性
### 健康检查
- PostgreSQL Ping
- Redis Ping
- 连接池状态
### 日志
- 访问日志: 所有 HTTP 请求
- 错误日志: 错误详情
- 慢查询日志: > 100ms
- 任务日志: 提交/执行/失败
### 指标(建议)
- API 响应时间
- 数据库连接数
- 任务队列长度
- 任务失败率
---
## 安全设计
### 数据安全
- SQL 注入防护GORM 参数化)
- 密码哈希bcrypt
- 敏感字段不返回(`json:"-"`
### 配置安全
- 生产环境使用环境变量
- 数据库 SSL 连接
- Redis 密码认证
---
## 性能优化
### 数据库
- 适当索引
- 批量操作
- 分页查询
- 慢查询监控
### 任务队列
- 优先级队列
- 并发控制
- 超时设置
- 幂等性保障
---
## 参考文档
- [功能总结](./功能总结.md)
- [使用指南](./使用指南.md)
- [项目 Constitution](../../.specify/memory/constitution.md)

View File

@@ -1,886 +0,0 @@
# 使用指南Fiber 错误处理集成
**功能编号**: 003-error-handling
**版本**: 1.0.0
**更新日期**: 2025-11-15
## 目录
1. [快速开始](#快速开始)
2. [错误码参考](#错误码参考)
3. [Handler 中使用错误](#handler-中使用错误)
4. [客户端错误处理](#客户端错误处理)
5. [错误日志查询](#错误日志查询)
6. [最佳实践](#最佳实践)
7. [常见问题](#常见问题)
---
## 快速开始
### 1. 在 Handler 中返回错误
```go
package handler
import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
)
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
userID := c.Params("id")
// 参数验证失败
if userID == "" {
return errors.New(errors.CodeInvalidParam, "用户 ID 不能为空")
}
// 调用服务层
user, err := h.service.GetByID(c.Context(), userID)
if err != nil {
// 包装底层错误
return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
}
// 资源未找到
if user == nil {
return errors.New(errors.CodeNotFound, "用户不存在")
}
return response.Success(c, user)
}
```
### 2. 错误响应格式
所有错误自动转换为统一格式:
```json
{
"code": 1001,
"data": null,
"msg": "参数验证失败",
"timestamp": "2025-11-15T10:00:00+08:00"
}
```
HTTP Header 中包含 Request ID
```
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
```
---
## 错误码参考
### 成功
| 错误码 | 名称 | HTTP 状态 | 消息 |
|--------|------|-----------|------|
| 0 | CodeSuccess | 200 | 操作成功 |
### 客户端错误 (1000-1999)
| 错误码 | 名称 | HTTP 状态 | 消息 | 使用场景 |
|--------|------|-----------|------|----------|
| 1001 | CodeInvalidParam | 400 | 参数验证失败 | 请求参数格式错误、必填字段缺失 |
| 1002 | CodeMissingToken | 401 | 缺少认证令牌 | 未提供 Token |
| 1003 | CodeInvalidToken | 401 | 无效的认证令牌 | Token 格式错误 |
| 1004 | CodeInvalidCredentials | 401 | 认证凭证无效 | Token 过期、验证失败 |
| 1005 | CodeForbidden | 403 | 禁止访问 | 无权限访问资源 |
| 1006 | CodeNotFound | 404 | 资源未找到 | 用户、订单等资源不存在 |
| 1007 | CodeConflict | 409 | 资源冲突 | 唯一性约束冲突 |
| 1008 | CodeTooManyRequests | 429 | 请求过多 | 触发限流 |
| 1009 | CodeRequestEntityTooLarge | 413 | 请求体过大 | 文件上传超限 |
#### 财务相关错误 (1050-1069)
| 错误码 | 名称 | HTTP 状态 | 消息 | 使用场景 |
|--------|------|-----------|------|----------|
| 1050 | CodeInvalidStatus | 400 | 状态不允许此操作 | 资源状态不允许执行当前操作 |
| 1051 | CodeInsufficientBalance | 400 | 余额不足 | 钱包余额不足以完成操作 |
| 1052 | CodeWithdrawalNotFound | 404 | 提现申请不存在 | 提现记录未找到 |
| 1053 | CodeWalletNotFound | 404 | 钱包不存在 | 钱包记录未找到 |
| 1054 | CodeInsufficientQuota | 400 | 额度不足 | 套餐分配额度不足 |
| 1055 | CodeExceedLimit | 400 | 超过限制 | 超过系统限制(如设备绑定卡数) |
### 服务端错误 (2000-2999)
| 错误码 | 名称 | HTTP 状态 | 消息 | 使用场景 |
|--------|------|-----------|------|----------|
| 2001 | CodeInternalError | 500 | 内部服务器错误 | 未分类的内部错误 |
| 2002 | CodeDatabaseError | 500 | 数据库错误 | 数据库连接失败、查询错误 |
| 2003 | CodeCacheError | 500 | 缓存服务错误 | Redis 连接失败 |
| 2004 | CodeServiceUnavailable | 503 | 服务暂时不可用 | 外部服务不可用 |
| 2005 | CodeTimeout | 504 | 请求超时 | 上游服务超时 |
| 2006 | CodeQueueError | 500 | 任务队列错误 | Asynq 任务投递失败 |
---
## Handler 中使用错误
### 1. 参数验证错误
```go
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
var req CreateUserRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式错误")
}
// 业务验证
if len(req.Username) < 3 || len(req.Username) > 20 {
return errors.New(errors.CodeInvalidParam, "用户名长度必须在 3-20 个字符之间")
}
if !isValidEmail(req.Email) {
return errors.New(errors.CodeInvalidParam, "邮箱格式不正确")
}
// 继续处理...
}
```
### 2. 认证/授权错误
```go
func (h *OrderHandler) GetOrder(c *fiber.Ctx) error {
// 检查用户是否登录
userID := c.Locals("user_id")
if userID == nil {
return errors.New(errors.CodeMissingToken, "请先登录")
}
order, err := h.service.GetByID(c.Params("id"))
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "查询订单失败", err)
}
// 检查权限
if order.UserID != userID.(string) {
return errors.New(errors.CodeForbidden, "无权访问此订单")
}
return response.Success(c, order)
}
```
### 3. 资源未找到
```go
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
user, err := h.service.GetByID(c.Params("id"))
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
}
if user == nil {
return errors.New(errors.CodeNotFound, fmt.Sprintf("用户 ID %s 不存在", c.Params("id")))
}
return response.Success(c, user)
}
```
### 4. 资源冲突
```go
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
var req CreateUserRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数错误")
}
// 检查用户名是否已存在
exists, err := h.service.ExistsByUsername(req.Username)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "检查用户名失败", err)
}
if exists {
return errors.New(errors.CodeConflict, "用户名已被使用")
}
// 创建用户...
}
```
### 5. 外部服务错误
```go
func (h *NotificationHandler) SendEmail(c *fiber.Ctx) error {
var req SendEmailRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数错误")
}
// 调用外部邮件服务
err := h.emailService.Send(req.To, req.Subject, req.Body)
if err != nil {
// 包装外部服务错误
return errors.Wrap(errors.CodeServiceUnavailable, "邮件发送失败", err)
}
return response.Success(c, nil)
}
```
### 6. 自定义 HTTP 状态码(高级用法)
```go
func (h *Handler) SpecialCase(c *fiber.Ctx) error {
// 默认 CodeInvalidParam 映射为 400
// 但某些场景需要返回 422
appErr := errors.New(errors.CodeInvalidParam, "数据验证失败")
appErr = appErr.WithHTTPStatus(422)
return appErr
}
```
---
## Handler 层参数校验安全实践
### ❌ 错误示例:泄露内部细节
```go
func (h *ShopHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopRequest
// ❌ 错误:直接暴露解析错误
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数解析失败: "+err.Error())
// 可能泄露json: cannot unmarshal number into Go struct field CreateShopRequest.ShopCode of type string
}
// ❌ 错误:直接暴露 validator 错误
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
// 可能泄露Key: 'CreateShopRequest.ShopName' Error:Field validation for 'ShopName' failed on the 'required' tag
}
// ...
}
```
**安全风险**
- 泄露内部字段名ShopCode、ShopName
- 泄露数据类型string、number
- 泄露验证规则required、min、max 等)
- 攻击者可根据错误消息推断 API 内部结构
### ✅ 正确示例:安全的参数校验
```go
func (h *ShopHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopRequest
// ✅ 正确:通用错误消息 + 结构化日志WARN 级别)
if err := c.BodyParser(&req); err != nil {
logger.GetAppLogger().Warn("参数解析失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam, "请求参数格式错误")
}
// ✅ 正确:使用默认消息 + 结构化日志WARN 级别)
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) // 使用默认消息
}
// 业务逻辑...
shop, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, shop)
}
```
**安全优势**
- 对外:统一返回通用消息("参数验证失败"
- 日志:记录详细错误信息用于排查
- 包含 request_id便于日志关联和问题追踪
### 单元测试示例
```go
func TestShopHandler_Create_ParamValidation(t *testing.T) {
// 准备测试环境
app := fiber.New()
handler := NewShopHandler(mockService, mockValidator, logger)
app.Post("/shops", handler.Create)
tests := []struct {
name string
requestBody string
expectedCode int
expectedMsg string
}{
{
name: "参数解析失败",
requestBody: `{"shop_code": 123}`, // 类型错误
expectedCode: errors.CodeInvalidParam,
expectedMsg: "请求参数格式错误",
},
{
name: "必填字段缺失",
requestBody: `{"shop_code": ""}`, // ShopName 缺失
expectedCode: errors.CodeInvalidParam,
expectedMsg: "参数验证失败",
},
{
name: "正常请求",
requestBody: `{"shop_code": "SH001", "shop_name": "测试店铺"}`,
expectedCode: errors.CodeSuccess,
expectedMsg: "操作成功",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/shops", strings.NewReader(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Equal(t, tt.expectedCode, int(result["code"].(float64)))
assert.Equal(t, tt.expectedMsg, result["msg"])
// ✅ 验证:错误消息不泄露内部细节
assert.NotContains(t, result["msg"], "ShopCode")
assert.NotContains(t, result["msg"], "ShopName")
assert.NotContains(t, result["msg"], "required")
})
}
}
```
---
## 客户端错误处理
### JavaScript/TypeScript
```typescript
async function fetchUser(userId: string) {
try {
const response = await fetch(`/api/v1/users/${userId}`);
const data = await response.json();
// 检查业务错误码
if (data.code !== 0) {
const requestId = response.headers.get('X-Request-ID');
switch (data.code) {
// 认证错误 - 跳转登录
case 1002:
case 1003:
case 1004:
redirectToLogin();
break;
// 权限错误 - 显示无权限提示
case 1005:
showError('您没有权限访问此资源');
break;
// 资源未找到 - 显示 404 页面
case 1006:
showNotFoundPage();
break;
// 服务端错误 - 显示错误并提供 Request ID
case 2001:
case 2002:
case 2003:
case 2004:
showError(`服务器错误请联系管理员。Request ID: ${requestId}`);
break;
// 限流错误 - 提示稍后重试
case 1008:
showError('请求过于频繁,请稍后再试');
break;
// 其他错误 - 显示错误消息
default:
showError(data.msg);
}
return null;
}
return data.data;
} catch (err) {
// 网络错误
showError('网络连接失败,请检查您的网络');
return null;
}
}
```
### Axios 拦截器
```typescript
import axios from 'axios';
const api = axios.create({
baseURL: '/api/v1',
});
// 响应拦截器
api.interceptors.response.use(
(response) => {
const { code, data, msg } = response.data;
if (code !== 0) {
const requestId = response.headers['x-request-id'];
// 根据错误码处理
if ([1002, 1003, 1004].includes(code)) {
// 认证失败,跳转登录
redirectToLogin();
return Promise.reject(new Error(msg));
}
if (code === 1005) {
// 权限不足
showError('您没有权限执行此操作');
return Promise.reject(new Error(msg));
}
if (code >= 2000) {
// 服务端错误
console.error(`Server error: ${msg}, Request ID: ${requestId}`);
showError(`服务器错误Request ID: ${requestId}`);
return Promise.reject(new Error(msg));
}
// 其他业务错误
showError(msg);
return Promise.reject(new Error(msg));
}
return data;
},
(error) => {
// 网络错误
showError('网络连接失败');
return Promise.reject(error);
}
);
```
---
## 错误日志查询
### 1. 通过 Request ID 查询
```bash
# 查询特定请求的所有日志
grep "550e8400-e29b-41d4-a716-446655440000" logs/app.log
# 使用 jq 格式化 JSON 日志
grep "550e8400-e29b-41d4-a716-446655440000" logs/app.log | jq .
```
### 2. 查询特定错误码
```bash
# 查询所有参数验证失败的错误
grep '"error_code":1001' logs/app.log | jq .
# 查询所有数据库错误
grep '"error_code":2002' logs/app.log | jq .
```
### 3. 查询 Panic 堆栈
```bash
# 查询所有 panic 日志
grep "panic recovered" logs/app.log
# 查询包含堆栈的完整 panic 日志
grep -A 20 "panic recovered" logs/app.log
```
### 4. 按时间范围查询
```bash
# 查询最近 1 小时的错误日志
grep '"level":"error"' logs/app.log | grep "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H')"
```
---
## 最佳实践
### 1. 错误码选择
**正确示例**
```go
// 参数验证失败
return errors.New(errors.CodeInvalidParam, "用户名不能为空")
// 资源未找到
return errors.New(errors.CodeNotFound, "订单不存在")
// 数据库错误
return errors.Wrap(errors.CodeDatabaseError, "查询失败", err)
```
**错误示例**
```go
// 不要使用错误的错误码
return errors.New(errors.CodeDatabaseError, "用户名不能为空") // 应该用 CodeInvalidParam
// 不要返回空消息
return errors.New(errors.CodeNotFound, "") // 应该提供具体消息
```
### 2. 参数校验安全加固(重要)
**正确示例**
```go
// 参数解析失败
if err := c.BodyParser(&req); err != nil {
logger.GetAppLogger().Warn("参数解析失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
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) // 使用默认消息
}
```
**错误示例 - 泄露内部细节**
```go
// ❌ 危险:泄露 validator 规则和字段名
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
// 可能返回:"Field validation for 'Username' failed on the 'required' tag"
// ❌ 危险:泄露类型信息
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数解析失败: "+err.Error())
}
// 可能返回:"Unmarshal type error: expected=uint got=string field=shop_id"
```
**安全原则**
- 对外统一返回通用消息("参数验证失败"
- 详细错误信息仅记录到日志
- 使用 WARN 级别(客户端错误)
- 必须包含请求上下文path、method
### 3. 错误消息编写
**正确示例**
```go
// 清晰、具体的错误消息(不泄露内部细节)
errors.New(errors.CodeInvalidParam, "用户名长度必须在 3-20 个字符之间")
errors.New(errors.CodeNotFound, "用户不存在")
errors.New(errors.CodeConflict, "邮箱已被注册")
```
**错误示例**
```go
// 不要使用模糊的消息
errors.New(errors.CodeInvalidParam, "错误")
errors.New(errors.CodeNotFound, "not found")
// 不要暴露敏感信息和内部细节
errors.New(errors.CodeDatabaseError, "SQL error: SELECT * FROM users WHERE password = '...'")
errors.New(errors.CodeInvalidParam, "Field 'Username' validation failed") // 泄露字段名
```
### 3. 错误包装
**正确示例**
```go
// 包装底层错误,保留错误链
user, err := h.repo.GetByID(id)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
}
```
**错误示例**
```go
// 丢失原始错误信息
user, err := h.repo.GetByID(id)
if err != nil {
return errors.New(errors.CodeDatabaseError, "查询用户失败") // 应该用 Wrap
}
```
### 4. 不要过度处理错误
**正确示例**
```go
func (h *Handler) GetUser(c *fiber.Ctx) error {
user, err := h.service.GetByID(c.Params("id"))
if err != nil {
// 直接返回错误,让 ErrorHandler 统一处理
return err
}
return response.Success(c, user)
}
```
**错误示例**
```go
func (h *Handler) GetUser(c *fiber.Ctx) error {
user, err := h.service.GetByID(c.Params("id"))
if err != nil {
// 不要在 Handler 中手动构造错误响应
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return response.Success(c, user)
}
```
### 5. Panic 使用建议
**正确做法**
```go
// 让代码正常返回错误,不要主动 panic
func (s *Service) Process() error {
if invalidState {
return errors.New(errors.CodeInternalError, "无效状态")
}
return nil
}
```
**避免使用**
```go
// 避免在业务代码中主动 panic
func (s *Service) Process() {
if invalidState {
panic("invalid state") // 不推荐
}
}
```
**注意**:即使代码中有 panicRecover 中间件也会自动捕获并转换为错误响应,确保服务不崩溃。
---
## 常见问题
### Q1: 如何自定义错误消息?
A: 使用 `errors.New()` 的第二个参数:
```go
return errors.New(errors.CodeInvalidParam, "自定义错误消息")
```
### Q2: 如何查看底层错误详情?
A: 底层错误会记录在日志中,通过 Request ID 查询:
```bash
grep "<request-id>" logs/app.log | jq .
```
### Q3: 客户端如何获取 Request ID
A: 从响应 Header 中获取:
```javascript
const requestId = response.headers.get('X-Request-ID');
```
### Q4: 错误码冲突怎么办?
A: 参考 `pkg/errors/codes.go` 中的定义,避免使用已定义的错误码。如需新增错误码,请在对应范围内添加。
### Q5: 如何测试错误处理?
A: 参考 `tests/integration/error_handler_test.go` 中的示例:
```go
resp, _ := app.Test(httptest.NewRequest("GET", "/api/v1/users/invalid", nil))
assert.Equal(t, 400, resp.StatusCode)
```
### Q6: 如何关闭堆栈跟踪?
A: 堆栈跟踪仅在 panic 时记录,无法关闭。如需调整,修改 `internal/middleware/recover.go`
---
## 更多信息
- [功能总结](./功能总结.md) - 功能概述和技术要点
- [架构说明](./架构说明.md) - 错误处理架构设计
- [错误码定义](../../pkg/errors/codes.go) - 完整错误码列表
---
## Service 层错误处理实战案例
### 案例 1套餐服务 - 资源查询
**场景**:获取套餐详情,需处理不存在和数据库错误
```go
// internal/service/package/service.go
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
// ✅ 业务错误:资源不存在
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
// ✅ 系统错误:数据库查询失败
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
return s.toResponse(ctx, pkg), nil
}
```
**错误返回示例**
- 套餐不存在404
```json
{"code": 1006, "msg": "套餐不存在", "data": null}
```
- 数据库错误500
```json
{"code": 2001, "msg": "内部服务器错误", "data": null}
```
日志中记录详细错误:`获取套餐失败: connection refused`
### 案例 2分佣提现 - 复杂业务校验
**场景**:提现审核,需验证余额、状态等
```go
// internal/service/commission_withdrawal/service.go
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdrawalReq) (*dto.WithdrawalApprovalResp, error) {
// ✅ 业务错误:资源不存在
withdrawal, err := s.commissionWithdrawalReqStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
// ✅ 业务错误:状态不允许
if withdrawal.Status != constants.WithdrawalStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
}
// ✅ 业务错误:余额不足
wallet, err := s.walletStore.GetShopCommissionWallet(ctx, withdrawal.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
}
if wallet.FrozenBalance < amount {
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
}
// ✅ 系统错误:事务执行失败
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := s.walletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败")
}
// ...其他事务操作
return nil
})
if err != nil {
return nil, err
}
return &dto.WithdrawalApprovalResp{...}, nil
}
```
### 案例 3店铺管理 - 重复性检查
**场景**:创建店铺,需检查代码重复和层级限制
```go
// internal/service/shop/service.go
func (s *Service) Create(ctx context.Context, req *dto.CreateShopRequest) (*dto.ShopResponse, error) {
// ✅ 业务错误:重复检查
existing, _ := s.shopStore.GetByCode(ctx, req.ShopCode)
if existing != nil {
return nil, errors.New(errors.CodeDuplicate, "店铺代码已存在")
}
// ✅ 业务错误:层级限制
level := 1
if req.ParentID != nil {
parent, err := s.shopStore.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "上级店铺不存在")
}
level = parent.Level + 1
if level > 7 {
return nil, errors.New(errors.CodeInvalidParam, "店铺层级超过限制")
}
}
// ✅ 系统错误:数据库操作
shop := &model.Shop{...}
if err := s.shopStore.Create(ctx, shop); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败")
}
return s.toResponse(shop), nil
}
```
### 错误处理原则总结
| 场景类型 | 使用方式 | HTTP 状态码 | 示例 |
|---------|---------|-----------|------|
| 资源不存在 | `errors.New(CodeNotFound)` | 404 | 套餐、店铺、用户不存在 |
| 状态不允许 | `errors.New(CodeInvalidStatus)` | 400 | 订单已取消、提现已审核 |
| 参数错误 | `errors.New(CodeInvalidParam)` | 400 | 层级超限、金额无效 |
| 重复操作 | `errors.New(CodeDuplicate)` | 409 | 代码重复、用户名已存在 |
| 余额不足 | `errors.New(CodeInsufficientBalance)` | 400 | 钱包余额不足 |
| 数据库错误 | `errors.Wrap(CodeInternalError, err)` | 500 | 查询失败、创建失败 |
| 队列错误 | `errors.Wrap(CodeInternalError, err)` | 500 | 任务提交失败 |
**核心原则**
1. 业务错误4xx使用 `errors.New(Code4xx, msg)`
2. 系统错误5xx使用 `errors.Wrap(Code5xx, err, msg)`
3. 错误消息保持中文,便于日志排查
4. 禁止 `fmt.Errorf` 直接对外返回,避免泄露内部细节
---
**版本历史**:
- v1.1.0 (2026-01-29): 补充 Service 层错误处理实战案例
- v1.0.0 (2025-11-15): 初始版本

View File

@@ -1,253 +0,0 @@
# 功能总结Fiber 错误处理集成
**功能编号**: 003-error-handling
**完成日期**: 2025-11-15
**版本**: 1.0.0
## 功能概述
本功能为君鸿卡管系统实现了统一的错误处理机制,包括:
1. **统一错误响应格式**:所有 API 错误返回一致的 JSON 格式
2. **Panic 自动恢复**:捕获所有 panic 异常,防止服务崩溃
3. **错误分类处理**区分客户端错误4xx和服务端错误5xx记录相应日志级别
4. **敏感信息保护**:所有内部错误隐藏实现细节,仅返回通用消息
5. **完整错误追踪**:通过 Request ID 关联请求和错误日志
## 核心实现
### 1. 错误码系统
**文件**: `pkg/errors/codes.go`
定义了完整的错误码枚举:
- **成功**: `CodeSuccess = 0`
- **客户端错误 (1000-1999)**: 参数验证失败、认证失败、资源未找到等
- **服务端错误 (2000-2999)**: 内部错误、数据库错误、服务不可用等
核心函数:
- `GetMessage(code, lang)`: 获取错误码对应的中文消息
- `GetHTTPStatus(code)`: 将错误码映射为 HTTP 状态码
- `GetLogLevel(code)`: 将错误码映射为日志级别warn/error
### 2. 错误类型
**文件**: `pkg/errors/errors.go`
```go
type AppError struct {
Code int // 应用错误码
Message string // 错误消息(用户可见)
HTTPStatus int // HTTP 状态码(自动映射)
Err error // 底层错误(可选,用于错误链)
}
```
构造函数:
- `New(code, message)`: 创建新错误
- `Wrap(code, message, err)`: 包装现有错误
- `WithHTTPStatus(status)`: 覆盖默认 HTTP 状态码
### 3. 全局错误处理器
**文件**: `pkg/errors/handler.go`
`SafeErrorHandler()` 实现了 Fiber 全局 ErrorHandler功能包括
1. **响应状态检查**:判断响应是否已发送,避免重复修改
2. **错误类型分类**
- `*AppError`: 应用自定义错误
- `*fiber.Error`: Fiber 框架错误
- 其他 `error`: 默认为内部错误
3. **敏感信息脱敏**:所有 5xx 错误返回通用消息
4. **请求上下文记录**:提取 Request ID、路径、方法等
5. **日志级别控制**:客户端错误 Warn服务端错误 Error
6. **自身保护**:使用 defer/recover 防止 ErrorHandler 自身 panic
### 4. Panic 恢复中间件
**文件**: `internal/middleware/recover.go`
增强的 Recover 中间件:
1. **完整堆栈跟踪**:使用 `runtime/debug.Stack()` 捕获堆栈
2. **转换为 AppError**:将 panic 转换为可控错误
3. **与 ErrorHandler 集成**panic 统一由 ErrorHandler 处理
4. **服务稳定性**:单个请求 panic 不影响其他请求
### 5. 错误上下文
**文件**: `pkg/errors/context.go`
`ErrorContext` 结构体包含:
- Request ID、HTTP 方法、路径
- Query 参数、客户端 IP、User-Agent
- User ID如果已认证
`FromFiberContext()` 从 Fiber 上下文自动提取
`ToLogFields()` 转换为 Zap 日志字段
## 技术要点
### 1. 循环导入处理
**问题**: `pkg/errors/handler.go` 导入 `pkg/response`,而 `pkg/response` 已导入 `pkg/errors`
**解决方案**: ErrorHandler 直接使用 `fiber.Map` 构造 JSON 响应,避免依赖 `pkg/response`
### 2. 错误响应格式
所有错误响应统一格式:
```json
{
"code": 1001,
"data": null,
"msg": "参数验证失败",
"timestamp": "2025-11-15T10:00:00+08:00"
}
```
Request ID 在响应 Header 中:`X-Request-ID: uuid`
### 3. 敏感信息保护策略
- **服务端错误 (5xx)**: 始终返回通用消息(如"内部服务器错误"
- **客户端错误 (4xx)**: 可返回具体业务错误(如"用户名不能为空"
- **原始错误详情**: 仅记录到日志,不返回给客户端
### 4. 日志级别映射
| 错误码范围 | 日志级别 | HTTP 状态码 | 说明 |
|-----------|---------|------------|------|
| 0 | Info | 200 | 成功 |
| 1000-1999 | Warn | 4xx | 客户端错误 |
| 2000-2999 | Error | 5xx | 服务端错误 |
### 5. 中间件注册顺序
```go
// 1. Recover - 必须第一个,捕获所有 panic
app.Use(middleware.Recover(logger))
// 2. RequestID - 生成请求 ID
app.Use(requestid.New())
// 3. Logger - 记录请求日志
app.Use(logger.Middleware())
// 4. 其他中间件...
```
ErrorHandler 在 Fiber 配置中注册(不是中间件)
## 使用示例
### 1. Handler 中返回错误
```go
func (h *Handler) CreateUser(c *fiber.Ctx) error {
var req CreateUserRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数格式错误")
}
user, err := h.service.Create(req)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "创建用户失败", err)
}
return response.Success(c, user)
}
```
### 2. 触发 Panic会被自动捕获
```go
func (h *Handler) DangerousOperation(c *fiber.Ctx) error {
// 如果这里发生 panicRecover 中间件会捕获
result := riskyFunction()
return response.Success(c, result)
}
```
### 3. 客户端处理错误
```typescript
const response = await fetch('/api/v1/users/123');
const data = await response.json();
if (data.code !== 0) {
const requestId = response.headers.get('X-Request-ID');
switch (data.code) {
case 1002:
case 1003:
redirectToLogin();
break;
case 2001:
case 2002:
showError(`服务器错误Request ID: ${requestId}`);
break;
default:
showError(data.msg);
}
}
```
## 性能指标
- **错误处理延迟**: < 1ms (P95)
- **内存开销**: ErrorContext 约 200 bytes
- **日志记录**: 异步,不阻塞响应
## 向后兼容
保留了现有错误常量的别名:
```go
CodeBadRequest = CodeInvalidParam // 兼容旧代码
CodeAuthServiceUnavailable = CodeServiceUnavailable
```
现有 Handler 代码无需修改,自动使用新的错误处理机制。
## 已实现功能
**User Story 1**: 统一错误响应格式
**User Story 2**: Panic 自动恢复
**User Story 3**: 错误分类和日志级别控制
**User Story 4**: 错误追踪(基础功能已实现,完整测试待补充)
## 待完成工作
- [ ] 单元测试T016, T017, T028, T038
- [ ] 集成测试T029-T032, T039-T042, T045-T050, T054-T057
- [ ] 性能基准测试T060-T061
- [ ] 代码质量检查T067-T069
## 文件清单
**新增文件**:
- `pkg/errors/codes.go` - 错误码定义
- `pkg/errors/handler.go` - 全局 ErrorHandler
- `pkg/errors/context.go` - 错误上下文
- `internal/middleware/error_handler.go` - ErrorHandler 包装
**修改文件**:
- `pkg/errors/errors.go` - 扩展 AppError
- `internal/middleware/recover.go` - 增强 Panic 恢复
- `cmd/api/main.go` - 配置 ErrorHandler
## 总结
本功能实现了生产级的错误处理机制,确保:
1. **一致性**:所有 API 错误响应格式统一
2. **稳定性**100% 捕获 panic防止服务崩溃
3. **安全性**:隐藏敏感信息,防止信息泄露
4. **可追踪性**:完整的错误日志和 Request ID 追踪
5. **可维护性**:清晰的错误分类和日志级别
系统已准备好投入生产环境使用。

View File

@@ -1,787 +0,0 @@
# 架构说明Fiber 错误处理集成
**功能编号**: 003-error-handling
**版本**: 1.0.0
**更新日期**: 2025-11-15
## 目录
1. [架构概览](#架构概览)
2. [核心组件](#核心组件)
3. [错误处理流程](#错误处理流程)
4. [设计决策](#设计决策)
5. [性能优化](#性能优化)
6. [扩展性设计](#扩展性设计)
---
## 架构概览
### 整体架构图
```
┌─────────────────────────────────────────────────────────────┐
│ Fiber Application │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Middleware Chain │
│ ┌────────────┐ ┌───────────┐ ┌────────┐ ┌──────────┐ │
│ │ Recover │→ │ RequestID │→ │ Logger │→ │ ... │ │
│ └────────────┘ └───────────┘ └────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Handlers │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ if err != nil { │ │
│ │ return errors.New(code, msg) ──────┐ │ │
│ │ } │ │ │
│ └─────────────────────────────────────────┼────────────┘ │
└──────────────────────────────────────────┼──────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Global ErrorHandler │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 响应状态检查 │ │
│ │ 2. 错误类型分类 (*AppError, *fiber.Error, error) │ │
│ │ 3. 提取错误上下文 (FromFiberContext) │ │
│ │ 4. 错误消息脱敏 (5xx → 通用消息) │ │
│ │ 5. 记录日志 (按级别: Warn/Error) │ │
│ │ 6. 构造 JSON 响应 │ │
│ │ 7. 设置 X-Request-ID Header │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Client Response │
│ { │
│ "code": 1001, │
│ "data": null, │
│ "msg": "参数验证失败", │
│ "timestamp": "2025-11-15T10:00:00+08:00" │
│ } │
│ X-Request-ID: uuid │
└─────────────────────────────────────────────────────────────┘
```
### 数据流图
```
Request
├─→ Recover Middleware ──[panic]──→ AppError(Code2001)
│ │
├─→ RequestID Middleware ──[生成 UUID]───→ c.Locals("requestid")
│ │
├─→ Handler ──[返回错误]──→ AppError/fiber.Error/error
│ │
└───────────────────────────────────────→ ErrorHandler
├─→ ErrorContext.FromFiberContext()
│ (提取 Request ID, 路径, 参数等)
├─→ GetLogLevel(code)
│ (确定日志级别)
├─→ 脱敏逻辑
│ (5xx → "内部服务器错误")
├─→ Logger.Warn/Error()
│ (记录到日志文件)
└─→ c.Status(httpStatus).JSON(response)
(返回统一格式)
```
---
## 核心组件
### 1. 错误码系统 (`pkg/errors/codes.go`)
**职责**: 定义标准错误码和映射规则
**设计原则**:
- 错误码分段管理(成功=0客户端=1xxx服务端=2xxx
- 每个错误码有固定的 HTTP 状态码和日志级别
- 支持多语言错误消息(当前支持中文)
**核心数据结构**:
```go
const (
CodeSuccess = 0
CodeInvalidParam = 1001 // 客户端错误
CodeDatabaseError = 2002 // 服务端错误
)
// 错误消息映射
var errorMessages = map[int]map[string]string{
CodeSuccess: {"zh": "操作成功"},
CodeInvalidParam: {"zh": "参数验证失败"},
}
// HTTP 状态码映射
func GetHTTPStatus(code int) int
// 日志级别映射
func GetLogLevel(code int) string
```
**扩展性**:
- 新增错误码:在对应范围内添加常量和消息映射
- 新增语言:在 `errorMessages` 中添加语言键
---
### 2. 应用错误类型 (`pkg/errors/errors.go`)
**职责**: 封装业务错误,支持错误链
**设计原则**:
- 实现标准 `error` 接口
- 支持错误包装 (`Unwrap()`)
- 自动关联 HTTP 状态码
**核心数据结构**:
```go
type AppError struct {
Code int // 应用错误码
Message string // 用户可见消息
HTTPStatus int // HTTP 状态码(自动映射)
Err error // 底层错误(可选)
}
func (e *AppError) Error() string // 实现 error 接口
func (e *AppError) Unwrap() error // 支持 errors.Unwrap()
func (e *AppError) WithHTTPStatus(int) *AppError // 覆盖状态码
```
**使用模式**:
```go
// 创建新错误
err := errors.New(errors.CodeNotFound, "用户不存在")
// 包装现有错误
err := errors.Wrap(errors.CodeDatabaseError, "查询失败", dbErr)
// 自定义状态码
err := errors.New(errors.CodeInvalidParam, "验证失败").WithHTTPStatus(422)
```
---
### 3. 错误上下文 (`pkg/errors/context.go`)
**职责**: 提取和管理请求上下文信息
**设计原则**:
- 从 Fiber Context 自动提取
- 转换为结构化日志字段
- 包含调试所需的所有信息
**核心数据结构**:
```go
type ErrorContext struct {
RequestID string
Method string
Path string
Query string
IP string
UserAgent string
UserID string // 如果已认证
}
func FromFiberContext(c *fiber.Ctx) *ErrorContext
func (ec *ErrorContext) ToLogFields() []zap.Field
```
**信息提取逻辑**:
```go
RequestID c.Locals("requestid") // 由 RequestID 中间件设置
Method c.Method()
Path c.Path()
Query c.Request().URI().QueryArgs()
IP c.IP()
UserAgent c.Get("User-Agent")
UserID c.Locals("user_id") // 由认证中间件设置
```
---
### 4. 全局错误处理器 (`pkg/errors/handler.go`)
**职责**: 统一处理所有错误,生成标准响应
**设计原则**:
- 单一入口,统一格式
- 自身保护(防止 ErrorHandler panic
- 敏感信息脱敏
**核心逻辑**:
```go
func SafeErrorHandler() fiber.ErrorHandler {
return func(c *fiber.Ctx, err error) error {
defer func() {
if r := recover(); r != nil {
// ErrorHandler 自身保护
fallbackError(c)
}
}()
return handleError(c, err)
}
}
func handleError(c *fiber.Ctx, err error) error {
// 1. 响应状态检查
if c.Response().StatusCode() != fiber.StatusOK {
return nil // 已发送响应,避免重复处理
}
// 2. 错误类型分类
var (
code int
message string
httpStatus int
)
switch e := err.(type) {
case *AppError:
code = e.Code
message = e.Message
httpStatus = e.HTTPStatus
case *fiber.Error:
code = mapHTTPStatusToCode(e.Code)
message = e.Message
httpStatus = e.Code
default:
code = CodeInternalError
message = "内部服务器错误"
httpStatus = 500
}
// 3. 敏感信息脱敏
if httpStatus >= 500 {
message = GetMessage(code, "zh") // 使用通用消息
}
// 4. 提取错误上下文
errCtx := FromFiberContext(c)
// 5. 记录日志
logLevel := GetLogLevel(code)
if logLevel == "error" {
logger.Error("服务端错误", errCtx.ToLogFields()...)
} else {
logger.Warn("客户端错误", errCtx.ToLogFields()...)
}
// 6. 构造响应
response := fiber.Map{
"code": code,
"data": nil,
"msg": message,
"timestamp": time.Now().Format(time.RFC3339),
}
// 7. 设置 Header
c.Set("X-Request-ID", errCtx.RequestID)
return c.Status(httpStatus).JSON(response)
}
```
---
### 5. Panic 恢复中间件 (`internal/middleware/recover.go`)
**职责**: 捕获 panic防止服务崩溃
**设计原则**:
- 第一层防护,必须最先注册
- 完整堆栈跟踪
- 转换为标准错误
**核心逻辑**:
```go
func Recover(logger *zap.Logger) fiber.Handler {
return func(c *fiber.Ctx) error {
defer func() {
if r := recover(); r != nil {
// 1. 捕获堆栈跟踪
stack := debug.Stack()
// 2. 记录详细日志
logger.Error("panic recovered",
zap.Any("panic", r),
zap.String("stack", string(stack)),
zap.String("request_id", c.Locals("requestid").(string)),
)
// 3. 转换为 AppError
err := &errors.AppError{
Code: errors.CodeInternalError,
Message: "服务发生异常",
HTTPStatus: 500,
}
// 4. 委托给 ErrorHandler 处理
c.Next() // 触发 ErrorHandler
}
}()
return c.Next()
}
}
```
---
## 错误处理流程
### 正常错误流程
```
1. Handler 返回错误
2. Fiber 调用 ErrorHandler
3. ErrorHandler 分类错误
4. 提取错误上下文
5. 确定日志级别
6. 脱敏处理(如果是 5xx
7. 记录日志
8. 构造 JSON 响应
9. 返回给客户端
```
### Panic 处理流程
```
1. Handler 发生 panic
2. Recover 中间件捕获
3. 记录完整堆栈到日志
4. 转换为 AppError(Code2001)
5. 委托给 ErrorHandler 处理
6. 返回 500 错误响应
```
### 并发处理保障
```
┌─────────┐ ┌─────────┐ ┌─────────┐
│Request 1│ │Request 2│ │Request 3│
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
├─→ Goroutine 1 ├─→ Goroutine 2 ├─→ Goroutine 3
│ │ │
│ (独立 Fiber Ctx, 独立 defer/recover)
│ │ │
▼ ▼ ▼
正常响应 Panic 捕获 错误响应
```
每个请求在独立的 Goroutine 中处理,拥有独立的:
- Fiber Context
- defer/recover 堆栈
- 错误处理流程
**保证**: 单个请求的 panic 不会影响其他请求。
---
## 设计决策
### 1. 为什么使用错误码而不是 HTTP 状态码?
**问题**: HTTP 状态码不足以表达业务语义
**示例**:
- 400 Bad Request: 参数格式错误?缺失字段?验证失败?
- 401 Unauthorized: 缺少 TokenToken 无效Token 过期?
**解决方案**:
- 引入应用错误码1001, 1002, ...
- 每个错误码有明确的业务含义
- HTTP 状态码仅用于 HTTP 层分类4xx/5xx
**好处**:
- 客户端可精确识别错误类型
- 支持多语言错误消息
- 便于统计和监控
---
### 2. 为什么 ErrorHandler 不依赖 `pkg/response`
**问题**: 循环依赖
```
pkg/response ──imports──> pkg/errors
↑ │
└───────imports───────────┘ (循环!)
```
**解决方案**: ErrorHandler 直接使用 `fiber.Map`
```go
// 不使用 response.Error()
return c.Status(500).JSON(fiber.Map{
"code": code,
"data": nil,
"msg": message,
"timestamp": time.Now().Format(time.RFC3339),
})
```
**好处**:
- 避免循环导入
- 减少依赖耦合
- ErrorHandler 可作为独立模块
---
### 3. 为什么敏感信息只在 5xx 时脱敏?
**原则**: 区分客户端错误和服务端错误
**客户端错误 (4xx)**:
- 由用户行为引起
- 可返回具体业务错误("用户名已存在"
- 不涉及内部实现细节
**服务端错误 (5xx)**:
- 由系统故障引起
- 可能暴露敏感信息(数据库结构、内部路径)
- 必须返回通用消息("内部服务器错误"
**示例**:
```go
// 客户端错误 - 保留原始消息
errors.New(CodeInvalidParam, "用户名长度必须在 3-20 个字符之间")
客户端看到: "用户名长度必须在 3-20 个字符之间"
// 服务端错误 - 脱敏
errors.Wrap(CodeDatabaseError, "查询失败", dbErr)
客户端看到: "数据库错误"
日志记录: "查询失败: connection refused at 127.0.0.1:5432"
```
---
### 4. 为什么使用两层 defer/recover
**第一层**: Recover 中间件 - 捕获业务代码 panic
```go
func Recover() fiber.Handler {
return func(c *fiber.Ctx) error {
defer func() {
if r := recover() { /* 处理 panic */ }
}()
return c.Next()
}
}
```
**第二层**: SafeErrorHandler - 防止 ErrorHandler 自身 panic
```go
func SafeErrorHandler() fiber.ErrorHandler {
return func(c *fiber.Ctx, err error) error {
defer func() {
if r := recover() { /* 降级处理 */ }
}()
return handleError(c, err)
}
}
```
**为什么需要两层**:
- ErrorHandler 在中间件之外执行
- 如果 ErrorHandler panicRecover 中间件无法捕获
- SafeErrorHandler 自我保护,确保 100% 稳定
---
## 性能优化
### 1. 错误码映射优化
**策略**: 使用 `map[int]` 而非 `switch-case`
```go
// 优化前: O(n) 时间复杂度
func GetHTTPStatus(code int) int {
switch code {
case CodeInvalidParam: return 400
case CodeMissingToken: return 401
// ... 16+ cases
}
}
// 优化后: O(1) 时间复杤度
var httpStatusMap = map[int]int{
CodeInvalidParam: 400,
CodeMissingToken: 401,
// ...
}
func GetHTTPStatus(code int) int {
if status, ok := httpStatusMap[code]; ok {
return status
}
return 500
}
```
**性能提升**: ~6 ns/op (基准测试结果)
---
### 2. 上下文提取优化
**策略**: 按需提取,避免不必要的分配
```go
// 仅在需要时提取 Query 参数
func FromFiberContext(c *fiber.Ctx) *ErrorContext {
query := ""
if c.Request().URI().QueryArgs().Len() > 0 {
query = string(c.Request().URI().QueryArgs().QueryString())
}
return &ErrorContext{
RequestID: getRequestID(c), // 使用缓存的值
Method: c.Method(),
Path: c.Path(),
Query: query,
IP: c.IP(),
UserAgent: c.Get("User-Agent"),
}
}
```
**性能指标**: ~188 ns/op, 208 B/op (基准测试结果)
---
### 3. 日志字段构造优化
**策略**: 复用 Zap 字段,减少内存分配
```go
func (ec *ErrorContext) ToLogFields() []zap.Field {
fields := make([]zap.Field, 0, 7) // 预分配容量
fields = append(fields,
zap.String("request_id", ec.RequestID),
zap.String("method", ec.Method),
zap.String("path", ec.Path),
zap.String("ip", ec.IP),
)
if ec.Query != "" {
fields = append(fields, zap.String("query", ec.Query))
}
if ec.UserID != "" {
fields = append(fields, zap.String("user_id", ec.UserID))
}
return fields
}
```
**性能指标**: ~145 ns/op, 768 B/op (基准测试结果)
---
### 4. 整体性能目标
| 指标 | 目标 | 实测 | 状态 |
|------|------|------|------|
| 错误处理延迟 (P95) | < 1ms | < 0.5μs | ✅ |
| 内存开销 | < 1KB | ~1KB | ✅ |
| 并发处理能力 | 10k+ RPS | 测试通过 | ✅ |
---
## 扩展性设计
### 1. 新增错误码
**步骤**:
1.`pkg/errors/codes.go` 添加常量:
```go
const (
CodeNewError = 1010 // 新错误码
)
```
2. 添加错误消息:
```go
var errorMessages = map[int]map[string]string{
// ...
CodeNewError: {"zh": "新错误消息"},
}
```
3. 添加 HTTP 状态码映射(如果非标准):
```go
var httpStatusMap = map[int]int{
// ...
CodeNewError: 400,
}
```
4. 添加日志级别映射(如果非标准):
```go
var logLevelMap = map[int]string{
// ...
CodeNewError: "warn",
}
```
---
### 2. 支持多语言
**扩展点**: `errorMessages` 支持多语言键
**示例**:
```go
var errorMessages = map[int]map[string]string{
CodeInvalidParam: {
"zh": "参数验证失败",
"en": "Parameter validation failed",
},
}
func GetMessage(code int, lang string) string {
if msg, ok := errorMessages[code]; ok {
if text, ok := msg[lang]; ok {
return text
}
}
return "Unknown error"
}
```
**调用**:
```go
// 从请求 Header 获取语言
lang := c.Get("Accept-Language", "zh")
message := errors.GetMessage(code, lang)
```
---
### 3. 自定义日志格式
**扩展点**: `safeLogWithLevel()` 可自定义日志结构
**示例**:
```go
func safeLogWithLevel(logger *zap.Logger, level string, msg string, fields ...zap.Field) {
// 添加自定义字段
fields = append(fields,
zap.String("service", "junhong-cmp"),
zap.String("env", os.Getenv("ENV")),
)
switch level {
case "error":
logger.Error(msg, fields...)
case "warn":
logger.Warn(msg, fields...)
default:
logger.Info(msg, fields...)
}
}
```
---
### 4. 集成监控系统
**扩展点**: 在 ErrorHandler 中添加指标上报
**示例**:
```go
func handleError(c *fiber.Ctx, err error) error {
// ... 现有逻辑 ...
// 上报错误指标
metrics.IncrementErrorCounter(code, httpStatus)
if httpStatus >= 500 {
metrics.RecordServerError(code, errCtx.Path)
}
return c.Status(httpStatus).JSON(response)
}
```
---
## 总结
### 设计亮点
1. **分层架构**: 清晰的职责划分(错误码、错误类型、上下文、处理器)
2. **防御性编程**: 双层 defer/recover 保护,确保 100% 稳定
3. **高性能**: 所有操作 < 1μs零阻塞
4. **可扩展**: 易于新增错误码、多语言、监控集成
5. **安全性**: 敏感信息脱敏,防止信息泄露
### 技术特点
- **类型安全**: 使用强类型 `AppError` 而非 `error` 字符串
- **错误链**: 支持 `errors.Unwrap()` 保留完整错误上下文
- **结构化日志**: 使用 Zap 字段而非字符串拼接
- **并发安全**: 每个请求独立处理,无共享状态
### 适用场景
- ✅ RESTful API 错误处理
- ✅ 微服务错误统一
- ✅ 高并发场景10k+ RPS
- ✅ 需要详细错误追踪的系统
---
**版本历史**:
- v1.0.0 (2025-11-15): 初始版本

View File

@@ -1,723 +0,0 @@
# 使用指南RBAC 表结构与 GORM 数据权限过滤
**功能编号**: 004-rbac-data-permission
**适用版本**: v1.0.0
**更新日期**: 2025-11-18
## 目录
1. [快速开始](#快速开始)
2. [账号管理](#账号管理)
3. [角色管理](#角色管理)
4. [权限管理](#权限管理)
5. [数据权限过滤](#数据权限过滤)
6. [业务表集成指南](#业务表集成指南)
7. [常见问题](#常见问题)
8. [最佳实践](#最佳实践)
---
## 快速开始
### 环境要求
- Go 1.25.4+
- PostgreSQL 14+
- Redis 6.0+
- golang-migrate v4.x
### 数据库初始化
```bash
# 运行数据库迁移
migrate -path migrations -database "postgresql://postgres:password@localhost:5432/junhong_cmp_fiber?sslmode=disable" up
# 验证表创建
psql -U postgres -d junhong_cmp_fiber -c "\dt"
```
### 创建 root 账号
```sql
-- 使用 bcrypt 哈希密码Password123
INSERT INTO tb_account (username, phone, password, user_type, shop_id, parent_id, status, creator, updater, created_at, updated_at)
VALUES ('root', '13800000000', '$2a$10$N9qo8uLOickgx2ZMRZoMye1P7Z.mKAeQ7pjSeG7gYDobOAZCnOMUa', 1, NULL, NULL, 1, 1, 1, NOW(), NOW());
```
---
## 账号管理
### 1. 创建账号
**API 端点**: `POST /api/v1/accounts`
**请求示例**:
```bash
curl -X POST http://localhost:8080/api/v1/accounts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"username": "platform_user",
"phone": "13900000001",
"password": "Password123",
"user_type": 2,
"shop_id": 10,
"parent_id": 1
}'
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 用户名3-20 个字符,字母/数字/下划线) |
| phone | string | 是 | 手机号11 位中国大陆手机号) |
| password | string | 是 | 密码(最少 8 位,包含字母和数字) |
| user_type | int | 是 | 用户类型1=root, 2=平台, 3=代理, 4=企业 |
| shop_id | int | 条件 | 所属店铺 IDuser_type=1 时可为空) |
| parent_id | int | 条件 | 上级账号 IDuser_type=1 时可为空) |
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": {
"id": 2,
"username": "platform_user",
"phone": "13900000001",
"user_type": 2,
"shop_id": 10,
"parent_id": 1,
"status": 1,
"created_at": "2025-11-18T10:00:00Z",
"updated_at": "2025-11-18T10:00:00Z"
},
"timestamp": "2025-11-18T10:00:00Z"
}
```
**注意事项**:
- ✅ 密码会自动使用 bcrypt 加密存储
- ✅ username 和 phone 必须唯一(软删除后可重复使用)
- ✅ 非 root 用户必须提供 parent_id
- ✅ 创建成功后会自动清除父账号的下级 ID 缓存
### 2. 获取账号详情
**API 端点**: `GET /api/v1/accounts/:id`
**请求示例**:
```bash
curl -X GET http://localhost:8080/api/v1/accounts/2 \
-H "Authorization: Bearer <token>"
```
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": {
"id": 2,
"username": "platform_user",
"phone": "13900000001",
"user_type": 2,
"shop_id": 10,
"parent_id": 1,
"status": 1,
"created_at": "2025-11-18T10:00:00Z",
"updated_at": "2025-11-18T10:00:00Z"
},
"timestamp": "2025-11-18T10:00:00Z"
}
```
**注意**: password 字段不会返回给客户端(已通过 `json:"-"` 标签隐藏)
### 3. 更新账号
**API 端点**: `PUT /api/v1/accounts/:id`
**请求示例**:
```bash
curl -X PUT http://localhost:8080/api/v1/accounts/2 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"username": "new_username",
"phone": "13900000002",
"status": 1
}'
```
**注意事项**:
-**禁止修改**: user_type, parent_id创建后不可更改
-**可选修改**: username, phone, status
### 4. 删除账号(软删除)
**API 端点**: `DELETE /api/v1/accounts/:id`
**请求示例**:
```bash
curl -X DELETE http://localhost:8080/api/v1/accounts/2 \
-H "Authorization: Bearer <token>"
```
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": null,
"timestamp": "2025-11-18T10:00:00Z"
}
```
**注意事项**:
- ✅ 软删除:只设置 `deleted_at` 字段,数据仍保留
- ✅ 软删除后username 和 phone 可以被重新使用
- ✅ 删除成功后会递归清除所有上级账号的下级 ID 缓存
- ⚠️ 软删除账号的数据对上级仍然可见(递归查询包含已删除账号)
### 5. 获取账号列表
**API 端点**: `GET /api/v1/accounts`
**请求示例**:
```bash
curl -X GET "http://localhost:8080/api/v1/accounts?page=1&page_size=20" \
-H "Authorization: Bearer <token>"
```
**查询参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量(最大 100 |
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"id": 2,
"username": "platform_user",
"user_type": 2,
"shop_id": 10,
"parent_id": 1,
"status": 1
}
],
"total": 1,
"page": 1,
"page_size": 20
},
"timestamp": "2025-11-18T10:00:00Z"
}
```
### 6. 为账号分配角色
**API 端点**: `POST /api/v1/accounts/:id/roles`
**请求示例**:
```bash
curl -X POST http://localhost:8080/api/v1/accounts/2/roles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"role_ids": [1, 2]
}'
```
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": [
{
"id": 1,
"account_id": 2,
"role_id": 1,
"status": 1,
"created_at": "2025-11-18T10:00:00Z"
},
{
"id": 2,
"account_id": 2,
"role_id": 2,
"status": 1,
"created_at": "2025-11-18T10:00:00Z"
}
],
"timestamp": "2025-11-18T10:00:00Z"
}
```
### 7. 获取账号的角色列表
**API 端点**: `GET /api/v1/accounts/:id/roles`
**请求示例**:
```bash
curl -X GET http://localhost:8080/api/v1/accounts/2/roles \
-H "Authorization: Bearer <token>"
```
### 8. 移除账号的角色
**API 端点**: `DELETE /api/v1/accounts/:account_id/roles/:role_id`
**请求示例**:
```bash
curl -X DELETE http://localhost:8080/api/v1/accounts/2/roles/1 \
-H "Authorization: Bearer <token>"
```
---
## 角色管理
### 1. 创建角色
**API 端点**: `POST /api/v1/roles`
**请求示例**:
```bash
curl -X POST http://localhost:8080/api/v1/roles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"role_name": "超级管理员",
"role_desc": "系统超级管理员",
"role_type": 1
}'
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| role_name | string | 是 | 角色名称(长度 ≤50 |
| role_desc | string | 否 | 角色描述(长度 ≤255 |
| role_type | int | 是 | 角色类型1=超级, 2=代理, 3=企业 |
### 2. 为角色分配权限
**API 端点**: `POST /api/v1/roles/:id/permissions`
**请求示例**:
```bash
curl -X POST http://localhost:8080/api/v1/roles/1/permissions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"perm_ids": [1, 2, 3]
}'
```
### 3. 获取角色的权限列表
**API 端点**: `GET /api/v1/roles/:id/permissions`
**请求示例**:
```bash
curl -X GET http://localhost:8080/api/v1/roles/1/permissions \
-H "Authorization: Bearer <token>"
```
---
## 权限管理
### 1. 创建权限
**API 端点**: `POST /api/v1/permissions`
**请求示例**:
```bash
curl -X POST http://localhost:8080/api/v1/permissions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"perm_name": "用户管理",
"perm_code": "user:manage",
"perm_type": 1,
"url": "/admin/users",
"parent_id": null,
"sort": 1
}'
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| perm_name | string | 是 | 权限名称(长度 ≤50 |
| perm_code | string | 是 | 权限编码(格式:`module:action`,如 `user:create` |
| perm_type | int | 是 | 权限类型1=菜单, 2=按钮 |
| url | string | 否 | URL 路径(长度 ≤255 |
| parent_id | int | 否 | 上级权限 ID支持层级 |
| sort | int | 否 | 排序序号(默认 0 |
**注意事项**:
- ✅ perm_code 必须唯一(软删除后可重复使用)
- ✅ 支持层级权限(通过 parent_id 构建权限树)
---
## 数据权限过滤
### 核心概念
数据权限过滤机制确保每个用户只能访问自己及下级的数据,通过 `owner_id``shop_id` 双重过滤实现多租户数据隔离。
### 过滤规则
```sql
WHERE owner_id IN (ID列表) AND shop_id = shop_id
```
### 示例场景
假设用户层级关系为A(root, ID=1) → B(平台, ID=2) → C(代理, ID=3)
- **用户 A 查询**返回所有数据root 用户跳过过滤)
- **用户 B 查询**:返回 `owner_id IN (2, 3) AND shop_id = 10` 的数据
- **用户 C 查询**:返回 `owner_id = 3 AND shop_id = 10` 的数据
### 递归查询下级 ID
系统使用 PostgreSQL WITH RECURSIVE 查询所有下级 ID并通过 Redis 缓存优化性能:
- **缓存 Key**: `account:subordinates:{账号ID}`
- **过期时间**: 30 分钟
- **清除时机**: 账号创建/删除时主动清除
### 跳过数据权限过滤
某些特殊场景(如 C 端业务用户、系统任务)需要跳过数据权限过滤:
**方式 1在 Store 层使用 WithoutDataFilter 选项**
```go
users, err := userStore.List(ctx, &store.QueryOptions{
WithoutDataFilter: true,
})
```
**方式 2root 用户自动跳过过滤**
root 用户user_type=1的所有查询会自动跳过数据权限过滤。
---
## 业务表集成指南
### 步骤 1添加数据权限字段
为业务表添加 `owner_id``shop_id` 字段:
**数据库迁移**:
```sql
-- migrations/000004_add_owner_id_to_business_table.up.sql
ALTER TABLE tb_your_table ADD COLUMN owner_id INTEGER;
ALTER TABLE tb_your_table ADD COLUMN shop_id INTEGER;
CREATE INDEX idx_your_table_owner_id ON tb_your_table(owner_id);
CREATE INDEX idx_your_table_shop_id ON tb_your_table(shop_id);
```
**GORM 模型更新**:
```go
// internal/model/your_model.go
type YourModel struct {
ID uint `gorm:"primarykey" json:"id"`
// ... 其他字段 ...
OwnerID *uint `gorm:"index" json:"owner_id,omitempty"` // 新增
ShopID *uint `gorm:"index" json:"shop_id,omitempty"` // 新增
CreatedAt time.Time `gorm:"not null" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
```
### 步骤 2在 Store 层应用数据权限过滤
```go
// internal/store/postgres/your_store.go
import (
"context"
"your-project/internal/model"
"your-project/internal/store"
"gorm.io/gorm"
)
type YourStore struct {
db *gorm.DB
accountStore *AccountStore // 注入 AccountStore用于递归查询
}
func (s *YourStore) List(ctx context.Context, opts *store.QueryOptions) ([]*model.YourModel, error) {
query := s.db.WithContext(ctx)
// 应用数据权限过滤(如果未禁用)
if !opts.WithoutDataFilter {
query = query.Scopes(DataPermissionScope(s.accountStore))
}
var items []*model.YourModel
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (s *YourStore) GetByID(ctx context.Context, id uint, opts *store.QueryOptions) (*model.YourModel, error) {
query := s.db.WithContext(ctx)
// 应用数据权限过滤(如果未禁用)
if !opts.WithoutDataFilter {
query = query.Scopes(DataPermissionScope(s.accountStore))
}
var item model.YourModel
if err := query.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
```
### 步骤 3在 Service 层设置 owner_id 和 shop_id
```go
// internal/service/your_service/service.go
import (
"context"
"your-project/internal/model"
"your-project/pkg/middleware"
)
func (s *Service) Create(ctx context.Context, req *CreateRequest) (*model.YourModel, error) {
// 从 context 提取当前用户信息
userID := middleware.GetUserIDFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
item := &model.YourModel{
// ... 其他字段 ...
OwnerID: &userID, // 设置为当前用户 ID
ShopID: &shopID, // 设置为当前用户的 shop_id
}
if err := s.store.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
```
### 步骤 4验证数据权限过滤
创建测试数据并验证不同用户的查询结果:
```sql
-- 创建测试数据
INSERT INTO tb_your_table (name, owner_id, shop_id, created_at, updated_at)
VALUES
('数据A - 用户B创建', 2, 10, NOW(), NOW()),
('数据B - 用户C创建', 3, 10, NOW(), NOW()),
('数据C - 其他店铺', 2, 20, NOW(), NOW());
```
**预期查询结果**:
- **用户 Aroot**: 返回所有 3 条数据
- **用户 B平台**: 返回 2 条数据owner_id=2 或 3且 shop_id=10
- **用户 C代理**: 返回 1 条数据owner_id=3且 shop_id=10
---
## 常见问题
### Q1: 如何验证密码?
**A**: 使用 bcrypt 验证密码:
```go
import "golang.org/x/crypto/bcrypt"
func ValidatePassword(plainPassword, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
return err == nil
}
```
### Q2: 递归查询性能问题?
**A**: 系统已通过 Redis 缓存优化,缓存命中率预期 > 90%。如果层级深度超过 10 层,建议:
- 监控递归查询耗时P95 应 < 50ms
- 考虑使用闭包表Closure Table替代递归查询
### Q3: 软删除账号的数据如何处理?
**A**: 软删除账号后:
- ✅ 该账号的数据对上级仍然可见(递归查询包含已删除账号)
- ✅ username 和 phone 可以被重新使用
- ✅ 所有上级的下级 ID 缓存会被清除
### Q4: 如何清除 Redis 缓存?
**A**: 账号创建/删除时会自动清除缓存,也可以手动清除:
```bash
# 清除指定账号的下级 ID 缓存
redis-cli DEL account:subordinates:2
# 清除所有下级 ID 缓存
redis-cli KEYS "account:subordinates:*" | xargs redis-cli DEL
```
### Q5: 如何为 C 端业务用户实现数据过滤?
**A**: C 端业务用户通常不使用 owner_id 过滤,而是基于业务字段(如 iccid/device_id
```go
// 使用 WithoutDataFilter 跳过 owner_id 过滤
users, err := userStore.List(ctx, &store.QueryOptions{
WithoutDataFilter: true,
})
// 在 Service 层应用业务字段过滤
filteredUsers := filterByICCID(users, targetICCID)
```
### Q6: 如何处理跨店铺查询?
**A**: 数据权限过滤强制 `shop_id = 当前用户的shop_id`,不支持跨店铺查询。如果需要跨店铺查询:
- 方式 1使用 root 用户(自动跳过过滤)
- 方式 2使用 `WithoutDataFilter` 选项(需要在业务层额外校验权限)
---
## 最佳实践
### 1. 创建账号时的层级关系
**推荐**:只有本级账号能创建下级账号
```
A(root) 创建 B(平台)
B(平台) 创建 C(代理)
C(代理) 创建 D(企业)
```
**不推荐**跨级创建A 直接创建 C
### 2. 数据归属设置
**推荐**:创建数据时 owner_id 设置为当前用户 ID
```go
item.OwnerID = &userID // 当前用户 ID
item.ShopID = &shopID // 当前用户的 shop_id
```
**不推荐**owner_id 设置为其他用户 ID除非是数据转移场景
### 3. 缓存管理
**推荐**:依赖自动缓存清除机制
- 账号创建时自动清除父账号缓存
- 账号删除时递归清除所有上级缓存
**不推荐**:手动清除缓存(除非调试或紧急修复)
### 4. 错误处理
**推荐**:使用统一错误处理机制
```go
import "your-project/pkg/errors"
if account == nil {
return nil, errors.New(errors.CodeAccountNotFound, "账号不存在")
}
```
**不推荐**:手动构造错误响应
```go
// ❌ 不推荐
return c.Status(404).JSON(fiber.Map{"error": "账号不存在"})
```
### 5. 安全性
**推荐**
- 使用 bcrypt 加密密码
- password 字段使用 `json:"-"` 隐藏
- 验证用户权限后再执行敏感操作
**不推荐**
- 使用 MD5 加密密码(已废弃)
- 返回 password 字段给客户端
- 跳过权限校验
---
## 相关文档
- **功能总结**: [功能总结.md](./功能总结.md)
- **快速入门**: [specs/004-rbac-data-permission/quickstart.md](../../specs/004-rbac-data-permission/quickstart.md)
- **数据模型**: [specs/004-rbac-data-permission/data-model.md](../../specs/004-rbac-data-permission/data-model.md)
- **API 文档**: [specs/004-rbac-data-permission/contracts/](../../specs/004-rbac-data-permission/contracts/)
---
**更新日期**: 2025-11-18
**维护者**: AI Assistant (Claude)

View File

@@ -1,325 +0,0 @@
# 功能总结RBAC 表结构与 GORM 数据权限过滤
**功能编号**: 004-rbac-data-permission
**完成日期**: 2025-11-18
**版本**: v1.0.0
## 功能概述
本功能实现了完整的 RBAC基于角色的访问控制权限系统和基于 owner_id + shop_id 的自动数据权限过滤机制。核心功能包括:
1. **RBAC 权限系统**5 个核心表(账号、角色、权限、账号-角色关联、角色-权限关联)支持层级关系和软删除
2. **数据权限过滤**GORM Scopes 自动应用 `owner_id IN (...) AND shop_id = ?` 过滤条件
3. **递归查询优化**:使用 PostgreSQL WITH RECURSIVE 查询所有下级 ID结合 Redis 缓存30 分钟过期)
4. **主函数重构**:将 main 函数拆分为 9 个独立初始化函数≤100 行)
5. **路由模块化**:路由按业务模块拆分到 `internal/routes/` 目录
## 核心实现
### 1. RBAC 数据库设计
创建了 5 个核心表:
- **tb_account**账号表支持层级关系parent_id 自关联、用户类型root/平台/代理/企业)、软删除
- **tb_role**(角色表):支持角色类型(超级/代理/企业)、软删除
- **tb_permission**权限表支持层级关系parent_id 自关联)、权限类型(菜单/按钮)、软删除
- **tb_account_role**(账号-角色关联表):多对多关联,支持软删除
- **tb_role_permission**(角色-权限关联表):多对多关联,支持软删除
**核心设计原则**
- ✅ 禁止外键约束Foreign Key Constraints
- ✅ 禁止 GORM 关联标签(`foreignKey``hasMany``belongsTo` 等)
- ✅ 通过 ID 字段手动维护关联
- ✅ 所有表支持软删除(`deleted_at` 字段)
- ✅ 时间字段由 GORM 自动管理created_at, updated_at
### 2. 数据权限过滤机制
**过滤逻辑**
```go
// internal/store/postgres/scopes.go
func DataPermissionScope(accountStore *AccountStore) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// 1. 从 context 提取用户信息
userID := middleware.GetUserIDFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
// 2. 检查是否为 root 用户(跳过过滤)
if middleware.IsRootUser(ctx) {
return db
}
// 3. 获取用户的所有下级 ID含缓存
subordinateIDs, err := accountStore.GetSubordinateIDs(ctx, userID)
// 4. 应用双重过滤owner_id IN (...) AND shop_id = ?
return db.Where("owner_id IN ? AND shop_id = ?", subordinateIDs, shopID)
}
}
```
**使用方式**
```go
// 在 Store 层自动应用过滤
func (s *UserStore) List(ctx context.Context, opts *store.QueryOptions) ([]*model.User, error) {
query := s.db.WithContext(ctx)
if !opts.WithoutDataFilter {
query = query.Scopes(DataPermissionScope(s.accountStore))
}
var users []*model.User
return users, query.Find(&users).Error
}
```
### 3. 递归查询与缓存
**递归查询实现**PostgreSQL WITH RECURSIVE
```sql
WITH RECURSIVE subordinates AS (
-- 基础查询:选择当前账号
SELECT id FROM tb_account WHERE id = ? AND deleted_at IS NULL
UNION ALL
-- 递归查询:选择所有下级(包括软删除的账号)
SELECT a.id
FROM tb_account a
INNER JOIN subordinates s ON a.parent_id = s.id
)
SELECT id FROM subordinates WHERE id != ?
```
**缓存策略**
- **Redis Key**: `account:subordinates:{账号ID}`
- **数据格式**: JSON 序列化的 ID 数组(使用 sonic 库)
- **过期时间**: 30 分钟
- **清除时机**: 账号创建/删除时主动清除(递归清除所有上级缓存)
**性能优化**
- 递归查询 P95 < 50ms, P99 < 100ms含 Redis 缓存)
- 缓存命中率预期 > 90%
- 支持至少 5 层用户层级
### 4. 主函数重构
`main()` 函数从 200+ 行重构为 ≤100 行,拆分为 9 个独立函数:
- `initConfig()`:加载配置文件
- `initLogger()`:初始化 Zap + Lumberjack 日志
- `initDatabase()`:连接 PostgreSQL
- `initRedis()`:连接 Redis
- `initQueue()`:初始化 Asynq 任务队列
- `initServices()`:初始化所有 Service 和 Store
- `initMiddleware()`:注册全局中间件
- `initRoutes()`:注册所有路由
- `startServer()`:启动 Fiber 服务器
**main 函数结构**
```go
func main() {
cfg := initConfig()
logger := initLogger(cfg)
db := initDatabase(cfg, logger)
redis := initRedis(cfg, logger)
queue := initQueue(cfg, logger, redis)
services := initServices(db, redis, queue, logger)
app := fiber.New(fiber.Config{/* ... */})
initMiddleware(app, logger)
initRoutes(app, services)
startServer(app, cfg, logger)
}
```
### 5. 路由模块化
路由按业务模块拆分到 `internal/routes/` 目录:
- `routes.go`路由总入口RegisterRoutes 函数)
- `account.go`账号路由CRUD + 角色分配)
- `role.go`角色路由CRUD + 权限分配)
- `permission.go`权限路由CRUD + 树形查询)
- `health.go`:健康检查路由
- `task.go`:任务路由
**路由注册流程**
```go
// internal/routes/routes.go
func RegisterRoutes(app *fiber.App, services *Services) {
api := app.Group("/api/v1")
registerHealthRoutes(app)
registerAccountRoutes(api, services.Account)
registerRoleRoutes(api, services.Role)
registerPermissionRoutes(api, services.Permission)
registerTaskRoutes(api)
}
```
## 技术要点
### 1. 遵循宪章原则
-**技术栈遵守**Fiber + GORM + Viper + Zap + Lumberjack.v2 + sonic + Asynq + PostgreSQL + Redis
-**分层架构**Handler → Service → Store → Model
-**统一错误处理**pkg/errors/ 中定义所有错误码
-**统一响应格式**pkg/response/ 中定义统一 JSON 格式
-**常量管理**pkg/constants/ 中定义所有常量(包括 Redis key 生成函数)
-**数据库设计**:禁止外键约束、禁止 GORM 关联标签
-**Go 惯用设计**:无 Java 风格模式、使用组合而非继承、显式错误处理
### 2. 安全性
-**密码哈希**:使用 bcrypt 加密密码(替代 MD5
-**密码字段隐藏**Account 模型中 password 字段使用 `json:"-"` 标签
-**数据隔离**owner_id + shop_id 双重过滤确保多租户数据隔离
-**防止越权**:非 root 用户只能访问自己及下级的数据
### 3. 性能优化
-**Redis 缓存**:递归查询结果缓存 30 分钟,显著降低数据库负载
-**索引优化**:所有查询条件和关联字段都有索引支持
-**批量操作**:角色分配、权限分配使用批量插入
-**连接池配置**PostgreSQL 连接池 MaxOpenConns=25Redis 连接池 PoolSize=10
### 4. 可维护性
-**主函数简化**≤100 行,清晰的初始化流程
-**路由模块化**:每个路由文件 ≤100 行,按业务模块组织
-**函数单一职责**:每个函数只负责一件事
-**代码注释**:实现注释使用中文,日志消息使用中文
## 文件清单
### 新增文件(核心功能)
**模型层internal/model/**
- `account.go``account_dto.go`
- `role.go``role_dto.go`
- `permission.go``permission_dto.go`
- `account_role.go``account_role_dto.go`
- `role_permission.go``role_permission_dto.go`
**Store 层internal/store/postgres/**
- `account_store.go`
- `role_store.go`
- `permission_store.go`
- `account_role_store.go`
- `role_permission_store.go`
- `scopes.go`(数据权限过滤 Scope
**Service 层internal/service/**
- `account/service.go`
- `role/service.go`
- `permission/service.go`
**Handler 层internal/handler/**
- `account.go`
- `role.go`
- `permission.go`
**路由层internal/routes/**
- `routes.go`(总入口)
- `account.go`
- `role.go`
- `permission.go`
- `health.go`
- `task.go`
**数据库迁移migrations/**
- `000002_rbac_data_permission.up.sql`
- `000002_rbac_data_permission.down.sql`
- `000003_add_owner_id_shop_id.up.sql`(示例迁移)
- `000003_add_owner_id_shop_id.down.sql`(示例迁移)
**辅助文件**
- `internal/store/options.go`Store 查询选项)
- `pkg/constants/constants.go`(添加 RBAC 常量)
- `pkg/constants/redis.go`(添加 RedisAccountSubordinatesKey 函数)
- `pkg/errors/codes.go`(添加 RBAC 错误码)
- `pkg/middleware/auth.go`(添加 Context 辅助函数)
### 修改文件
- `cmd/api/main.go`:重构为 9 个初始化函数 + 编排 main 函数
## API 端点清单
### 账号管理
- `POST /api/v1/accounts`:创建账号
- `GET /api/v1/accounts/:id`:获取账号详情
- `PUT /api/v1/accounts/:id`:更新账号
- `DELETE /api/v1/accounts/:id`:删除账号(软删除)
- `GET /api/v1/accounts`:获取账号列表(支持分页)
- `POST /api/v1/accounts/:id/roles`:为账号分配角色
- `GET /api/v1/accounts/:id/roles`:获取账号的角色列表
- `DELETE /api/v1/accounts/:account_id/roles/:role_id`:移除账号的角色
### 角色管理
- `POST /api/v1/roles`:创建角色
- `GET /api/v1/roles/:id`:获取角色详情
- `PUT /api/v1/roles/:id`:更新角色
- `DELETE /api/v1/roles/:id`:删除角色(软删除)
- `GET /api/v1/roles`:获取角色列表(支持分页)
- `POST /api/v1/roles/:id/permissions`:为角色分配权限
- `GET /api/v1/roles/:id/permissions`:获取角色的权限列表
- `DELETE /api/v1/roles/:role_id/permissions/:perm_id`:移除角色的权限
### 权限管理
- `POST /api/v1/permissions`:创建权限
- `GET /api/v1/permissions/:id`:获取权限详情
- `PUT /api/v1/permissions/:id`:更新权限
- `DELETE /api/v1/permissions/:id`:删除权限(软删除)
- `GET /api/v1/permissions`:获取权限列表(支持分页)
## 已知限制
1. **层级深度限制**:支持至少 5 层用户层级,超过 10 层可能影响性能
2. **缓存过期时间**Redis 缓存 30 分钟过期,极端情况下可能出现短暂的数据不一致
3. **未来功能**数据变更日志表tb_data_transfer_log暂未实现预留给未来版本
4. **示例表**user 和 order 表是之前的示例代码,实际业务表需自行添加 owner_id/shop_id 字段
## 后续改进建议
1. **权限校验中间件**:实现基于 RBAC 的 API 权限校验中间件
2. **数据变更日志**:实现 tb_data_transfer_log 表记录数据归属变更历史
3. **性能监控**:添加递归查询和缓存命中率监控
4. **单元测试**:补充完整的单元测试和集成测试(当前测试覆盖率 < 70%
5. **API 文档**:生成 OpenAPISwagger规范文档
6. **权限树形查询**:实现权限的树形结构查询 API
7. **缓存预热**:启动时预热高频访问的下级 ID 缓存
## 相关文档
- **功能规格**[specs/004-rbac-data-permission/spec.md](../../specs/004-rbac-data-permission/spec.md)
- **实现计划**[specs/004-rbac-data-permission/plan.md](../../specs/004-rbac-data-permission/plan.md)
- **数据模型**[specs/004-rbac-data-permission/data-model.md](../../specs/004-rbac-data-permission/data-model.md)
- **技术研究**[specs/004-rbac-data-permission/research.md](../../specs/004-rbac-data-permission/research.md)
- **快速入门**[specs/004-rbac-data-permission/quickstart.md](../../specs/004-rbac-data-permission/quickstart.md)
- **任务清单**[specs/004-rbac-data-permission/tasks.md](../../specs/004-rbac-data-permission/tasks.md)
- **使用指南**[docs/004-rbac-data-permission/使用指南.md](./使用指南.md)
## 贡献者
- **开发**: AI Assistant (Claude)
- **项目负责人**: break
- **完成日期**: 2025-11-18
---
**版本历史**
- v1.0.0 (2025-11-18): 初始版本,实现 RBAC 权限系统和数据权限过滤

View File

@@ -1,264 +0,0 @@
# 架构说明RBAC 表结构与 GORM 数据权限过滤
## 概述
本功能实现了完整的 RBAC基于角色的访问控制权限系统以及基于 `owner_id` + `shop_id` 的自动数据权限过滤机制。
## 架构分层
本系统遵循 Handler → Service → Store → Model 四层架构:
```
┌─────────────────────────────────────────────────────────┐
│ Handler Layer │
│ (HTTP 请求/响应处理,参数验证,调用 Service) │
├─────────────────────────────────────────────────────────┤
│ Service Layer │
│ (业务逻辑,事务管理,缓存清理,跨模块调用) │
├─────────────────────────────────────────────────────────┤
│ Store Layer │
│ (数据访问GORM 操作,数据权限过滤 Scopes) │
├─────────────────────────────────────────────────────────┤
│ Model Layer │
│ (数据模型定义DTO 结构) │
└─────────────────────────────────────────────────────────┘
```
## 核心组件
### 1. 数据模型
#### RBAC 表结构
| 表名 | 说明 | 关键字段 |
|------|------|----------|
| `tb_account` | 账号表 | `parent_id`(层级关系), `shop_id`(店铺隔离), `user_type` |
| `tb_role` | 角色表 | `role_type`(角色类型)|
| `tb_permission` | 权限表 | `perm_code`(唯一权限码), `parent_id`(树形结构)|
| `tb_account_role` | 账号-角色关联表 | `account_id`, `role_id` |
| `tb_role_permission` | 角色-权限关联表 | `role_id`, `perm_id` |
#### 设计原则
- **无外键约束**:表之间通过 ID 字段关联,不使用数据库外键约束
- **软删除支持**:所有表都有 `deleted_at` 字段,支持 GORM 软删除
- **唯一约束带条件**:用户名、手机号、权限码的唯一约束仅在未删除记录中生效
### 2. 数据权限过滤
#### 核心流程
```
用户请求 → 认证中间件 → 设置用户上下文 → 业务查询 → DataPermissionScope → 返回数据
```
#### DataPermissionScope 工作原理
1. 从 Context 提取用户 ID、用户类型、店铺 ID
2. 检查是否为 root 用户(跳过过滤)
3. 递归查询当前用户的所有下级 ID含自己
4. 应用 WHERE 条件:`owner_id IN (...) AND shop_id = ?`
#### 递归查询优化
使用 PostgreSQL 的 `WITH RECURSIVE` 进行递归查询:
```sql
WITH RECURSIVE subordinates AS (
SELECT id FROM tb_account WHERE id = ?
UNION ALL
SELECT a.id FROM tb_account a
INNER JOIN subordinates s ON a.parent_id = s.id
)
SELECT id FROM subordinates
```
### 3. Redis 缓存策略
#### 缓存设计
- **缓存键格式**`account:subordinates:{account_id}`
- **过期时间**30 分钟
- **缓存内容**:用户及其所有下级的 ID 列表
#### 缓存失效策略
- 创建子账号时:清除父账号及所有上级的缓存
- 删除账号时:清除父账号及所有上级的缓存
- 缓存自动过期后:下次查询重新生成
### 4. Context 上下文传递
#### 上下文键
```go
const (
UserIDKey = "user_id"
UserTypeKey = "user_type"
ShopIDKey = "shop_id"
)
```
#### 辅助函数
- `SetUserContext(ctx, userID, userType, shopID)` - 设置用户上下文
- `GetUserIDFromContext(ctx)` - 获取用户 ID
- `GetShopIDFromContext(ctx)` - 获取店铺 ID
- `IsRootUser(ctx)` - 检查是否为 root 用户
## 路由模块化
### 目录结构
```
internal/routes/
├── routes.go # 主入口Services 容器
├── account.go # 账号路由
├── role.go # 角色路由
├── permission.go # 权限路由
├── health.go # 健康检查路由
└── task.go # 任务路由
```
### Services 容器
```go
type Services struct {
AccountHandler *handler.AccountHandler
RoleHandler *handler.RoleHandler
PermissionHandler *handler.PermissionHandler
}
```
## 主函数重构
### 编排模式
main 函数仅做编排,不包含具体实现:
```go
func main() {
cfg := initConfig() // 加载配置
logger := initLogger(cfg) // 初始化日志
db := initDatabase(cfg) // 初始化数据库
redis := initRedis(cfg) // 初始化 Redis
queue := initQueue(redis) // 初始化队列
services := initServices(db, redis) // 初始化服务
app := createFiberApp(cfg) // 创建应用
initMiddleware(app, cfg) // 注册中间件
initRoutes(app, services) // 注册路由
startServer(app, cfg) // 启动服务器
}
```
### 初始化函数职责
| 函数 | 职责 |
|------|------|
| `initConfig` | 加载配置文件 |
| `initLogger` | 初始化 Zap 日志 |
| `initDatabase` | 连接 PostgreSQL |
| `initRedis` | 连接 Redis |
| `initQueue` | 初始化 Asynq 客户端 |
| `initServices` | 创建所有 Service 实例 |
| `initMiddleware` | 注册全局中间件 |
| `initRoutes` | 注册所有路由 |
| `startServer` | 启动 HTTP 服务器 |
## 性能考量
### 关键性能指标
- API 响应时间 P95 < 200ms
- 递归查询下级 ID P95 < 50ms含 Redis 缓存)
- 数据库查询 P95 < 50ms
### 优化措施
1. **Redis 缓存**:缓存递归查询结果,避免重复数据库查询
2. **索引优化**:关键字段都建立索引(`parent_id``shop_id``owner_id`
3. **批量操作**:账号-角色、角色-权限支持批量创建
4. **连接池**:数据库和 Redis 配置合理的连接池
## 安全考量
### 数据隔离
- **店铺隔离**:通过 `shop_id` 实现多租户数据隔离
- **层级权限**:用户只能访问自己及下级创建的数据
- **root 用户**:跳过数据权限过滤,可访问所有数据
### 密码安全
- 密码字段使用 `json:"-"` 标签,不返回给客户端
- 密码使用 bcrypt 加密存储
### 错误处理
- 查询下级 ID 失败时,降级为只返回自己的数据
- 所有错误通过统一错误处理机制返回
## 扩展指南
### 添加新业务表的数据权限
1. 在业务表中添加 `owner_id``shop_id` 字段
2. 在 Store 方法中应用 `DataPermissionScope`
3. 确保创建记录时设置正确的 `owner_id``shop_id`
示例:
```go
func (s *OrderStore) List(ctx context.Context) ([]*model.Order, error) {
var orders []*model.Order
err := s.db.WithContext(ctx).
Scopes(postgres.DataPermissionScope(ctx, s.accountStore)).
Find(&orders).Error
return orders, err
}
```
### 添加新的 RBAC 实体
1. 创建 Model 和 DTO
2. 创建 StoreCRUD 方法)
3. 创建 Service业务逻辑
4. 创建 HandlerHTTP 接口)
5. 添加路由文件
6. 更新 Services 容器
## 测试策略
### 单元测试
- 递归查询测试
- 缓存读写测试
- 数据权限 Scope 测试
- 软删除测试
### 集成测试
- 数据库迁移测试
- 层级数据权限过滤测试
- 跨店铺数据隔离测试
- API 端点测试
## 技术决策记录
### 为什么不使用外键?
1. **灵活性**:业务逻辑完全在代码中控制
2. **性能**:无外键约束检查开销
3. **分布式友好**:便于未来拆分微服务
### 为什么使用 WITH RECURSIVE
1. **原生支持**PostgreSQL 内置支持
2. **性能优异**:单次查询获取所有下级
3. **深度无限**:支持任意层级的递归
### 为什么缓存过期时间是 30 分钟?
1. **平衡性**:在实时性和性能之间取得平衡
2. **业务特点**:账号层级变化不频繁
3. **可配置**:可根据业务需求调整

View File

@@ -1,235 +0,0 @@
# 差价佣金文档索引
本索引汇总了所有关于差价佣金计算和分配的文档,帮助快速定位所需信息。
## 📚 文档列表
### 1. **commission-search-result.md** (237行)
**内容**: 差价佣金计算和分配逻辑的完整搜索结果
**包含内容**:
- ✅ 差价佣金的计算方式(公式、流程、示例)
- ✅ 差价佣金的发放条件(触发条件、发放流程、入账步骤)
- ✅ 差价佣金与订单状态的关系(状态定义、转换关系、关键字段)
- ✅ 关键代码位置总结(表格形式)
- ✅ 相关常量定义
**适合场景**: 需要快速了解差价佣金的完整逻辑
**快速导航**:
- 差价佣金计算公式 → 第一部分 1.1
- 计算流程详解 → 第一部分 1.2
- 触发条件 → 第二部分 2.1
- 发放流程 → 第二部分 2.2
- 佣金入账 → 第二部分 2.3
---
### 2. **commission-flow-diagram.md** (319行)
**内容**: 差价佣金计算的可视化流程图
**包含内容**:
- ✅ 订单支付到佣金入账的完整流程ASCII流程图
- ✅ 成本价差佣金计算详细流程(分步骤流程图)
- ✅ 佣金入账流程5个步骤
- ✅ 订单状态转换图
- ✅ 佣金记录状态转换图
- ✅ 代理链佣金分配示例(实际案例)
**适合场景**: 需要理解整个流程的全貌,或向他人解释流程
**快速导航**:
- 完整流程 → 第一部分
- 计算细节 → 第二部分
- 入账步骤 → 第三部分
- 状态转换 → 第四、五部分
- 实际案例 → 第六部分
---
### 3. **commission-quick-reference.md** (283行)
**内容**: 差价佣金的快速参考指南
**包含内容**:
- ✅ 关键文件位置表8个关键功能
- ✅ 关键常量速查(任务类型、佣金来源、状态常量)
- ✅ 关键数据库表5个核心表
- ✅ 常见问题快速查询10个Q&A
- ✅ 调试技巧6个SQL查询示例
- ✅ 性能优化建议3个优化方向
- ✅ 常见错误排查3个常见错误
**适合场景**: 开发过程中快速查询信息,或调试问题
**快速导航**:
- 找代码位置 → 快速查询表 1
- 找常量值 → 快速查询表 2
- 找数据库表 → 快速查询表 3
- 常见问题 → 常见问题快速查询
- 调试SQL → 调试技巧
- 性能问题 → 性能优化建议
- 错误处理 → 常见错误排查
---
### 4. **commission-package-model.md** (351行)
**内容**: 套餐与佣金业务模型(原有文档)
**包含内容**:
- ✅ 核心概念(两种佣金类型、实体关系)
- ✅ 套餐模型详解
- ✅ 差价佣金规则(计算规则、关键区分)
- ✅ 一次性佣金规则(触发条件、链式分配、流程)
- ✅ 梯度佣金规则
- ✅ 约束规则6个约束
- ✅ 操作流程(理想的线性流程)
**适合场景**: 需要理解完整的业务规则和约束
**快速导航**:
- 两种佣金类型 → 第一部分 1.1
- 差价佣金规则 → 第三部分
- 一次性佣金规则 → 第四部分
- 约束规则 → 第六部分
---
## 🎯 使用指南
### 场景1: 我是新开发者,想快速了解差价佣金
**推荐阅读顺序**:
1. 先读 `commission-flow-diagram.md` 的第一部分(了解整体流程)
2. 再读 `commission-search-result.md` 的第一部分(理解计算方式)
3. 最后读 `commission-quick-reference.md` 的常见问题(掌握细节)
**预计时间**: 30分钟
---
### 场景2: 我需要修改佣金计算逻辑
**推荐阅读顺序**:
1. 先读 `commission-search-result.md` 的第一部分(理解当前逻辑)
2. 查看 `commission-quick-reference.md` 的关键文件位置(定位代码)
3. 查看 `commission-flow-diagram.md` 的第二部分(理解计算细节)
4. 打开代码文件进行修改
**关键文件**: `internal/service/commission_calculation/service.go:121-225`
---
### 场景3: 我需要调试佣金计算问题
**推荐阅读顺序**:
1. 先读 `commission-quick-reference.md` 的调试技巧获取SQL查询
2. 执行SQL查询获取数据
3. 对比 `commission-flow-diagram.md` 的第六部分(验证计算结果)
4. 查看 `commission-quick-reference.md` 的常见错误排查(定位问题)
**关键SQL**: 见 `commission-quick-reference.md` 的调试技巧部分
---
### 场景4: 我需要向产品经理解释佣金逻辑
**推荐阅读顺序**:
1. 先读 `commission-package-model.md` 的第三部分(业务规则)
2. 使用 `commission-flow-diagram.md` 的第六部分(实际案例)
3. 使用 `commission-flow-diagram.md` 的流程图(可视化展示)
**关键资源**: `commission-flow-diagram.md` 的第六部分(代理链佣金分配示例)
---
### 场景5: 我需要验证佣金计算的正确性
**推荐阅读顺序**:
1. 先读 `commission-quick-reference.md` 的Q10验证方法
2. 执行SQL查询获取数据
3. 按照验证步骤进行检查
4. 如有问题,查看常见错误排查
**关键内容**: `commission-quick-reference.md` 的Q10
---
## 📊 文档对比表
| 文档 | 长度 | 类型 | 适合场景 | 重点 |
|------|------|------|---------|------|
| commission-search-result.md | 237行 | 参考 | 快速了解逻辑 | 代码位置、计算方式 |
| commission-flow-diagram.md | 319行 | 可视化 | 理解流程 | 流程图、状态转换 |
| commission-quick-reference.md | 283行 | 工具 | 开发调试 | 常见问题、SQL查询 |
| commission-package-model.md | 351行 | 规范 | 业务理解 | 业务规则、约束 |
---
## 🔗 关键代码位置速查
| 功能 | 文件 | 行号 | 文档位置 |
|------|------|------|---------|
| 差价佣金计算 | `internal/service/commission_calculation/service.go` | 121-225 | search-result 1.2 |
| 佣金入账 | `internal/service/commission_calculation/service.go` | 641-687 | search-result 2.3 |
| 佣金计算触发 | `internal/service/order/service.go` | 2132-2150 | search-result 2.1 |
| 订单支付完成 | `internal/service/order/service.go` | 1431-1623 | search-result 2.1 |
| 异步任务处理 | `internal/task/commission_calculation.go` | 40-62 | search-result 2.2 |
---
## 💡 常见问题速查
| 问题 | 答案位置 |
|------|---------|
| 差价佣金什么时候计算? | quick-reference Q1 |
| 差价佣金的计算公式是什么? | quick-reference Q2 / search-result 1.1 |
| 如何查询某个订单的佣金记录? | quick-reference Q3 |
| 佣金什么时候入账? | quick-reference Q4 |
| 链路断裂是什么意思? | quick-reference Q5 |
| 如何追踪佣金计算过程? | quick-reference Q6 |
| 代理钱包余额如何更新? | quick-reference Q7 |
| 如何处理佣金计算失败? | quick-reference Q8 |
| 一个订单产生多少条佣金记录? | quick-reference Q9 |
| 如何验证佣金计算的正确性? | quick-reference Q10 |
---
## 📝 文档维护
### 最后更新时间
- commission-search-result.md: 2025-04-11
- commission-flow-diagram.md: 2025-04-11
- commission-quick-reference.md: 2025-04-11
- commission-package-model.md: 2025-02-03
### 更新日志
- 2025-04-11: 新增三份文档search-result, flow-diagram, quick-reference
- 2025-02-03: 原有 commission-package-model.md
### 如何贡献
如果发现文档有误或需要补充,请:
1. 提交 Issue 描述问题
2. 或直接提交 PR 修改文档
3. 确保修改后的文档保持一致性
---
## 🚀 快速开始
**第一次接触差价佣金?**
1. 花5分钟看 `commission-flow-diagram.md` 的第一部分
2. 花10分钟看 `commission-search-result.md` 的第一部分
3. 需要时查阅 `commission-quick-reference.md`
**预计总时间**: 15分钟快速入门
---
## 📞 获取帮助
- **代码问题**: 查看 `commission-quick-reference.md` 的常见错误排查
- **业务问题**: 查看 `commission-package-model.md` 的约束规则
- **流程问题**: 查看 `commission-flow-diagram.md` 的流程图
- **其他问题**: 查看 `commission-quick-reference.md` 的常见问题

View File

@@ -1,449 +0,0 @@
# 部署问题排查指南
## 当前状态
**最新修复**: Commit `bf4ef37` - 修复 docker compose 找不到配置文件:显式指定文件名
**修复内容**:
- 所有 `docker compose` 命令添加 `-f docker-compose.prod.yml` 参数
- 确保在正确的工作目录执行命令
---
## 快速验证清单
### 1. 检查 Gitea Actions 构建状态
访问: https://git.boss160.cn/csxj2026/junhong_cmp_fiber/actions
**查看最新运行**:
- 运行 ID: 查看最新的 workflow 运行
- 状态: 应该显示 ✅ 成功(绿色)
- 时间: 预计 15-20 分钟完成
**关键步骤验证**:
```
✅ 检出代码 (Checkout code)
✅ 设置镜像标签 (Set image tags)
✅ 登录 Docker Registry (Login to Docker Registry)
✅ 构建 API 镜像 (Build API image)
✅ 构建 Worker 镜像 (Build Worker image)
✅ 推送 API 镜像 (Push API image)
✅ 推送 Worker 镜像 (Push Worker image)
✅ 部署到测试服务器 (Deploy to test server) <-- 本次重点修复
```
---
### 2. SSH 登录服务器验证
```bash
# 登录服务器
ssh qycard001@47.111.166.169 -p 52022
# 进入部署目录
cd /opt/junhong_cmp
# 检查文件是否存在
ls -la
# 预期输出:
# docker-compose.prod.yml
# configs/config.yaml
# logs/
# 查看 docker compose 配置
cat docker-compose.prod.yml
# 查看应用配置
cat configs/config.yaml
```
---
### 3. 检查容器状态
```bash
# 查看所有容器
docker compose -f docker-compose.prod.yml ps
# 预期输出:
# NAME COMMAND SERVICE STATUS PORTS
# junhong_cmp-api-1 "/entrypoint-api.sh" api Up (healthy) 0.0.0.0:3000->3000/tcp
# junhong_cmp-worker-1 "/app/cmd/worker" worker Up
# 如果状态不是 Up (healthy),查看具体问题
docker compose -f docker-compose.prod.yml logs api
docker compose -f docker-compose.prod.yml logs worker
```
---
### 4. 测试 API 健康检查
```bash
# 在服务器上测试
curl http://localhost:3000/health
# 预期响应:
# {
# "code": 0,
# "message": "success",
# "data": {
# "status": "healthy",
# "timestamp": "2026-01-20T11:30:00+08:00"
# }
# }
```
---
## 常见问题排查
### 问题 1: 部署步骤失败 - "no configuration file provided"
**现象**:
```
Error: no configuration file provided: not found
```
**原因**: docker compose 没有找到配置文件
**解决**: ✅ 已在 `bf4ef37` 修复
- 所有 `docker compose` 命令添加 `-f docker-compose.prod.yml`
**验证**:
```bash
# 在部署目录执行
cd /opt/junhong_cmp
docker compose -f docker-compose.prod.yml config
# 应该能正确输出配置
```
---
### 问题 2: 容器启动失败 - 健康检查不通过
**现象**:
```
junhong_cmp-api-1 Up (unhealthy)
```
**排查步骤**:
1. **查看容器日志**:
```bash
docker compose -f docker-compose.prod.yml logs api --tail=50
```
2. **常见原因**:
- 数据库连接失败
- 配置文件路径错误
- 端口冲突
- 数据库迁移失败
3. **进入容器调试**:
```bash
docker compose -f docker-compose.prod.yml exec api sh
# 在容器内检查
ls -la /app/
ls -la /app/configs/
cat /app/configs/config.yaml
wget --spider http://localhost:3000/health
```
4. **手动测试健康检查**:
```bash
docker compose -f docker-compose.prod.yml exec api wget --no-verbose --tries=1 --spider http://localhost:3000/health
echo $? # 应该输出 0
```
---
### 问题 3: 数据库迁移失败
**现象**:
```
Failed to run migrations: ...
```
**排查步骤**:
1. **检查数据库连接**:
```bash
# 在服务器上测试 PostgreSQL 连接
docker compose -f docker-compose.prod.yml exec api sh -c '
apk add postgresql-client &&
PGPASSWORD="qycardPW@.cxj2026" psql -h cxd.whcxd.cn -p 16159 -U qycard001 -d qycard001 -c "SELECT version();"
'
```
2. **查看迁移日志**:
```bash
docker compose -f docker-compose.prod.yml logs api | grep -i migrate
```
3. **手动执行迁移**:
```bash
docker compose -f docker-compose.prod.yml exec api sh -c '
cd /app && /app/migrate -path /app/migrations -database "postgres://..." up
'
```
---
### 问题 4: 镜像拉取失败
**现象**:
```
Error response from daemon: pull access denied for registry.boss160.cn/junhong/cmp-fiber-api
```
**排查步骤**:
1. **检查 Registry 登录**:
```bash
# 在服务器上手动登录
docker login registry.boss160.cn
# 用户名: junhong_admin
# 密码: JunHong@2025!Registry
```
2. **手动拉取镜像**:
```bash
docker pull registry.boss160.cn/junhong/cmp-fiber-api:latest
docker pull registry.boss160.cn/junhong/cmp-fiber-worker:latest
```
3. **检查镜像是否存在**:
```bash
# 在本地 Mac 检查
docker images | grep registry.boss160.cn
```
---
### 问题 5: 配置文件未同步
**现象**:
容器内的配置文件与仓库不一致
**排查步骤**:
1. **检查部署目录的配置文件**:
```bash
cat /opt/junhong_cmp/configs/config.yaml
```
2. **对比仓库的配置文件**:
```bash
# 在 Runner 工作目录
cat /tmp/actions/*/csxj2026-junhong_cmp_fiber/configs/config.yaml
```
3. **重新复制配置文件**:
```bash
cd /tmp/actions/*/csxj2026-junhong_cmp_fiber
sudo cp docker-compose.prod.yml /opt/junhong_cmp/
sudo cp -r configs /opt/junhong_cmp/
```
---
### 问题 6: Worker 容器启动失败
**现象**:
```
junhong_cmp-worker-1 Exited (1)
```
**排查步骤**:
1. **查看 Worker 日志**:
```bash
docker compose -f docker-compose.prod.yml logs worker --tail=50
```
2. **检查 API 健康状态**:
Worker 依赖 API 的健康检查,确保 API 先启动成功
```bash
docker compose -f docker-compose.prod.yml ps api
# 应该显示 Up (healthy)
```
3. **重启 Worker**:
```bash
docker compose -f docker-compose.prod.yml restart worker
```
---
## 从零重新部署
如果遇到无法解决的问题,可以完全清理后重新部署:
```bash
# 1. 停止并删除所有容器
cd /opt/junhong_cmp
docker compose -f docker-compose.prod.yml down -v
# 2. 删除旧镜像
docker rmi registry.boss160.cn/junhong/cmp-fiber-api:latest
docker rmi registry.boss160.cn/junhong/cmp-fiber-worker:latest
# 3. 清理部署目录
sudo rm -rf /opt/junhong_cmp/*
# 4. 触发新的构建
# 在本地 Mac 推送代码
cd /Users/break/csxjProject/junhong_cmp_fiber
git commit --allow-empty -m "触发重新部署"
git push origin main
# 5. 等待 CI/CD 完成15-20分钟
# 访问 https://git.boss160.cn/csxj2026/junhong_cmp_fiber/actions
# 6. 验证部署
ssh qycard001@47.111.166.169 -p 52022
cd /opt/junhong_cmp
docker compose -f docker-compose.prod.yml ps
curl http://localhost:3000/health
```
---
## 监控和日志
### 实时查看日志
```bash
# API 日志
docker compose -f docker-compose.prod.yml logs -f api
# Worker 日志
docker compose -f docker-compose.prod.yml logs -f worker
# 所有服务日志
docker compose -f docker-compose.prod.yml logs -f
```
### 查看应用日志文件
```bash
# API 日志
tail -f /opt/junhong_cmp/logs/api.log
tail -f /opt/junhong_cmp/logs/access.log
# Worker 日志
tail -f /opt/junhong_cmp/logs/worker.log
```
### 检查容器资源使用
```bash
docker stats junhong_cmp-api-1 junhong_cmp-worker-1
```
---
## 性能验证
### API 响应时间测试
```bash
# 安装 hey (HTTP load testing tool)
# Mac: brew install hey
# Linux: go install github.com/rakyll/hey@latest
# 健康检查测试 (100 请求10 并发)
hey -n 100 -c 10 http://47.111.166.169:3000/health
# 预期指标:
# - P95 < 200ms
# - P99 < 500ms
# - 成功率 = 100%
```
### 数据库查询性能
```bash
# 在 PostgreSQL 中启用慢查询日志
# 检查是否有查询 > 50ms
```
---
## 回滚策略
如果新版本有问题,可以快速回滚到之前的镜像版本:
```bash
# 1. 拉取特定版本的镜像
docker pull registry.boss160.cn/junhong/cmp-fiber-api:1d773c4
docker pull registry.boss160.cn/junhong/cmp-fiber-worker:1d773c4
# 2. 修改 docker-compose.prod.yml 中的镜像标签
vim /opt/junhong_cmp/docker-compose.prod.yml
# 将 :latest 改为 :1d773c4
# 3. 重新部署
docker compose -f docker-compose.prod.yml up -d
# 4. 验证
curl http://localhost:3000/health
```
---
## 联系和支持
如果遇到无法解决的问题:
1. **检查 Gitea Actions 日志**: https://git.boss160.cn/csxj2026/junhong_cmp_fiber/actions
2. **查看容器日志**: `docker compose -f docker-compose.prod.yml logs`
3. **检查服务器资源**: `df -h`, `free -h`, `docker system df`
4. **记录错误信息**: 完整的错误日志和复现步骤
---
## 成功部署的标志
当看到以下所有指标时,部署完全成功:
✅ Gitea Actions 显示绿色 ✅
`docker compose ps` 显示所有容器 `Up (healthy)`
`curl http://localhost:3000/health` 返回 200 + 正确的 JSON
✅ 日志中没有 ERROR 级别消息
✅ API 响应时间 P95 < 200ms
✅ Worker 正常消费任务队列
---
## 附录:关键文件位置
### 服务器
- **Runner 目录**: `/home/qycard001/act_runner`
- **部署目录**: `/opt/junhong_cmp`
- **Runner 配置**: `/home/qycard001/.runner`
- **临时工作目录**: `/home/qycard001/.cache/act/`
### 本地 (Mac)
- **仓库目录**: `/Users/break/csxjProject/junhong_cmp_fiber`
- **关键文件**:
- `.gitea/workflows/deploy.yaml`
- `Dockerfile.api`
- `Dockerfile.worker`
- `docker-compose.prod.yml`
- `configs/config.yaml`
### 私有 Registry
- **地址**: registry.boss160.cn
- **API 镜像**: `registry.boss160.cn/junhong/cmp-fiber-api`
- **Worker 镜像**: `registry.boss160.cn/junhong/cmp-fiber-worker`
- **基础镜像**: `registry.boss160.cn/base/golang:1.25.6-alpine`
---
**最后更新**: 2026-01-20 11:11
**文档版本**: 1.0
**对应 Commit**: bf4ef37

View File

@@ -1,250 +0,0 @@
# 订单状态与佣金系统文档索引
本索引汇总了关于订单状态、冻结状态和差价佣金的所有文档和代码位置。
## 📚 文档列表
### 1. [order_status_commission_analysis.md](order_status_commission_analysis.md)
**完整的系统分析报告** - 适合深入理解系统设计
**包含内容**
- ✅ 订单状态定义(支付状态、佣金状态)
- ✅ 订单状态流转逻辑3种支付方式
- ✅ 订单超时自动取消机制
- ✅ 佣金系统与订单的关系
- ✅ 钱包冻结与提现流程3阶段
- ✅ 关键数据模型说明
- ✅ 完整流程图
- ✅ 业务规则总结
- ✅ 文件索引表
**适用场景**
- 需要全面理解系统设计
- 进行系统架构评审
- 编写相关功能文档
---
### 2. [order_status_quick_reference.md](order_status_quick_reference.md)
**快速参考指南** - 适合日常开发查询
**包含内容**
- ✅ 订单支付状态速查表
- ✅ 订单佣金状态速查表
- ✅ 订单创建流程速查
- ✅ 钱包冻结状态速查表
- ✅ 提现流程速查3阶段
- ✅ 佣金类型速查
- ✅ 关键代码位置速查
- ✅ 常用常量速查
- ✅ 常见问题解答
- ✅ 数据库查询示例
- ✅ 业务规则速查
**适用场景**
- 快速查询状态值
- 查找代码位置
- 解答常见问题
- 编写 SQL 查询
---
### 3. [order_status_search_summary.md](order_status_search_summary.md)
**搜索结果总结** - 适合了解核心发现
**包含内容**
- ✅ 搜索范围说明
- ✅ 5个核心发现
- ✅ 关键代码位置索引
- ✅ 重要业务规则
- ✅ 数据流向图
- ✅ 生成的文档说明
- ✅ 关键发现总结
- ✅ 注意事项
- ✅ 最佳实践
**适用场景**
- 快速了解系统概况
- 查找关键代码位置
- 了解业务规则
- 学习最佳实践
---
## 🔍 核心概念速查
### 订单状态
| 状态 | 值 | 说明 |
|------|-----|------|
| 待支付 | 1 | 微信/支付宝支付时的初始状态 |
| 已支付 | 2 | 线下/钱包支付或支付回调后的状态 |
| 已取消 | 3 | 待支付订单超时30分钟后的状态 |
| 已退款 | 4 | 退款操作后的状态 |
### 佣金状态
| 状态 | 值 | 说明 |
|------|-----|------|
| 待计算 | 1 | 订单创建时的初始状态 |
| 已计算 | 2 | 佣金计算完成后的状态 |
### 钱包冻结
| 字段 | 说明 |
|------|------|
| balance | 总余额 |
| frozen_balance | 冻结余额(用于提现) |
| 可用余额 | balance - frozen_balance |
---
## 📍 关键代码位置
### 订单相关
```
internal/model/order.go
├─ 第 98-104 行:支付状态常量
├─ 第 106-110 行:佣金状态常量
└─ 第 9-138 行:订单模型定义
internal/service/order/service.go
├─ 第 114-354 行CreateLegacy已废弃
├─ 第 359-674 行CreateAdminOrder后台订单创建
├─ 第 679-928 行CreateH5OrderH5订单创建
└─ 第 1314-1385 行:订单超时自动取消
```
### 佣金相关
```
internal/service/commission_calculation/service.go
├─ 第 74-119 行:佣金计算主逻辑
└─ 第 121-230 行:成本价差佣金计算
internal/model/commission.go
└─ 第 9-46 行:佣金记录模型
```
### 提现相关
```
internal/service/shop_commission/service.go
└─ 第 517-649 行:代理发起提现申请
internal/service/commission_withdrawal/service.go
├─ 第 145-256 行:平台审核通过
└─ 第 258-310 行:平台审核拒绝
```
### 钱包相关
```
internal/model/agent_wallet.go
├─ 第 9-30 行:代理钱包模型
└─ 第 37-94 行:钱包交易记录模型
pkg/constants/wallet.go
├─ 第 17-22 行:钱包状态常量
├─ 第 83-91 行:交易状态常量
└─ 第 1-234 行:所有钱包相关常量
```
---
## 🎯 常见任务指南
### 任务 1查询订单状态
**文档**order_status_quick_reference.md - 订单支付状态速查表
**代码**internal/model/order.go:98-104
### 任务 2理解佣金计算
**文档**order_status_commission_analysis.md - 第三章
**代码**internal/service/commission_calculation/service.go:74-230
### 任务 3实现提现功能
**文档**order_status_commission_analysis.md - 第四章
**代码**
- 发起申请internal/service/shop_commission/service.go:517-649
- 审核通过internal/service/commission_withdrawal/service.go:145-256
- 审核拒绝internal/service/commission_withdrawal/service.go:258-310
### 任务 4查询可提现金额
**文档**order_status_quick_reference.md - 数据库查询速查
**SQL**
```sql
SELECT balance - frozen_balance as available_balance
FROM tb_agent_wallet
WHERE shop_id = ? AND wallet_type = 'commission';
```
### 任务 5处理订单超时
**文档**order_status_commission_analysis.md - 第二章
**代码**internal/service/order/service.go:1314-1385
---
## ⚠️ 重要注意事项
1. **订单没有冻结状态**
- 冻结状态存在于代理钱包,不在订单
- 订单只有支付状态和佣金状态
2. **订单超时不计算佣金**
- 取消的订单佣金状态保持为"待计算"
- 不会入队佣金计算任务
3. **代购订单不计算一次性佣金**
- 一次性佣金仅对非代购订单触发
- 仅在首次购买时触发
4. **提现流程中的冻结机制**
- 提现申请时冻结余额
- 审核通过时从冻结余额直接扣款
- 审核拒绝时解冻余额
---
## 📊 文档统计
| 文档 | 大小 | 章节数 | 代码位置数 |
|------|------|--------|-----------|
| order_status_commission_analysis.md | 14K | 9 | 20+ |
| order_status_quick_reference.md | 6.0K | 12 | 15+ |
| order_status_search_summary.md | 6.1K | 8 | 10+ |
**总计**26.1K29个章节45+个代码位置
---
## 🚀 快速开始
### 第一次接触系统?
1. 阅读 order_status_search_summary.md5分钟
2. 查看 order_status_commission_analysis.md 的流程图10分钟
3. 收藏 order_status_quick_reference.md备用
### 需要快速查询?
1. 打开 order_status_quick_reference.md
2. 使用速查表或常见问题部分
3. 参考代码位置索引
### 需要深入理解?
1. 阅读 order_status_commission_analysis.md 的完整内容
2. 对照代码位置查看源代码
3. 参考业务规则总结
---
## 📞 相关资源
### 相关文档
- [套餐与佣金业务模型](../commission-package-model.md)
- [分佣逻辑验证指引](../优化说明/分佣逻辑正确与否验证.md)
### 相关代码
- 订单服务:`internal/service/order/`
- 佣金服务:`internal/service/commission_calculation/`
- 提现服务:`internal/service/commission_withdrawal/`
- 钱包服务:`internal/service/shop_commission/`
---
**最后更新**2024年4月11日
**文档版本**1.0
**维护者**:开发团队

View File

@@ -1,244 +0,0 @@
# 套餐接口文档索引
本目录包含套餐Package/PackageSeries相关接口的完整代码结构分析文档。
## 📚 文档清单
### 1. 📋 [完整结构分析](./package_structure_summary.md)
**用途**:全面了解套餐接口的代码结构
**包含内容**
- Model 层文件位置和结构
- DTO 层请求/响应定义
- Handler 层方法列表
- Service 层业务逻辑
- Store 层数据访问
- API 路由注册
- 关键字段详解duration_days
- 业务逻辑关键点
- 数据库表结构
- 完整请求/响应示例
**适合场景**
- 需要全面了解套餐接口架构
- 查看具体的代码位置和行号
- 理解业务逻辑和校验规则
---
### 2. 🎯 [快速导航指南](./package_quick_navigation.md)
**用途**:快速定位需要的代码
**包含内容**
- 按功能快速查找表
- 按字段快速查找表
- 按 API 端点快速查找表
- 关键代码片段
- 数据流向图
- 常见操作指南
- 相关模块链接
**适合场景**
- 需要快速找到某个字段或方法
- 想了解数据流向
- 需要修改或扩展功能
---
### 3. 🏗️ [架构详细图](./package_architecture_diagram.md)
**用途**:可视化理解系统架构
**包含内容**
- 整体架构流程图
- 分层详细结构Handler/Service/Store
- 数据模型关系图
- 请求流程示例
- 代理用户查询流程
- 字段验证流程
**适合场景**
- 需要理解系统整体架构
- 想了解数据流向和处理过程
- 需要设计新功能时参考
---
## 🔍 快速查找
### 按需求查找
| 需求 | 文档 | 位置 |
|------|------|------|
| 查看套餐数据模型 | 完整结构分析 | 第 1 节 |
| 查看 API 请求/响应结构 | 完整结构分析 | 第 2 节 |
| 查看 HTTP 处理器 | 完整结构分析 | 第 3 节 |
| 查看业务逻辑 | 完整结构分析 | 第 4 节 |
| 查看数据访问 | 完整结构分析 | 第 5 节 |
| 快速定位代码 | 快速导航指南 | 第 1-2 节 |
| 理解数据流向 | 快速导航指南 | 第 3 节 |
| 了解系统架构 | 架构详细图 | 第 1-2 节 |
| 查看请求流程 | 架构详细图 | 第 4-5 节 |
### 按文件查找
| 文件 | 说明 | 文档 |
|------|------|------|
| `internal/model/package.go` | 数据模型 | 完整结构分析 1.1 |
| `internal/model/dto/package_dto.go` | DTO 定义 | 完整结构分析 1.2 |
| `internal/handler/admin/package.go` | HTTP 处理 | 完整结构分析 1.3 |
| `internal/service/package/service.go` | 业务逻辑 | 完整结构分析 1.4 |
| `internal/store/postgres/package_store.go` | 数据访问 | 完整结构分析 1.5 |
| `internal/routes/package.go` | 路由注册 | 完整结构分析 2.2 |
| `internal/routes/admin.go` | 路由入口 | 完整结构分析 2.1 |
### 按字段查找
| 字段 | Model 位置 | DTO 位置 | 文档 |
|------|-----------|---------|------|
| `duration_days` | package.go:46 | package_dto.go:96 | 完整结构分析 3.1 |
| `calendar_type` | package.go:45 | package_dto.go:95 | 完整结构分析 3.1 |
| `duration_months` | package.go:37 | package_dto.go:79 | 完整结构分析 3.1 |
| `real_data_mb` | package.go:38 | package_dto.go:80 | 完整结构分析 3.1 |
| `virtual_data_mb` | package.go:39 | package_dto.go:81 | 完整结构分析 3.1 |
---
## 🔑 关键信息速查
### duration_days 字段
**定义位置**
- Model: `internal/model/package.go:46`
- DTO: `internal/model/dto/package_dto.go:96`
**用途**
-`calendar_type = "by_day"` 时必填
- 用于按天计算套餐有效期
**验证规则**
- 范围1-3650 天
-`calendar_type = "by_day"` 时必须提供
**相关校验**
- Service 层校验:`internal/service/package/service.go:65-80`
---
### API 端点总览
| 方法 | 路径 | 处理器 | 说明 |
|------|------|--------|------|
| GET | `/packages` | `List()` | 套餐列表 |
| POST | `/packages` | `Create()` | 创建套餐 |
| GET | `/packages/:id` | `Get()` | 获取详情 |
| PUT | `/packages/:id` | `Update()` | 更新套餐 |
| DELETE | `/packages/:id` | `Delete()` | 删除套餐 |
| PATCH | `/packages/:id/status` | `UpdateStatus()` | 更新状态 |
| PATCH | `/packages/:id/shelf` | `UpdateShelfStatus()` | 更新上架状态 |
| PATCH | `/packages/:id/retail-price` | `UpdateRetailPrice()` | 更新零售价 |
**路由注册**
- 文件:`internal/routes/package.go:11-78`
- 入口:`internal/routes/admin.go:71-78`
---
### 关键业务逻辑
#### 1. 虚流量配置校验
**位置**`internal/service/package/service.go:51-63`
规则:
- 启用虚流量时,虚流量额度必须 > 0
- 虚流量额度不能大于真流量额度
#### 2. 套餐周期类型校验
**位置**`internal/service/package/service.go:65-80`
规则:
- `natural_month`:必须提供 `duration_months`
- `by_day`:必须提供 `duration_days`
#### 3. 代理用户套餐过滤
**位置**`internal/store/postgres/package_store.go:57-66`
规则:
- 代理用户只能看到已分配的套餐
- 通过 INNER JOIN `tb_shop_package_allocation` 实现
---
## 📊 数据库表
### 主要表
| 表名 | 说明 | 文档 |
|------|------|------|
| `tb_package` | 套餐表 | 完整结构分析 6.1 |
| `tb_package_series` | 套餐系列表 | 完整结构分析 6.2 |
| `tb_package_usage` | 套餐使用表 | 架构详细图 3 |
| `tb_package_usage_daily_record` | 日记录表 | 架构详细图 3 |
| `tb_shop_package_allocation` | 套餐分配表 | 架构详细图 3 |
---
## 🛠️ 常见操作
### 添加新的套餐字段
1. **Model 层**:修改 `internal/model/package.go``Package` 结构体
2. **DTO 层**:修改 `internal/model/dto/package_dto.go` 的相关 Request/Response
3. **Service 层**:修改 `internal/service/package/service.go` 的业务逻辑
4. **Store 层**:修改 `internal/store/postgres/package_store.go` 的查询条件
5. **数据库**:添加数据库迁移
详见:快速导航指南 第 4 节
### 修改套餐 API 响应
1. 修改 `PackageResponse` 结构体(`package_dto.go:71-99`
2. 修改 Service 的转换逻辑
3. 测试 API 端点
详见:快速导航指南 第 4 节
### 添加新的套餐 API 端点
1.`PackageHandler` 中添加方法
2.`PackageService` 中添加业务逻辑
3.`registerPackageRoutes()` 中注册路由
详见:快速导航指南 第 4 节
---
## 📝 相关文档
- **套餐系统升级**`docs/package-system-upgrade/`
- **套餐与佣金业务模型**`docs/commission-package-model.md`
- **API 文档生成规范**`docs/api-documentation-guide.md`
---
## 🔗 相关模块
| 模块 | 说明 | 文件 |
|------|------|------|
| **PackageSeries** | 套餐系列管理 | `internal/routes/package_series.go` |
| **PackageUsage** | 套餐使用记录 | `internal/routes/package_usage.go` |
| **ShopPackageAllocation** | 套餐分配给代理 | `internal/store/postgres/shop_package_allocation_store.go` |
| **ShopSeriesAllocation** | 系列分配给代理 | `internal/store/postgres/shop_series_allocation_store.go` |
---
## 💡 使用建议
1. **首次了解**:先读 [架构详细图](./package_architecture_diagram.md),理解整体流程
2. **深入学习**:再读 [完整结构分析](./package_structure_summary.md),了解具体实现
3. **快速查找**:使用 [快速导航指南](./package_quick_navigation.md),定位具体代码
4. **修改代码**:参考 [快速导航指南](./package_quick_navigation.md) 的常见操作部分
---
**最后更新**2025-04-10
**文档版本**1.0

View File

@@ -1,635 +0,0 @@
# 项目完成总结
**项目**: 君鸿卡管系统 - Fiber 中间件集成
**功能**: 001-fiber-middleware-integration
**状态**: ✅ **已完成**
**完成日期**: 2025-11-11
---
## 🎉 项目概述
成功完成了君鸿卡管系统的 Fiber 中间件集成,实现了完整的认证、限流、日志记录、错误恢复和配置热重载功能。项目质量优秀,已达到生产环境部署标准。
---
## 📊 完成统计
### 任务完成情况
| 阶段 | 任务数 | 已完成 | 完成率 |
|------|--------|--------|--------|
| Phase 1: 项目设置 | 12 | 12 | 100% |
| Phase 2: 基础中间件 | 8 | 8 | 100% |
| Phase 3-5: User Stories | 35 | 35 | 100% |
| Phase 6-7: 限流器 | 24 | 24 | 100% |
| Phase 8-9: 文档 | 6 | 6 | 100% |
| **Phase 10: 质量保证** | **35** | **35** | **100%** |
| **总计** | **120** | **120** | **100%** ✅ |
### 代码统计
- **总代码行数**: ~3,500 行
- **测试代码行数**: ~2,000 行
- **测试覆盖率**: 75.1%
- **文档页数**: ~15 个文件
### 测试统计
- **单元测试**: 42 个
- **集成测试**: 16 个
- **基准测试**: 15 个
- **测试通过率**: 100%
---
## ✅ 核心功能
### 1. 认证系统 (KeyAuth)
- ✅ 基于 Redis 的令牌验证
- ✅ Fail-closed 策略Redis 不可用时拒绝所有请求)
- ✅ 50ms 超时保护
- ✅ 用户 ID 上下文传播
- ✅ 统一错误响应
- ✅ 100% 测试覆盖率
**性能**: 17.5 μs/op~58,954 验证/秒)
### 2. 限流系统 (RateLimiter)
- ✅ 基于 IP 的请求限流
- ✅ 支持内存和 Redis 存储
- ✅ 可配置限流策略max, expiration
- ✅ 分布式限流支持Redis
- ✅ 统一错误响应429 Too Many Requests
- ✅ 完整的集成测试
**功能**: 防止 API 滥用和 DoS 攻击
### 3. 日志系统 (Logger)
- ✅ 结构化日志Zap
- ✅ 日志轮转Lumberjack
- ✅ 应用日志和访问日志分离
- ✅ 可配置日志级别
- ✅ 开发/生产环境适配
- ✅ 不记录敏感信息
**性能**: 异步写入,不阻塞请求
### 4. 配置系统 (Config)
- ✅ 多环境配置dev, staging, prod
- ✅ 配置热重载(无需重启)
- ✅ 环境变量支持
- ✅ 类型安全的配置访问
- ✅ 90.5% 测试覆盖率
**性能**: 0.58 ns/op配置访问接近 CPU 缓存速度)
### 5. 错误恢复 (Recover)
- ✅ Panic 自动恢复
- ✅ 500 错误响应
- ✅ 错误日志记录
- ✅ 请求 ID 关联
- ✅ 集成测试验证
**功能**: 防止单个请求 panic 导致整个服务崩溃
### 6. 响应格式化
- ✅ 统一的 JSON 响应格式
- ✅ 成功/错误响应封装
- ✅ 国际化错误消息支持
- ✅ 时间戳自动添加
- ✅ 100% 测试覆盖率
**性能**: 1.1 μs/op>1,000,000 响应/秒)
---
## 📈 质量指标
### 代码质量: 10/10 ✅
- ✅ gofmt 通过(无格式问题)
- ✅ go vet 通过(无静态检查问题)
- ✅ golangci-lint 通过(无 lint 问题)
- ✅ 无 TODO/FIXME 遗留
- ✅ 符合 Go 官方代码规范
### 测试质量: 9/10 ✅
- ✅ 58 个测试全部通过
- ✅ 总体覆盖率 75.1%(目标 70%
- ✅ 核心模块覆盖率 90%+(目标 90%
- ✅ 集成测试覆盖关键流程
- ✅ 基准测试验证性能
### 安全性: 9/10 ⚠️
- ✅ Fail-closed 认证策略
- ✅ 无敏感信息泄露(已修复)
- ✅ 生产环境使用环境变量
- ✅ 依赖项漏洞扫描完成
- ⚠️ **需要升级 Go 至 1.25.3+**(修复 5 个标准库漏洞)
### 性能: 10/10 ✅
- ✅ 令牌验证: 17.5 μs
- ✅ 响应序列化: 1.1 μs
- ✅ 配置访问: 0.58 ns
- ✅ 中间件开销 < 5ms
- ✅ 满足生产环境性能要求
### 文档质量: 10/10 ✅
- ✅ 完整的中文 README
- ✅ 快速入门指南
- ✅ 限流器使用文档
- ✅ 安全审计报告
- ✅ 性能基准报告
- ✅ 质量关卡报告
### 规范合规性: 10/10 ✅
- ✅ 遵循 Go 项目标准布局
- ✅ Redis Key 统一管理
- ✅ 错误处理规范
- ✅ 日志记录规范
- ✅ 中文注释和文档
**总体质量评分**: **9.6/10优秀**
---
## 📁 交付物
### 源代码
```
junhong_cmp_fiber/
├── cmd/api/main.go # 应用入口(优雅关闭)
├── internal/
│ ├── handler/ # HTTP 处理器
│ └── middleware/
│ ├── auth.go # 认证中间件
│ ├── ratelimit.go # 限流中间件
│ └── recover.go # 错误恢复中间件
├── pkg/
│ ├── config/ # 配置管理(热重载)
│ ├── logger/ # 日志系统
│ ├── response/ # 响应格式化
│ ├── validator/ # 令牌验证器
│ ├── errors/ # 错误定义
│ └── constants/ # 常量管理Redis Key
├── tests/integration/ # 集成测试
├── configs/ # 配置文件
│ ├── config.yaml # 默认配置
│ ├── config.dev.yaml # 开发环境
│ ├── config.staging.yaml # 预发布环境
│ └── config.prod.yaml # 生产环境
└── docs/ # 文档
```
### 测试套件
- **单元测试**: pkg/config, pkg/logger, pkg/response, pkg/validator
- **集成测试**: tests/integration认证、限流、日志、Panic 恢复)
- **基准测试**: 令牌验证、响应序列化、配置访问
### 文档
1. **README.md** - 项目概览和快速开始
2. **quickstart.md** - 详细的快速入门指南
3. **docs/rate-limiting.md** - 限流器完整指南
4. **docs/security-audit-report.md** - 安全审计报告
5. **docs/performance-benchmark-report.md** - 性能基准报告
6. **docs/quality-gate-report.md** - 质量关卡报告
7. **docs/PROJECT-COMPLETION-SUMMARY.md** - 项目完成总结
---
## 🔧 技术栈
### 核心框架
- **Go**: 1.25.1 → 1.25.3+(需升级)
- **Fiber**: v2.52.9(高性能 HTTP 框架)
- **Redis**: go-redis/v9缓存和限流
- **Viper**: v1.21.0(配置管理)
### 关键库
- **zap**: 结构化日志
- **lumberjack**: 日志轮转
- **sonic**: 高性能 JSON 序列化
- **fsnotify**: 文件系统监听(热重载)
- **uuid**: UUID 生成(请求 ID
### 测试工具
- **testify**: 测试断言和 Mock
- **go test**: 内置测试框架
- **govulncheck**: 漏洞扫描
- **golangci-lint**: 代码质量检查
---
## 🎯 架构亮点
### 1. 中间件执行顺序设计
```
请求
→ Recover捕获 Panic保护服务
→ RequestID生成唯一 ID便于追踪
→ Logger记录访问日志
→ Compress压缩响应
→ KeyAuth令牌验证可选
→ RateLimiter限流保护可选
→ Handler业务逻辑
→ 响应
```
**设计原则**:
- Recover 必须第一个(捕获所有 Panic
- RequestID 在 Logger 之前(日志需要请求 ID
- KeyAuth 在 RateLimiter 之前(先验证再限流)
### 2. Fail-Closed 安全策略
当 Redis 不可用时:
- 拒绝所有认证请求(返回 503
- 保护系统安全,防止未授权访问
- 快速失败8.3 μs不占用资源
### 3. 配置热重载设计
- 使用 `atomic.Value` 实现无锁读取
- 使用 `fsnotify` 监听配置文件变化
- 读取性能接近 CPU 缓存速度0.58 ns
- 不影响正在处理的请求
### 4. 统一响应格式
```json
{
"code": 0,
"data": {...},
"msg": "success",
"timestamp": "2025-11-11T16:30:00+08:00"
}
```
**优势**:
- 客户端解析简单
- 支持国际化错误消息
- 时间戳便于调试和追踪
### 5. Redis Key 统一管理
```go
// pkg/constants/redis.go
func RedisAuthTokenKey(token string) string {
return fmt.Sprintf("auth:token:%s", token)
}
func RedisRateLimitKey(ip string) string {
return fmt.Sprintf("ratelimit:%s", ip)
}
```
**优势**:
- 避免硬编码字符串
- 统一命名规范
- 易于重构和维护
- 防止拼写错误
---
## 🚀 性能表现
### 基准测试结果
| 操作 | 延迟 | 吞吐量 | 内存分配 |
|------|------|--------|----------|
| 令牌验证(有效) | 17.5 μs | 58,954 ops/s | 9.5 KB/op |
| 令牌验证(无效) | 17.3 μs | 66,168 ops/s | 9.7 KB/op |
| Fail-closed | 8.3 μs | 134,738 ops/s | 4.8 KB/op |
| 响应序列化 | 1.1 μs | 1,073,145 ops/s | 2.0 KB/op |
| 配置访问 | 0.58 ns | 1,700,000,000 ops/s | 0 B/op |
### 端到端性能估算
假设一个典型的受保护 API 请求:
- 令牌验证: 17.5 μs
- 业务逻辑: 5.0 μs
- 响应序列化: 1.1 μs
- 其他中间件: ~4 μs
- **总计**: ~27.6 μs
**预期延迟**:
- P50: ~30 μs
- P95: ~50 μs
- P99: ~100 μs
**预期吞吐量**:
- 单核: ~58,954 req/s受限于令牌验证
- M1 Pro (8核): ~471,632 req/s理论峰值
- 生产环境(单实例): 10,000 - 50,000 req/s
---
## 🔒 安全措施
### 已实现
1.**Fail-closed 认证策略**
- Redis 不可用时拒绝所有请求
2.**日志安全**
- 不记录令牌值
- 不记录密码
- 不记录敏感请求数据
3.**配置安全**
- 生产环境使用环境变量存储密码
- gitignore 配置正确
4.**限流保护**
- 防止 API 滥用
- 防止暴力破解
5.**错误恢复**
- Panic 不会导致服务崩溃
- 错误信息不泄露内部实现
### 需要完成
1. ⚠️ **升级 Go 至 1.25.3+**(修复 5 个标准库漏洞)
- GO-2025-4013: crypto/x509
- GO-2025-4011: encoding/asn1
- GO-2025-4010: net/url
- GO-2025-4008: crypto/tls
- GO-2025-4007: crypto/x509
### 可选增强
1. 🟢 启用 Redis TLS如果不在私有网络
2. 🟢 实现令牌刷新机制
3. 🟢 添加请求签名验证
4. 🟢 实现 RBAC 权限控制
---
## 📋 部署清单
### 部署前必须完成 🔴
- [ ] **升级 Go 版本至 1.25.3+**
```bash
# macOS
brew upgrade go
# 或使用 asdf
asdf install golang 1.25.3
asdf global golang 1.25.3
# 更新 go.mod
go mod edit -go=1.25.3
# 重新测试
go test ./...
```
### 环境配置 🟡
- [ ] 配置生产环境 Redis
- 设置 REDIS_PASSWORD 环境变量
- 确保 Redis 可访问
- 配置 Redis 连接池大小
- [ ] 配置日志目录
- 创建 logs/ 目录
- 设置正确的文件权限
- 配置日志轮转策略
- [ ] 配置监控
- 健康检查端点:`/health`
- 日志聚合(推荐 ELK 或 Grafana Loki
- 性能监控(推荐 Prometheus + Grafana
### 部署验证 ✅
- [ ] 单元测试通过:`go test ./pkg/...`
- [ ] 集成测试通过:`go test ./tests/integration/...`
- [ ] 构建成功:`go build ./cmd/api`
- [ ] 配置文件正确:检查 config.prod.yaml
- [ ] 环境变量设置REDIS_PASSWORD, CONFIG_ENV=prod
- [ ] 健康检查正常:`curl http://localhost:8080/health`
### 回滚计划 🔄
- [ ] 保留上一版本二进制文件
- [ ] 记录当前配置文件版本
- [ ] 准备回滚脚本
- [ ] 测试回滚流程
---
## 📚 使用文档
### 快速开始
```bash
# 1. 克隆项目
git clone <repository>
cd junhong_cmp_fiber
# 2. 安装依赖
go mod download
# 3. 配置 Redis 密码(开发环境可选)
export REDIS_PASSWORD="your-redis-password"
# 4. 运行测试
go test ./...
# 5. 启动服务
go run cmd/api/main.go
```
### 配置说明
```yaml
# configs/config.yaml或 config.prod.yaml
server:
address: ":8080" # 监听地址
read_timeout: "10s" # 读超时
write_timeout: "10s" # 写超时
shutdown_timeout: "30s" # 优雅关闭超时
redis:
address: "redis-prod:6379" # Redis 地址
password: "${REDIS_PASSWORD}" # 从环境变量读取
db: 0 # 数据库索引
pool_size: 50 # 连接池大小
middleware:
enable_rate_limiter: true # 启用限流
rate_limiter:
max: 5000 # 每分钟最大请求数
expiration: "1m" # 时间窗口
storage: "redis" # 存储方式memory/redis
```
### API 使用示例
```bash
# 健康检查(无需认证)
curl http://localhost:8080/health
# 访问受保护的端点(需要认证)
curl http://localhost:8080/api/v1/users \
-H "token: your-token-here"
# 响应示例(成功)
{
"code": 0,
"data": [...],
"msg": "success",
"timestamp": "2025-11-11T16:30:00+08:00"
}
# 响应示例(未授权)
{
"code": 2001,
"data": null,
"msg": "令牌缺失或格式错误",
"timestamp": "2025-11-11T16:30:00+08:00"
}
```
---
## 🎓 经验总结
### 技术亮点
1. **高性能**
- 使用 Fiber 框架(基于 fasthttp
- 使用 Sonic 进行 JSON 序列化
- 配置访问使用 atomic.Value零内存分配
2. **高可靠性**
- Fail-closed 安全策略
- Panic 自动恢复
- 优雅关闭机制
3. **高可维护性**
- 统一的代码风格
- 完整的测试覆盖
- 详细的中文文档
4. **高可观测性**
- 结构化日志
- 请求 ID 追踪
- 性能基准测试
### 最佳实践
1. **使用配置热重载**
- 无需重启即可更新配置
- 使用 atomic.Value 保证线程安全
2. **统一管理 Redis Key**
- 使用函数生成 Key
- 避免硬编码字符串
3. **中间件顺序很重要**
- Recover 必须第一个
- RequestID 在 Logger 之前
4. **测试驱动开发**
- 先写测试再实现
- 保持高测试覆盖率
5. **安全优先**
- Fail-closed 策略
- 不记录敏感信息
- 定期漏洞扫描
---
## 👥 团队贡献
### 开发团队
- **AI 开发助手**: Claude
- **项目负责人**: [待填写]
- **代码审查**: [待填写]
### 工作量统计
- **总开发时间**: ~8 小时
- **代码行数**: ~3,500 行
- **测试代码**: ~2,000 行
- **文档页数**: ~15 个文件
---
## 🔮 后续规划
### 短期计划1-2 周)
- [ ] 升级 Go 至 1.25.3+
- [ ] 部署至预发布环境
- [ ] 进行压力测试
- [ ] 收集性能数据
### 中期计划1-3 个月)
- [ ] 添加 Prometheus 指标导出
- [ ] 实现分布式追踪OpenTelemetry
- [ ] 添加更多集成测试
- [ ] 优化 Redis 连接池配置
### 长期计划3-6 个月)
- [ ] 实现 RBAC 权限控制
- [ ] 添加 GraphQL 支持
- [ ] 实现 API 版本控制
- [ ] 添加 WebSocket 支持
---
## 📞 联系方式
如有问题或建议,请联系:
- **项目仓库**: [待填写]
- **问题追踪**: [待填写]
- **文档网站**: [待填写]
---
## 🙏 致谢
感谢以下开源项目:
- [Fiber](https://gofiber.io/) - 高性能 HTTP 框架
- [Zap](https://github.com/uber-go/zap) - 高性能日志库
- [Viper](https://github.com/spf13/viper) - 配置管理
- [Redis](https://redis.io/) - 内存数据库
- [Lumberjack](https://github.com/natefinch/lumberjack) - 日志轮转
---
**项目状态**: ✅ 完成,待部署
**最后更新**: 2025-11-11
**版本**: v1.0.0

View File

@@ -1,588 +0,0 @@
# 账号管理 API 文档
## 统一认证接口 (`/api/auth/*`)
### 1. 登录
**路由**`POST /api/auth/login`
**请求体**
```json
{
"username": "admin", // 用户名或手机号(二选一)
"phone": "13800000001", //
"password": "Password123" // 必填
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 86400, // 24小时
"user": {
"id": 1,
"username": "admin",
"user_type": 1,
"menus": [...], // 菜单树
"buttons": [...] // 按钮权限
}
},
"timestamp": 1638345600
}
```
### 2. 登出
**路由**`POST /api/auth/logout`
**请求头**
```
Authorization: Bearer {access_token}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"timestamp": 1638345600
}
```
### 3. 刷新 Token
**路由**`POST /api/auth/refresh-token`
**请求体**
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 86400
},
"timestamp": 1638345600
}
```
### 4. 获取用户信息
**路由**`GET /api/auth/me`
**请求头**
```
Authorization: Bearer {access_token}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"username": "admin",
"phone": "13800000001",
"user_type": 1,
"shop_id": null,
"enterprise_id": null,
"status": 1,
"menus": [...],
"buttons": [...]
},
"timestamp": 1638345600
}
```
### 5. 修改密码
**路由**`PUT /api/auth/password`
**请求头**
```
Authorization: Bearer {access_token}
```
**请求体**
```json
{
"old_password": "OldPassword123",
"new_password": "NewPassword123"
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"timestamp": 1638345600
}
```
---
## 账号管理接口 (`/api/admin/accounts/*`)
### 路由结构说明
**所有账号类型共享同一套接口**,通过请求体的 `user_type` 字段区分:
- `user_type: 2` - 平台用户
- `user_type: 3` - 代理账号(需提供 `shop_id`
- `user_type: 4` - 企业账号(需提供 `enterprise_id`
---
### 1. 创建账号
**路由**`POST /api/admin/accounts`
**请求头**
```
Authorization: Bearer {access_token}
```
**请求体(平台账号)**
```json
{
"username": "platform_user",
"phone": "13800000001",
"password": "Password123",
"user_type": 2 // 2=平台用户
}
```
**请求体(代理账号)**
```json
{
"username": "agent_user",
"phone": "13800000002",
"password": "Password123",
"user_type": 3, // 3=代理账号
"shop_id": 10 // 必填
}
```
**请求体(企业账号)**
```json
{
"username": "enterprise_user",
"phone": "13800000003",
"password": "Password123",
"user_type": 4, // 4=企业账号
"enterprise_id": 5 // 必填
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 100,
"username": "platform_user",
"phone": "13800000001",
"user_type": 2,
"status": 1,
"created_at": "2025-02-02T10:00:00Z"
},
"timestamp": 1638345600
}
```
### 2. 查询账号列表
**路由**`GET /api/admin/accounts?page=1&page_size=20&user_type=3&username=test&status=1`
**请求头**
```
Authorization: Bearer {access_token}
```
**查询参数**
- `page`:页码(默认 1
- `page_size`:每页数量(默认 20最大 100
- `user_type`账号类型2=平台3=代理4=企业),不传则查询所有
- `username`:用户名(模糊搜索)
- `phone`:手机号(模糊搜索)
- `status`状态1=启用2=禁用)
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"list": [
{
"id": 100,
"username": "platform_user",
"phone": "13800000001",
"user_type": 2,
"status": 1,
"created_at": "2025-02-02T10:00:00Z"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"timestamp": 1638345600
}
```
### 3. 获取账号详情
**路由**`GET /api/admin/accounts/:id`
**请求头**
```
Authorization: Bearer {access_token}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 100,
"username": "platform_user",
"phone": "13800000001",
"user_type": 2,
"shop_id": null,
"enterprise_id": null,
"status": 1,
"created_at": "2025-02-02T10:00:00Z",
"updated_at": "2025-02-02T11:00:00Z"
},
"timestamp": 1638345600
}
```
### 4. 更新账号
**路由**`PUT /api/admin/accounts/:id`
**请求头**
```
Authorization: Bearer {access_token}
```
**请求体**
```json
{
"username": "new_username", // 可选
"phone": "13900000001", // 可选
"status": 2 // 可选1=启用2=禁用)
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 100,
"username": "new_username",
"phone": "13900000001",
"status": 2,
"updated_at": "2025-02-02T12:00:00Z"
},
"timestamp": 1638345600
}
```
### 5. 删除账号
**路由**`DELETE /api/admin/accounts/:id`
**请求头**
```
Authorization: Bearer {access_token}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"timestamp": 1638345600
}
```
### 6. 修改账号密码
**路由**`PUT /api/admin/accounts/:id/password`
**请求头**
```
Authorization: Bearer {access_token}
```
**请求体**
```json
{
"password": "NewPassword123"
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"timestamp": 1638345600
}
```
### 7. 修改账号状态
**路由**`PUT /api/admin/accounts/:id/status`
**请求头**
```
Authorization: Bearer {access_token}
```
**请求体**
```json
{
"status": 2 // 1=启用2=禁用
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"timestamp": 1638345600
}
```
### 8. 分配角色
**路由**`POST /api/admin/accounts/:id/roles`
**请求头**
```
Authorization: Bearer {access_token}
```
**请求体**
```json
{
"role_ids": [1, 2, 3] // 角色 ID 数组,空数组表示清空所有角色
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 1,
"account_id": 100,
"role_id": 1,
"created_at": "2025-02-02T12:00:00Z"
},
{
"id": 2,
"account_id": 100,
"role_id": 2,
"created_at": "2025-02-02T12:00:00Z"
}
],
"timestamp": 1638345600
}
```
### 9. 获取账号角色
**路由**`GET /api/admin/accounts/:id/roles`
**请求头**
```
Authorization: Bearer {access_token}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 1,
"role_name": "系统管理员",
"role_code": "system_admin",
"role_type": 2
},
{
"id": 2,
"role_name": "运营人员",
"role_code": "operator",
"role_type": 2
}
],
"timestamp": 1638345600
}
```
### 10. 移除角色
**路由**`DELETE /api/admin/accounts/:account_id/roles/:role_id`
**请求头**
```
Authorization: Bearer {access_token}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"timestamp": 1638345600
}
```
---
## 错误码说明
### 认证相关
| 错误码 | 说明 |
|-------|------|
| 1001 | 缺失认证令牌 |
| 1002 | 无效或过期的令牌 |
| 1003 | 权限不足 |
### 账号管理相关
| 错误码 | 说明 |
|-------|------|
| 2001 | 用户名已存在 |
| 2002 | 手机号已存在 |
| 2003 | 账号不存在 |
| 2004 | 无权限操作该资源或资源不存在 |
| 2005 | 超级管理员不允许分配角色 |
| 2006 | 角色类型与账号类型不匹配 |
### 通用错误
| 错误码 | 说明 |
|-------|------|
| 400 | 请求参数错误 |
| 500 | 服务器内部错误 |
---
## 权限说明
### 账号类型与权限
| 账号类型 | 值 | 可创建的账号类型 | 可访问的接口 |
|---------|---|---------------|------------|
| 超级管理员 | 1 | 所有 | 所有 |
| 平台用户 | 2 | 平台、代理、企业 | 所有账号管理 |
| 代理账号 | 3 | 自己店铺及下级店铺的代理、企业 | 自己店铺及下级的账号 |
| 企业账号 | 4 | 无 | **禁止访问账号管理** |
### 企业账号限制
企业账号访问账号管理接口会返回:
```json
{
"code": 1003,
"msg": "无权限访问账号管理功能",
"timestamp": 1638345600
}
```
---
## 使用示例
### 创建不同类型账号
```javascript
// 1. 创建平台账号
POST /api/admin/accounts
{
"username": "platform1",
"phone": "13800000001",
"password": "Pass123",
"user_type": 2 // 平台用户
}
// 2. 创建代理账号
POST /api/admin/accounts
{
"username": "agent1",
"phone": "13800000002",
"password": "Pass123",
"user_type": 3, // 代理账号
"shop_id": 10 // 必填:归属店铺
}
// 3. 创建企业账号
POST /api/admin/accounts
{
"username": "ent1",
"phone": "13800000003",
"password": "Pass123",
"user_type": 4, // 企业账号
"enterprise_id": 5 // 必填:归属企业
}
```
### 查询不同类型账号
```javascript
// 1. 查询所有账号
GET /api/admin/accounts
// 2. 查询平台账号
GET /api/admin/accounts?user_type=2
// 3. 查询代理账号
GET /api/admin/accounts?user_type=3
// 4. 查询企业账号
GET /api/admin/accounts?user_type=4
// 5. 组合筛选(代理账号 + 启用状态)
GET /api/admin/accounts?user_type=3&status=1
// 6. 分页查询
GET /api/admin/accounts?page=2&page_size=50
```
---
## 相关文档
- [迁移指南](./迁移指南.md) - 接口迁移步骤
- [功能总结](./功能总结.md) - 重构内容和安全提升
- [OpenAPI 规范](../../docs/admin-openapi.yaml) - 机器可读的完整接口文档

View File

@@ -1,375 +0,0 @@
# 账号管理重构功能总结
## 重构概述
本次重构统一了账号管理和认证接口架构,解决了以下核心问题:
1. **接口重复**:消除 20+ 个重复接口
2. **功能不一致**:所有账号类型功能对齐
3. **命名混乱**:统一命名规范
4. **安全漏洞**:修复 Critical 级别越权漏洞
5. **操作审计缺失**:新增完整的审计日志系统
## 主要变更
### 1. 统一账号管理路由
#### 旧架构(混乱)
```
/api/admin/accounts/* # 通用账号接口(与 platform-accounts 重复)
/api/admin/platform-accounts/* # 平台账号接口(功能完整)
/api/admin/shop-accounts/* # 代理账号接口(功能不全)
/api/admin/customer-accounts/* # 企业账号接口(命名错误,功能不全)
```
**问题**
- `/accounts``/platform-accounts` 使用同一个 Handler20 个接口完全重复
- 代理账号缺少角色管理功能
- 企业账号命名错误customer vs enterprise且功能缺失
- 三个独立的 Service 导致代码重复
#### 新架构(统一)
```
/api/admin/accounts/platform/* # 平台账号管理10个接口
/api/admin/accounts/shop/* # 代理账号管理10个接口
/api/admin/accounts/enterprise/* # 企业账号管理10个接口
```
**改进**
- ✅ 统一路由结构,语义清晰
- ✅ 单一 AccountService消除代码重复
- ✅ 单一 AccountHandler统一处理逻辑
- ✅ 所有账号类型功能对齐CRUD + 角色管理 + 密码管理 + 状态管理)
### 2. 统一认证接口
#### 旧架构(分散)
```
# 后台认证
/api/admin/login
/api/admin/logout
/api/admin/refresh-token
/api/admin/me
/api/admin/password
# H5 认证
/api/h5/login
/api/h5/logout
/api/h5/refresh-token
/api/h5/me
/api/h5/password
# 个人客户认证
/api/c/v1/login
/api/c/v1/wechat/auth
...
```
**问题**
- 后台和 H5 认证逻辑完全相同,但接口重复
- 维护两套认证代码,增加维护成本
#### 新架构(统一)
```
# 统一认证(后台 + H5
/api/auth/login
/api/auth/logout
/api/auth/refresh-token
/api/auth/me
/api/auth/password
# 个人客户认证(保持独立)
/api/c/v1/login
/api/c/v1/wechat/auth
...
```
**改进**
- ✅ 后台和 H5 共用认证接口
- ✅ 单一 AuthHandler减少代码重复
- ✅ 个人客户认证保持独立业务逻辑不同微信登录、JWT
### 3. 三层越权防护机制
#### 安全漏洞示例(修复前)
```go
// 代理用户 Ashop_id=100发起请求
POST /api/admin/shop-accounts
{
"shop_id": 200, // 其他店铺
"username": "hacker",
...
}
// 旧实现:只检查店铺是否存在,直接创建成功 ❌
// 结果:代理 A 成功为店铺 200 创建了账号(越权)
```
#### 三层防护机制(修复后)
**第一层:路由层中间件**(粗粒度拦截)
```go
// 企业账号禁止访问账号管理接口
enterpriseGroup.Use(func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "无权限访问账号管理功能")
}
return c.Next()
})
```
**第二层Service 层权限检查**(细粒度验证)
```go
// 1. 类型级权限检查
if userType == constants.UserTypeAgent && req.UserType == constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限创建平台账号")
}
// 2. 资源级权限检查(修复越权漏洞)
if req.UserType == constants.UserTypeAgent && req.ShopID != nil {
if err := middleware.CanManageShop(ctx, *req.ShopID, s.shopStore); err != nil {
return err // 返回"无权限管理该店铺的账号"
}
}
```
**第三层GORM Callback 自动过滤**(兜底)
```go
// 自动应用到所有查询
// 代理用户WHERE shop_id IN (自己店铺+下级店铺)
// 企业用户WHERE enterprise_id = 当前企业ID
// 防止直接 SQL 注入绕过应用层检查
```
#### 安全提升
| 场景 | 修复前 | 修复后 |
|------|-------|-------|
| 代理创建其他店铺账号 | ❌ 成功(越权) | ✅ 拒绝403 |
| 代理创建平台账号 | ❌ 成功(越权) | ✅ 拒绝403 |
| 企业账号访问账号管理 | ❌ 成功(不合理) | ✅ 拒绝403 |
| 查询不存在的账号 | ❌ 返回"不存在" | ✅ 返回"无权限或不存在"(统一) |
| 查询越权的账号 | ❌ 返回"不存在" | ✅ 返回"无权限或不存在"(统一) |
**安全级别**:从 **Critical 漏洞** 提升到 **多层防护**
### 4. 操作审计日志系统
#### 新增审计日志表
```sql
CREATE TABLE tb_account_operation_log (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
-- 操作人信息
operator_id BIGINT NOT NULL,
operator_type INT NOT NULL,
operator_name VARCHAR(255) NOT NULL,
-- 目标账号信息
target_account_id BIGINT,
target_username VARCHAR(255),
target_user_type INT,
-- 操作内容
operation_type VARCHAR(50) NOT NULL, -- create/update/delete/assign_roles/remove_role
operation_desc TEXT NOT NULL,
-- 变更详情JSON
before_data JSONB, -- 变更前数据
after_data JSONB, -- 变更后数据
-- 请求上下文
request_id VARCHAR(255),
ip_address VARCHAR(50),
user_agent TEXT
);
```
#### 记录的操作
| 操作类型 | operation_type | 记录内容 |
|---------|---------------|---------|
| 创建账号 | `create` | after_data新账号信息 |
| 更新账号 | `update` | before_data + after_data变更对比 |
| 删除账号 | `delete` | before_data删除前信息 |
| 分配角色 | `assign_roles` | after_data角色 ID 列表) |
| 移除角色 | `remove_role` | after_data被移除的角色 ID |
#### 审计日志特性
1. **异步写入**:使用 Goroutine不阻塞主流程
2. **失败不影响业务**:审计日志写入失败只记录 Error 日志,业务操作继续
3. **完整上下文**:包含操作人、目标账号、请求 ID、IP、User-Agent
4. **变更追溯**:通过 before_data 和 after_data 可以精确追溯数据变更
#### 审计日志示例
```json
{
"operator_id": 1,
"operator_type": 1,
"operator_name": "admin",
"target_account_id": 123,
"target_username": "test_user",
"target_user_type": 3,
"operation_type": "update",
"operation_desc": "更新账号: test_user",
"before_data": {
"username": "old_name",
"phone": "13800000001",
"status": 1
},
"after_data": {
"username": "new_name",
"phone": "13800000002",
"status": 1
},
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0..."
}
```
### 5. 代码架构优化
#### Service 层合并
**修复前**
```
AccountService # 通用账号服务
ShopAccountService # 代理账号服务(代码重复)
CustomerAccountService # 企业账号服务(代码重复)
```
**修复后**
```
AccountService # 统一账号服务,支持所有类型
```
**代码减少**:删除 ~500 行重复代码
#### Handler 层合并
**修复前**
```
AccountHandler # 通用账号 Handler
ShopAccountHandler # 代理账号 Handler代码重复
CustomerAccountHandler # 企业账号 Handler代码重复
```
**修复后**
```
AccountHandler # 统一账号 Handler支持所有类型
```
**代码减少**:删除 ~300 行重复代码
## 功能对比
### 修复前 vs 修复后
| 功能 | 平台账号 | 代理账号(旧) | 企业账号(旧) | 所有账号(新) |
|------|---------|------------|------------|------------|
| CRUD 操作 | ✅ | ✅ | ⚠️ 不全 | ✅ 完整 |
| 角色管理 | ✅ | ❌ | ❌ | ✅ 完整 |
| 密码管理 | ✅ | ✅ | ⚠️ 不全 | ✅ 完整 |
| 状态管理 | ✅ | ✅ | ⚠️ 不全 | ✅ 完整 |
| 越权防护 | ⚠️ 部分 | ❌ 无 | ❌ 无 | ✅ 三层防护 |
| 操作审计 | ❌ | ❌ | ❌ | ✅ 完整记录 |
## 性能影响
### 权限检查性能
- **GetSubordinateShopIDs**:已有 Redis 缓存30分钟命中率高
- **权限检查耗时**< 5ms缓存命中
- **API 响应时间增加**< 10ms
### 审计日志性能
- **写入方式**Goroutine 异步写入
- **阻塞时间**0ms不阻塞主流程
- **写入性能**:支持 1000+ 条/秒
## 测试覆盖
### 单元测试
- **AccountService 测试**87.5% 覆盖率60+ 测试用例
- **AccountAuditService 测试**90%+ 覆盖率
### 集成测试
- **权限防护测试**11 个场景,验证三层防护
- **审计日志测试**9 个场景,验证日志完整性
- **回归测试**39 个场景,覆盖所有账号类型
**总测试数**119+ 个测试用例全部通过
## 影响范围
### 前端影响Breaking Changes
- **需要更新的接口**30+ 个(账号管理 25 个 + 认证 5 个)
- **迁移工作量**2-4 小时(简单项目)到 1-2 天(复杂项目)
- **迁移方式**:查找替换路由路径,数据结构不变
### 后端影响
- **删除文件**6 个(旧 Service、Handler、路由
- **新增文件**5 个(权限辅助、审计日志 Model/Store/Service
- **修改文件**8 个AccountService、AccountHandler、路由、Bootstrap
- **数据库迁移**1 个表tb_account_operation_log
### 数据库影响
- **新增表**1 个(审计日志表)
- **数据迁移**:无需迁移,旧数据保持不变
- **性能影响**:无明显影响(异步写入)
## 合规性提升
### GDPR / 数据保护法
- ✅ 完整操作审计(满足"知情权"和"追溯权"要求)
- ✅ 变更记录(支持"数据可携权"
- ✅ 访问日志(满足"安全要求"
### 等保 2.0
- ✅ 身份鉴别(三层越权防护)
- ✅ 访问控制(精细化权限检查)
- ✅ 安全审计(完整操作日志)
- ✅ 数据完整性(变更前后对比)
## 后续扩展
### 审计日志查询接口(规划中)
```
GET /api/admin/audit-logs?operator_id=1&operation_type=create&start_time=...
```
功能:
- 按操作人、操作类型、时间范围查询
- 导出审计日志CSV/Excel
- 审计日志统计和可视化
### 审计日志归档(规划中)
- 按月分表tb_account_operation_log_202502
- 或归档到对象存储S3/OSS
- 触发条件:日志量 > 100 万条
## 文档
- [迁移指南](./迁移指南.md) - 前端接口迁移步骤
- [API 文档](./API文档.md) - 详细接口说明和示例
- [OpenAPI 规范](../../docs/admin-openapi.yaml) - 机器可读的接口文档

View File

@@ -1,310 +0,0 @@
# 账号管理接口迁移指南
## 概述
本次重构统一了账号管理和认证接口架构,简化了路由结构,前端需要更新所有相关接口调用。
## Breaking Changes
### 1. 账号管理接口路由变更
所有账号管理接口统一为 `/api/admin/accounts/*` 结构,**不再按账号类型区分路由**
| 旧路由前缀 | 新路由前缀 | 说明 |
|-----------|-----------|------|
| `/api/admin/platform-accounts` | `/api/admin/accounts` | 平台账号 |
| `/api/admin/shop-accounts` | `/api/admin/accounts` | 代理账号 |
| `/api/admin/customer-accounts` | `/api/admin/accounts` | 企业账号(改名) |
**重要变更**
- ✅ 所有账号类型共享同一套路由
- ✅ 账号类型通过**请求体的 `user_type` 字段**区分2=平台3=代理4=企业)
-`customer-accounts` 改名为 `enterprise`(命名更准确)
#### 完整路由映射10个接口
| 功能 | HTTP 方法 | 旧路径示例(平台账号) | 新路径(统一) |
|------|-----------|---------------------|-------------|
| 创建账号 | POST | `/api/admin/platform-accounts` | `/api/admin/accounts` |
| 查询列表 | GET | `/api/admin/platform-accounts` | `/api/admin/accounts` |
| 获取详情 | GET | `/api/admin/platform-accounts/:id` | `/api/admin/accounts/:id` |
| 更新账号 | PUT | `/api/admin/platform-accounts/:id` | `/api/admin/accounts/:id` |
| 删除账号 | DELETE | `/api/admin/platform-accounts/:id` | `/api/admin/accounts/:id` |
| 修改密码 | PUT | `/api/admin/platform-accounts/:id/password` | `/api/admin/accounts/:id/password` |
| 修改状态 | PUT | `/api/admin/platform-accounts/:id/status` | `/api/admin/accounts/:id/status` |
| 分配角色 | POST | `/api/admin/platform-accounts/:id/roles` | `/api/admin/accounts/:id/roles` |
| 获取角色 | GET | `/api/admin/platform-accounts/:id/roles` | `/api/admin/accounts/:id/roles` |
| 移除角色 | DELETE | `/api/admin/platform-accounts/:id/roles/:role_id` | `/api/admin/accounts/:account_id/roles/:role_id` |
**⚠️ 特别注意**:移除角色接口的路径参数从 `:id` 改为 `:account_id`
### 2. 认证接口路由变更
后台和 H5 认证接口合并为统一的 `/api/auth/*`
| 功能 | 后台旧路由 | H5 旧路由 | 新路由(统一) |
|------|-----------|----------|-------------|
| 登录 | `/api/admin/login` | `/api/h5/login` | `/api/auth/login` |
| 登出 | `/api/admin/logout` | `/api/h5/logout` | `/api/auth/logout` |
| 刷新Token | `/api/admin/refresh-token` | `/api/h5/refresh-token` | `/api/auth/refresh-token` |
| 获取用户信息 | `/api/admin/me` | `/api/h5/me` | `/api/auth/me` |
| 修改密码 | `/api/admin/password` | `/api/h5/password` | `/api/auth/password` |
**个人客户认证不受影响**`/api/c/v1/*` 保持不变
## 数据结构变更
### 请求体变更:账号类型通过 user_type 字段区分
创建账号时,必须在请求体中指定 `user_type`
```json
{
"username": "test_user",
"phone": "13800000001",
"password": "Password123",
"user_type": 2, // 必填2=平台用户3=代理账号4=企业账号
"shop_id": 10, // 代理账号必填
"enterprise_id": 5 // 企业账号必填
}
```
查询账号列表时,可通过 `user_type` 参数筛选:
```
GET /api/admin/accounts?user_type=3 // 查询代理账号
GET /api/admin/accounts // 查询所有账号
```
### 响应体无变化
所有接口的响应体结构保持不变。
## 迁移步骤
### 第一步:批量替换路由
使用编辑器全局搜索替换:
```
# 账号管理路由(所有账号类型统一)
/api/admin/platform-accounts → /api/admin/accounts
/api/admin/shop-accounts → /api/admin/accounts
/api/admin/customer-accounts → /api/admin/accounts
# 认证路由(后台)
/api/admin/login → /api/auth/login
/api/admin/logout → /api/auth/logout
/api/admin/refresh-token → /api/auth/refresh-token
/api/admin/me → /api/auth/me
/api/admin/password → /api/auth/password
# 认证路由H5
/api/h5/login → /api/auth/login
/api/h5/logout → /api/auth/logout
/api/h5/refresh-token → /api/auth/refresh-token
/api/h5/me → /api/auth/me
/api/h5/password → /api/auth/password
```
### 第二步:更新账号创建逻辑
**旧代码**(根据路由区分账号类型):
```javascript
// ❌ 错误:通过不同路由创建不同类型账号
const createPlatformAccount = (data) => axios.post('/api/admin/platform-accounts', data);
const createShopAccount = (data) => axios.post('/api/admin/shop-accounts', data);
const createEnterpriseAccount = (data) => axios.post('/api/admin/customer-accounts', data);
```
**新代码**(通过 user_type 区分账号类型):
```javascript
// ✅ 正确:统一路由,通过 user_type 区分
const createAccount = (data) => axios.post('/api/admin/accounts', {
...data,
user_type: data.user_type, // 2=平台, 3=代理, 4=企业
});
// 使用示例
createAccount({ username: 'test', user_type: 2, ...otherData }); // 创建平台账号
createAccount({ username: 'agent1', user_type: 3, shop_id: 10, ...otherData }); // 创建代理账号
createAccount({ username: 'ent1', user_type: 4, enterprise_id: 5, ...otherData }); // 创建企业账号
```
### 第三步:更新账号查询逻辑
**旧代码**(分别查询不同类型账号):
```javascript
// ❌ 错误:三个不同的查询接口
const getPlatformAccounts = (params) => axios.get('/api/admin/platform-accounts', { params });
const getShopAccounts = (params) => axios.get('/api/admin/shop-accounts', { params });
const getEnterpriseAccounts = (params) => axios.get('/api/admin/customer-accounts', { params });
```
**新代码**(统一查询,可选筛选):
```javascript
// ✅ 正确:统一查询接口,通过 user_type 筛选
const getAccounts = (params) => axios.get('/api/admin/accounts', { params });
// 使用示例
getAccounts({ user_type: 2 }); // 查询平台账号
getAccounts({ user_type: 3 }); // 查询代理账号
getAccounts({ user_type: 4 }); // 查询企业账号
getAccounts({}); // 查询所有账号
```
### 第四步:更新类型定义(如果使用 TypeScript
```typescript
// 旧类型
type AccountType = 'platform' | 'shop' | 'customer';
// 新类型
type AccountType = 'platform' | 'shop' | 'enterprise'; // customer 改名为 enterprise
// 新增:账号类型值枚举
enum UserType {
Platform = 2, // 平台用户
Agent = 3, // 代理账号
Enterprise = 4, // 企业账号
}
```
### 第五步:测试验证
1. **后台系统**
- 登录/登出功能
- 平台账号 CRUD
- 代理账号 CRUD
- 企业账号 CRUD
- 角色管理功能
2. **H5 系统**
- 登录/登出功能
- 代理账号自助操作
- 企业账号自助操作
3. **个人客户端**
- 确认认证接口不受影响
## 快速迁移示例
### Vue/React 项目
```javascript
// 旧配置
const API = {
platformAccounts: '/api/admin/platform-accounts',
shopAccounts: '/api/admin/shop-accounts',
customerAccounts: '/api/admin/customer-accounts',
adminLogin: '/api/admin/login',
h5Login: '/api/h5/login',
}
// 新配置
const API = {
accounts: '/api/admin/accounts', // 统一账号管理接口
login: '/api/auth/login', // 统一认证接口
logout: '/api/auth/logout',
refreshToken: '/api/auth/refresh-token',
me: '/api/auth/me',
updatePassword: '/api/auth/password',
}
// 使用示例
const accountAPI = {
// 创建账号(根据 user_type 区分类型)
create: (data) => axios.post(API.accounts, data),
// 查询账号列表(可选筛选 user_type
list: (params) => axios.get(API.accounts, { params }),
// 获取详情
get: (id) => axios.get(`${API.accounts}/${id}`),
// 更新账号
update: (id, data) => axios.put(`${API.accounts}/${id}`, data),
// 删除账号
delete: (id) => axios.delete(`${API.accounts}/${id}`),
// 其他操作...
};
```
## 常见问题
### Q1为什么要做这次重构
**A**:解决以下问题:
1. 接口重复(三种账号类型有三套完全相同的接口)
2. 路由冗余Handler 逻辑完全一样,却有三套路由)
3. 维护成本高(新增功能需要改三处)
4. 命名混乱(`customer-accounts` 实际管理企业账号)
5. **安全漏洞**(缺少越权检查,代理可以为其他店铺创建账号)
### Q2是否支持向后兼容
**A****不支持**。这是 Breaking Change旧接口已完全删除前端必须同步更新。
### Q3迁移需要多长时间
**A**
- 简单项目2-4 小时(主要是查找替换 + 测试)
- 复杂项目1-2 天(需要重构业务逻辑 + 测试回归)
### Q4后台和 H5 登录接口合并后如何区分?
**A**:不需要区分。后端通过用户类型自动判断:
- 超级管理员、平台用户:只能后台登录
- 代理用户:可以后台和 H5 登录
- 企业用户:只能 H5 登录
### Q5企业账号有什么特殊限制
**A**:企业账号**禁止访问账号管理接口**(路由层直接拦截),尝试访问会返回 403 错误。
### Q6新增了哪些安全功能
**A**
1. **三层越权防护**:路由层拦截 + Service 层权限检查 + GORM 自动过滤
2. **操作审计日志**:所有账号操作(创建、更新、删除、角色分配)都被记录
3. **统一错误返回**:越权访问返回"无权限操作该资源或资源不存在",防止信息泄露
### Q7如何区分不同账号类型
**A**:通过 `user_type` 字段区分:
- `user_type: 2` - 平台用户
- `user_type: 3` - 代理账号(需提供 `shop_id`
- `user_type: 4` - 企业账号(需提供 `enterprise_id`
## 新增功能
### 1. 企业账号完整功能
企业账号现在支持所有操作(之前只有部分功能):
- ✅ CRUD 操作
- ✅ 角色管理
- ✅ 密码管理
- ✅ 状态管理
### 2. 代理账号完整功能
代理账号现在支持所有操作(之前缺少角色管理):
- ✅ CRUD 操作
-**角色管理**(新增)
- ✅ 密码管理
- ✅ 状态管理
### 3. 统一路由结构
所有账号类型共享同一套接口,简化了前端开发:
- ✅ 减少重复代码
- ✅ 统一接口调用方式
- ✅ 更容易扩展新功能
## 支持
如有问题请联系后端团队或查看以下文档:
- [功能总结](./功能总结.md)
- [API 文档](./API文档.md)
- [OpenAPI 规范](../../docs/admin-openapi.yaml)

View File

@@ -1,98 +0,0 @@
# 资产操作审计日志功能总结
## 1. 功能目标
为资产域IoT 卡/设备)补齐统一审计落库能力,解决“谁在什么时间对哪个资产做了什么变更、结果如何”的追溯问题。
## 2. 数据模型与落库
- 新增表:`tb_asset_operation_log`
- 关键字段:
- 操作人:`operator_id/operator_type/operator_name`
- 资产:`asset_type/asset_id/asset_identifier`
- 操作:`operation_type/operation_desc`
- 镜像:`before_data/after_data`JSONB
- 请求上下文:`request_id/ip_address/user_agent/request_path/request_method`
- 结果:`result_status/error_code/error_msg`
- 批量统计:`batch_total/success_count/fail_count`
- 索引:
- `idx_asset_log_asset_created`
- `idx_asset_log_identifier_created`
- `idx_asset_log_operator_created`
- `idx_asset_log_operation_created`
- `idx_asset_log_result_created`
## 3. 统一封装设计
### 3.1 基础设施统一入口
- `internal/service/asset_audit/service.go`
- 统一 `LogOperation(ctx, log)` 异步写入
- 写入失败只打 Error 日志,不阻断业务
- `internal/service/asset_audit/builder.go`
- 统一组装 `before/after`、请求上下文、错误码摘要
- 统一脱敏与裁剪
### 3.2 业务侧统一 helper
- IoT 卡:`internal/service/iot_card/audit.go`
- 设备:`internal/service/device/audit.go`
- 统一资产入口轮询:`internal/service/polling/asset_polling_service.go` 内部 helper
- 资产停用:`internal/service/asset/lifecycle_service.go` 内部 helper
- 导入任务:
- `internal/service/device_import/audit.go`
- `internal/service/iot_card_import/audit.go`
业务函数只传结构化参数,避免日志拼装散落在各分支。
## 4. 覆盖范围
### 4.1 IoT 卡
- 分配/回收、系列绑定、轮询开关、实名策略、手动实名状态、删除/批量删除
- 自动停复机 + 手动停复机(含 denied/failed
### 4.2 设备
- 删除、分配/回收、系列绑定、绑卡/解绑
- 设备停机/复机(含成功数、失败数、失败摘要)
- 远程控制限速、WiFi、切卡、切卡模式、重启、恢复出厂
### 4.3 统一资产入口与导入
- 统一入口:停用、轮询状态、实名策略
- 导入任务创建:设备导入、卡导入
## 5. 脱敏与体积控制
- 敏感键脱敏:`password/passwd/pwd/secret/token/wifi_password/wifipwd`
- WiFi 场景使用 `password` 键传参,落库前统一掩码处理
- `before_data/after_data` 超过阈值时裁剪并标记 `_truncated=true`
## 6. 查询与排障示例
### 6.1 审计日志查询 API前端
- 路径:`GET /api/admin/assets/:identifier/operation-logs`
- 操作人相关字段:
- `operator_type` 返回中文标准文案(如平台用户、代理账号、企业账号、个人客户、系统)
- `operator_type_code` 保留底层枚举编码(如 `admin_user``agent_user`
- `operator_name` 优先返回可读名称,历史 `类型#ID` 占位值会在查询时尽量补全
- 建议前端优先使用:
- `operation_content_before`(操作内容变更前)
- `operation_content_after`(操作内容变更后)
- `operation_fields_desc`(字段中文说明)
- 原始字段 `before_data/after_data` 建议仅用于调试回放。
- 字段明细文档:`docs/add-asset-operation-audit-log/操作内容字段说明.md`
### 6.2 SQL 排障示例
完整 SQL 见:
- `docs/add-asset-operation-audit-log/手工验收脚本.sql`
常用排障问题:
1. 某资产最近操作:按 `asset_type + asset_id` 查询
2. 某操作人近期高风险动作:按 `operator_id` + `result_status in ('failed','denied')`
3. WiFi 明文泄露检查:检索 `after_data::text ILIKE '%\"password\":\"%'` 并人工确认是否掩码

View File

@@ -1,89 +0,0 @@
-- 资产操作审计日志手工验收 SQL
-- 表tb_asset_operation_log
-- 1) 表结构与索引核验
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'tb_asset_operation_log';
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'tb_asset_operation_log'
ORDER BY indexname;
-- 2) 最近 50 条审计日志
SELECT id,
created_at,
operator_type,
operator_id,
asset_type,
asset_id,
asset_identifier,
operation_type,
result_status,
batch_total,
success_count,
fail_count
FROM tb_asset_operation_log
ORDER BY id DESC
LIMIT 50;
-- 3) success / failed / denied 三态分布
SELECT operation_type, result_status, COUNT(*) AS cnt
FROM tb_asset_operation_log
GROUP BY operation_type, result_status
ORDER BY operation_type, result_status;
-- 4) before/after 镜像完整性核验(近 200 条)
SELECT id,
operation_type,
result_status,
(before_data IS NOT NULL) AS has_before,
(after_data IS NOT NULL) AS has_after
FROM tb_asset_operation_log
ORDER BY id DESC
LIMIT 200;
-- 5) 批量聚合字段核验batch_total/success_count/fail_count
SELECT id,
operation_type,
batch_total,
success_count,
fail_count,
(batch_total >= success_count + fail_count) AS counter_ok
FROM tb_asset_operation_log
WHERE batch_total > 0
ORDER BY id DESC
LIMIT 200;
-- 6) 系统触发语义核验
SELECT id,
operation_type,
operator_type,
operator_id,
operator_name,
result_status
FROM tb_asset_operation_log
WHERE operation_type IN ('card_auto_stop', 'card_auto_start')
ORDER BY id DESC
LIMIT 100;
-- 7) WiFi 脱敏核验(不应出现明文密码)
SELECT id,
operation_type,
after_data
FROM tb_asset_operation_log
WHERE operation_type = 'device_set_wifi'
ORDER BY id DESC
LIMIT 20;
-- 8) 拒绝与失败热点排查
SELECT operation_type,
result_status,
COUNT(*) AS cnt
FROM tb_asset_operation_log
WHERE result_status IN ('failed', 'denied')
GROUP BY operation_type, result_status
ORDER BY cnt DESC, operation_type;

View File

@@ -1,69 +0,0 @@
# 资产审计日志接口回放示例
以下示例用于构造 success / failed / denied 三类日志数据。请替换 `<token>``<identifier>``<device_id>``<card_id>` 等占位符。
## 1. success 示例
### 1.1 设备限速(`device_speed_limit`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<identifier>/speed-limit" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"speed_limit":1024}'
```
### 1.2 资产轮询开关(`asset_polling_status`
```bash
curl -X PATCH "http://127.0.0.1:8080/api/admin/assets/<identifier>/polling-status" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enable_polling":false}'
```
## 2. denied 示例
### 2.1 保护期内停机(`device_stop` / `card_manual_stop`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/assets/<identifier>/stop" \
-H "Authorization: Bearer <token>"
```
若命中保护期规则,应返回拒绝并写入 `result_status=denied`
### 2.2 越权分配设备(`device_allocate`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/allocate" \
-H "Authorization: Bearer <agent-token>" \
-H "Content-Type: application/json" \
-d '{"target_shop_id":999999,"device_ids":[1,2,3],"remark":"审计验收"}'
```
## 3. failed 示例
### 3.1 无效设备标识触发远程控制失败(`device_reboot`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<invalid-identifier>/reboot" \
-H "Authorization: Bearer <token>"
```
### 3.2 导入任务入队失败(`device_import_task_create` / `iot_card_import_task_create`
通过临时关闭队列依赖或传入无效参数触发失败,确认写入 `result_status=failed` 和错误摘要。
## 4. 结果核对
执行完回放后,使用:
- `docs/add-asset-operation-audit-log/手工验收脚本.sql`
重点核对:
1. 同一操作存在 success/failed/denied
2. `before_data``after_data` 有效
3. WiFi 密码字段为掩码
4. 批量字段 `batch_total/success_count/fail_count` 合理

View File

@@ -1,102 +0,0 @@
# 资产审计日志操作内容字段说明
## 1. 查询接口
- 路径:`GET /api/admin/assets/:identifier/operation-logs`
- 说明:先按资产标识符解析资产,再查询该资产的审计日志。
## 2. 前端应优先使用的字段
为避免历史数据格式差异,前端应优先使用以下三个字段:
1. `operation_content_before`:操作内容(变更前)
2. `operation_content_after`:操作内容(变更后)
3. `operation_fields_desc`:字段中文说明(`key=字段名``value=中文解释`
`before_data/after_data` 保留为原始审计数据,建议仅作为调试或回放使用。
## 3. 通用字段
| 字段 | 说明 |
|---|---|
| `operator_type` | 操作人类型中文名称(如平台用户、代理账号、企业账号、个人客户、系统) |
| `operator_type_code` | 操作人类型编码(如 `admin_user``agent_user` |
| `operator_name` | 操作人可读名称,优先返回用户名/店铺名/企业名/客户昵称 |
| `batch_total` | 批量总数 |
| `success_count` | 成功数量 |
| `fail_count` | 失败数量 |
| `failed_items` | 失败明细 |
| `reason` | 原因说明 |
## 4. 常见操作字段
### 4.1 分配/回收
| 字段 | 说明 |
|---|---|
| `target_shop_id` / `to_shop_id` | 目标店铺ID |
| `source_shop_id` / `from_shop_id` | 来源店铺ID |
| `device_ids` | 设备ID列表 |
| `card_ids` | 卡ID列表 |
| `selection_type` | 选择方式 |
| `new_status` | 变更后状态 |
### 4.2 设备绑卡/解绑
| 字段 | 说明 |
|---|---|
| `binding_id` | 绑定记录ID |
| `slot_position` | 插槽位置 |
| `iot_card_id` | 卡ID |
| `iccid` | 卡ICCID |
| `unbind` | 是否执行解绑 |
### 4.3 设备停复机
| 字段 | 说明 |
|---|---|
| `success_count` | 成功数量 |
| `fail_count` | 失败数量 |
| `skip_count` | 跳过数量 |
| `failed_items` | 失败明细含ICCID和原因 |
### 4.4 远程控制
| 字段 | 说明 |
|---|---|
| `speed_limit` | 限速值KB/s |
| `ssid` | WiFi 名称 |
| `password` | WiFi 密码(已脱敏) |
| `enabled` | WiFi 开关状态 |
| `target_iccid` | 目标ICCID |
| `switch_mode` | 切卡模式0自动/1手动 |
### 4.5 轮询与实名
| 字段 | 说明 |
|---|---|
| `enable_polling` | 轮询开关 |
| `realname_policy` | 实名策略none/before_order/after_order |
| `real_name_status` | 实名状态0未实名/1已实名 |
## 5. 兼容性说明
历史日志中 `before_data` 可能是资产快照而不是操作内容。接口已做归一化处理:
- 自动抽取并返回 `operation_content_before/after`
- 当历史数据缺少“变更前操作内容”时,会按“变更后字段”与资产快照尽量回填
- 历史日志中的 `后台用户#ID``代理用户#ID` 等占位名称,接口会尽量按当前账号资料补全为可读名称
因此,前端无需再自行解析 `before_data/after_data` 的多种历史格式。
## 6. 前端处理建议
建议前端按以下优先级渲染变更明细:
1. 遍历 `operation_content_after` 的字段键作为“变更项集合”
2. 字段名展示优先使用 `operation_fields_desc[key]`,没有则回退为 `key`
3. 变更前值读取 `operation_content_before[key]`
4. 变更后值读取 `operation_content_after[key]`
5. 当前值为 `null` 时按“未知/未记录”展示,不要直接当空字符串
`before_data/after_data` 建议只用于问题排查,不参与主界面渲染。

View File

@@ -1,327 +0,0 @@
# 默认超级管理员自动初始化功能
## 功能概述
系统在 API 服务启动时,自动检查数据库中是否存在超级管理员账号。如果不存在,则自动创建一个默认的超级管理员账号,确保系统首次部署后可以立即登录使用。
## 业务背景
**问题**
- 首次部署新环境(开发、测试、生产)时,数据库中没有任何管理员账号
- 无法登录管理后台,需要手动执行 SQL 或脚本创建管理员
- 人为操作容易出错,不同环境的初始账号可能不一致
**解决方案**
- 系统启动时自动检测并创建默认超级管理员
- 支持配置文件自定义账号信息
- 幂等操作,多次启动不会重复创建
## 技术实现
### 核心逻辑
#### 1. 初始化时机
```go
// internal/bootstrap/bootstrap.go
func Bootstrap(deps *Dependencies) (*BootstrapResult, error) {
// ... 其他初始化 ...
// 4. 初始化默认超级管理员(降级处理:失败不中断启动)
if err := initDefaultAdmin(deps, services); err != nil {
deps.Logger.Error("初始化默认超级管理员失败", zap.Error(err))
}
// ... 继续初始化 ...
}
```
**初始化顺序**
1. Store 层初始化
2. GORM Callbacks 注册
3. Service 层初始化
4. **默认管理员初始化** ← 在此执行
5. Middleware 层初始化
6. Handler 层初始化
#### 2. 检查逻辑
```go
// 检查是否已存在超级管理员user_type = 1
var count int64
err := db.Model(&Account{}).Where("user_type = ?", constants.UserTypeSuperAdmin).Count(&count).Error
if count > 0 {
// 已存在,跳过创建
logger.Info("超级管理员账号已存在,跳过初始化", zap.Int64("count", count))
return nil
}
// count == 0创建默认管理员
```
**确保唯一性**
- ✅ 检查条件:`user_type = 1`(精确匹配超级管理员类型)
- ✅ 创建条件:只有 `count == 0` 时才创建
- ✅ 幂等性:多次启动不会重复创建
- ✅ 并发安全:启动时单进程执行,无并发问题
#### 3. 账号信息来源
优先级:**配置文件 > 代码默认值**
```go
// 代码默认值pkg/constants/constants.go
const (
DefaultAdminUsername = "admin"
DefaultAdminPassword = "Admin@123456"
DefaultAdminPhone = "13800000000"
)
// 读取配置文件(可选)
username := constants.DefaultAdminUsername
password := constants.DefaultAdminPassword
phone := constants.DefaultAdminPhone
if cfg.DefaultAdmin.Username != "" {
username = cfg.DefaultAdmin.Username // 使用配置文件值
}
if cfg.DefaultAdmin.Password != "" {
password = cfg.DefaultAdmin.Password
}
if cfg.DefaultAdmin.Phone != "" {
phone = cfg.DefaultAdmin.Phone
}
```
#### 4. 创建账号
```go
account := &model.Account{
Username: username,
Phone: phone,
Password: password, // 原始密码CreateSystemAccount 内部会进行 bcrypt 哈希
UserType: constants.UserTypeSuperAdmin, // 1=超级管理员
Status: constants.StatusEnabled, // 1=启用
}
// 调用 Service 层的系统创建方法(绕过当前用户检查)
err := services.Account.CreateSystemAccount(ctx, account)
```
**CreateSystemAccount 方法特点**
- 绕过 `currentUserID` 检查(因为是系统初始化,没有当前用户)
- 仍然保留用户名和手机号唯一性检查
- 密码自动进行 bcrypt 哈希
- 跳过数据权限过滤(使用 `SkipDataPermission(ctx)`
### 降级处理
```go
if err := initDefaultAdmin(deps, services); err != nil {
deps.Logger.Error("初始化默认超级管理员失败", zap.Error(err))
// 不返回错误,继续启动服务
}
```
**设计理由**
- 默认管理员创建失败不应导致整个服务无法启动
- 可能的失败原因:数据库连接问题、用户名/手机号冲突等
- 管理员可以通过日志查看失败原因,手动处理
## 配置说明
### 使用代码默认值(无需配置)
直接启动服务,系统使用内置默认值:
- 用户名:`admin`
- 密码:`Admin@123456`
- 手机号:`13800000000`
### 自定义配置
通过环境变量自定义:
```bash
export JUNHONG_DEFAULT_ADMIN_USERNAME="自定义用户名"
export JUNHONG_DEFAULT_ADMIN_PASSWORD="自定义密码"
export JUNHONG_DEFAULT_ADMIN_PHONE="自定义手机号"
```
**注意**
- 配置项为可选,不参与 `ValidateRequired()` 验证
- 任何字段留空则使用代码默认值
- 密码必须足够复杂(建议包含大小写字母、数字、特殊字符)
## 使用示例
### 场景1首次部署空数据库
```bash
# 启动服务
go run cmd/api/main.go
```
**日志输出**
```
{"level":"info","msg":"默认超级管理员创建成功","username":"admin","phone":"13800000000"}
```
**结果**
- 数据库 `tb_account` 表新增一条记录
- `user_type = 1`(超级管理员)
- `username = "admin"`
- `phone = "13800000000"`
- `password` 为 bcrypt 哈希值
### 场景2已有管理员再次启动
```bash
# 再次启动服务
go run cmd/api/main.go
```
**日志输出**
```
{"level":"info","msg":"超级管理员账号已存在,跳过初始化","count":1}
```
**结果**
- 跳过创建,不修改现有数据
### 场景3使用自定义配置
**设置环境变量**
```bash
export JUNHONG_DEFAULT_ADMIN_USERNAME="myadmin"
export JUNHONG_DEFAULT_ADMIN_PASSWORD="MySecurePass@2024"
export JUNHONG_DEFAULT_ADMIN_PHONE="13900000000"
```
**启动服务**
```bash
go run cmd/api/main.go
```
**日志输出**
```
{"level":"info","msg":"默认超级管理员创建成功","username":"myadmin","phone":"13900000000"}
```
## 安全注意事项
### 1. 密码安全
- ✅ 密码使用 bcrypt 哈希存储,不可逆
- ⚠️ 默认密码相对简单,建议首次登录后立即修改
- ⚠️ 生产环境建议通过配置文件使用更复杂的密码
### 2. 权限控制
- ✅ 超级管理员拥有所有权限,跳过数据权限过滤
- ⚠️ 不要将超级管理员账号用于日常操作
- ⚠️ 建议为日常管理员创建独立的平台用户账号
### 3. 审计日志
- ✅ 创建成功/跳过都记录在 `logs/app.log`
- ✅ 包含时间戳、用户名、手机号
- ⚠️ 日志中不会记录明文密码
### 4. 配置安全
- ✅ 配置通过环境变量设置,不存储在代码仓库中
- ⚠️ 确保环境变量安全(使用密钥管理服务或加密存储)
- ⚠️ 生产环境务必修改默认密码
## 手动创建管理员(备用方案)
如果自动初始化失败,可以手动执行以下 SQL
```sql
-- 生成 bcrypt 哈希密码(使用 Go 代码或在线工具)
-- bcrypt.GenerateFromPassword([]byte("Admin@123456"), bcrypt.DefaultCost)
-- 示例哈希值(实际使用时需重新生成):
-- $2a$10$abcdefghijklmnopqrstuvwxyz...
INSERT INTO tb_account (
username,
phone,
password,
user_type,
status,
created_at,
updated_at
) VALUES (
'admin',
'13800000000',
'$2a$10$...your-bcrypt-hash...', -- 替换为实际的 bcrypt 哈希
1, -- 超级管理员
1, -- 启用
NOW(),
NOW()
);
```
**生成 bcrypt 哈希工具**
```go
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := "Admin@123456"
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
fmt.Println(string(hash))
}
```
## 相关文件
- `pkg/constants/constants.go` - 默认值常量定义
- `pkg/config/config.go` - 配置结构定义
- `pkg/config/defaults/config.yaml` - 嵌入式默认配置
- `docs/environment-variables.md` - 环境变量配置文档
- `internal/service/account/service.go` - CreateSystemAccount 方法
- `internal/bootstrap/admin.go` - initDefaultAdmin 函数
- `internal/bootstrap/bootstrap.go` - Bootstrap 主流程
## 常见问题
### Q1: 为什么要用系统创建方法而不是直接插入数据库?
**A**: 保持业务逻辑统一性和数据一致性:
- 复用用户名/手机号唯一性检查逻辑
- 自动进行密码哈希处理
- 遵循分层架构原则(通过 Service 层操作)
- 未来扩展更容易(如添加审计日志、事件通知等)
### Q2: 如果数据库有多个超级管理员怎么办?
**A**: 系统检查 `user_type = 1` 的账号数量:
- `count == 0`:创建默认管理员
- `count > 0`:跳过创建(不管有几个)
- 系统不会删除或修改现有的超级管理员
### Q3: 可以通过配置禁用这个功能吗?
**A**: 目前不支持配置禁用,因为这是核心初始化逻辑。如果需要禁用:
1. 手动创建一个超级管理员账号(系统会自动跳过)
2. 或者修改代码注释掉 `initDefaultAdmin()` 调用
### Q4: 创建失败会影响服务启动吗?
**A**: 不会。采用降级处理策略:
- 创建失败只记录错误日志
- 服务继续正常启动
- 管理员可通过日志排查原因并手动创建
### Q5: 如何验证管理员创建成功?
**A**: 三种方式:
1. 查看 `logs/app.log`,搜索 "默认超级管理员创建成功"
2. 查询数据库:`SELECT * FROM tb_account WHERE user_type = 1;`
3. 尝试使用默认账号登录管理后台

View File

@@ -1,395 +0,0 @@
# 强充系统和代购订单功能总结
## 功能概述
本次实现包含三个核心功能模块:
1. **钱包充值系统**:个人客户可通过微信/支付宝为钱包充值
2. **强充要求机制**:套餐购买前强制要求充值指定金额
3. **代购订单支持**:平台可代客户购买套餐并跳过佣金计算
---
## 业务规则
### 1. 钱包充值系统
#### 充值限额
- **最小充值金额**1元100分
- **最大充值金额**100,000元10,000,000分
#### 充值订单状态
| 状态码 | 状态名称 | 说明 |
|-------|---------|------|
| 1 | 待支付 | 订单已创建,等待支付 |
| 2 | 已支付 | 支付成功,等待入账 |
| 3 | 已完成 | 钱包余额已增加,佣金已触发 |
| 4 | 已关闭 | 订单超时自动关闭 |
| 5 | 已退款 | 支付退款 |
#### 订单号规则
- 前缀:`RCH`
- 格式:`RCH + 14位时间戳 + 6位随机数`
- 示例:`RCH17698320001234567890`
#### 支付回调处理
- 根据订单号前缀区分订单类型RCH → 充值订单,其他 → 套餐订单)
- 幂等性处理:已支付/已完成状态不重复处理
- 事务保证:余额增加、状态更新、佣金触发在同一事务内
---
### 2. 强充要求机制
#### 触发条件
**单次充值型**`single_recharge`
- 配置:`force_recharge_trigger_type = 1`
- 条件:一次性充值金额 ≥ `force_recharge_amount`
- 场景:新客户首次购买套餐前必须充值 200 元
**累计充值型**`accumulated_recharge`
- 配置:`force_recharge_trigger_type = 2`
- 条件:历史累计充值金额 ≥ `force_recharge_amount`
- 场景:老客户需累计充值 1000 元才能购买特定套餐
#### 验证时机
1. **充值预检接口**`GET /api/h5/wallets/recharge-check`
- 返回是否需要强充、触发类型、所需金额
2. **套餐购买预检接口**`POST /api/admin/orders/purchase-check`
- 返回套餐总价、强充要求、实际支付金额
3. **订单创建**:自动验证强充要求,不满足则拒绝
#### 豁免规则
- 已发放过一次性佣金的卡/设备,无需强充
- 代购订单无需强充验证
---
### 3. 代购订单
#### 适用场景
平台使用线下支付代客户购买套餐,绕过钱包和在线支付流程。
#### 创建条件
- **权限要求**:仅超级管理员和平台用户可创建
- **支付方式**`payment_method = "offline"`
- **资源归属**:卡/设备必须已分配给某个代理商
#### 业务逻辑差异
| 项目 | 普通订单 | 代购订单 |
|-----|---------|---------|
| 支付方式 | 钱包/微信/支付宝 | 线下支付offline |
| 支付状态 | 1-待支付 → 2-已支付 | 直接为 2-已支付 |
| 钱包扣款 | 需要扣款 | 跳过 |
| 差价佣金 | 计算 | 计算 |
| 累计充值更新 | 更新 | **跳过** |
| 一次性佣金触发 | 触发 | **跳过** |
| 套餐激活 | 手动/支付后自动 | 创建后立即自动激活 |
#### 标识字段
- `tb_order.is_purchase_on_behalf = true`(代购订单标识)
---
## API 接口
### 充值相关接口H5
#### 1. 创建充值订单
```
POST /api/h5/wallets/recharge
```
**请求参数**
```json
{
"resource_type": "iot_card", // 资源类型: iot_card | device
"resource_id": 123, // 资源ID
"amount": 20000, // 充值金额200元
"payment_method": "wechat" // 支付方式: wechat | alipay
}
```
**响应数据**
```json
{
"code": 0,
"data": {
"id": 1,
"recharge_no": "RCH17698320001234567890",
"user_id": 100,
"wallet_id": 200,
"amount": 20000,
"payment_method": "wechat",
"status": 1,
"status_text": "待支付",
"created_at": "2026-01-31T12:00:00Z"
}
}
```
#### 2. 充值预检
```
GET /api/h5/wallets/recharge-check?resource_type=iot_card&resource_id=123
```
**响应数据**
```json
{
"code": 0,
"data": {
"need_force_recharge": true,
"force_recharge_amount": 20000,
"trigger_type": "single_recharge",
"min_amount": 100,
"max_amount": 10000000,
"current_accumulated": 5000,
"threshold": 20000,
"message": "购买此套餐需先充值200元",
"first_commission_paid": false
}
}
```
#### 3. 查询充值订单列表
```
GET /api/h5/wallets/recharges?page=1&page_size=20&status=1
```
**可选参数**
- `wallet_id`: 钱包ID筛选
- `status`: 状态筛选1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
- `start_time`: 开始时间
- `end_time`: 结束时间
#### 4. 查询充值订单详情
```
GET /api/h5/wallets/recharges/:id
```
---
### 代购订单接口Admin
#### 套餐购买预检
```
POST /api/admin/orders/purchase-check
```
**请求参数**
```json
{
"order_type": "iot_card",
"resource_id": 123,
"package_ids": [1, 2, 3]
}
```
**响应数据**
```json
{
"code": 0,
"data": {
"total_price": 39900,
"need_force_recharge": true,
"force_recharge_amount": 20000,
"actual_payment": 59900,
"trigger_type": "single_recharge",
"message": "需先充值200元实际支付599元"
}
}
```
---
## 数据库变更
### 1. tb_order 表新增字段
```sql
ALTER TABLE tb_order ADD COLUMN is_purchase_on_behalf BOOLEAN DEFAULT false;
COMMENT ON COLUMN tb_order.is_purchase_on_behalf IS '是否为代购订单';
```
### 2. tb_shop_series_allocation 表新增字段
```sql
ALTER TABLE tb_shop_series_allocation
ADD COLUMN enable_force_recharge BOOLEAN DEFAULT false,
ADD COLUMN force_recharge_amount BIGINT DEFAULT 0,
ADD COLUMN force_recharge_trigger_type INTEGER DEFAULT 1;
COMMENT ON COLUMN tb_shop_series_allocation.enable_force_recharge IS '是否启用强充要求';
COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_amount IS '强充金额(分)';
COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_trigger_type IS '强充触发类型: 1-单次充值 2-累计充值';
```
### 3. tb_recharge_record 表(新增)
```sql
CREATE TABLE tb_recharge_record (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP,
creator BIGINT,
updater BIGINT,
recharge_no VARCHAR(30) UNIQUE NOT NULL,
user_id BIGINT NOT NULL,
wallet_id BIGINT NOT NULL,
amount BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL,
payment_channel VARCHAR(50),
payment_transaction_id VARCHAR(100),
status INTEGER NOT NULL DEFAULT 1,
paid_at TIMESTAMP,
completed_at TIMESTAMP
);
```
---
## 错误码
| 错误码 | 名称 | 说明 |
|-------|------|------|
| 1120 | CodeRechargeAmountInvalid | 充值金额无效 |
| 1121 | CodeRechargeNotFound | 充值订单不存在 |
| 1122 | CodeRechargeAlreadyPaid | 充值订单已支付 |
| 1130 | CodePurchaseOnBehalfForbidden | 无权创建代购订单 |
| 1131 | CodePurchaseOnBehalfInvalidTarget | 代购订单资源未分配 |
| 1140 | CodeForceRechargeRequired | 需要强充 |
| 1141 | CodeForceRechargeAmountMismatch | 强充金额不足 |
---
## 测试覆盖
### Store 层
- ✅ RechargeStore: 94.7%CRUD、分页筛选、并发操作
### Service 层
- ✅ RechargeService: 83.8%(创建、预检、支付回调、佣金触发)
- ✅ OrderService: 95%+(强充验证、代购订单创建、购买预检)
- ✅ CommissionCalculation: 95%+(代购订单跳过一次性佣金和累计充值)
### Handler 层
- ✅ RechargeHandler: 100%HTTP 接口)
- ✅ OrderHandler: 100%(代购预检接口)
- ✅ PaymentCallback: 100%(充值订单回调支持)
---
## 使用示例
### 场景 1个人客户充值购买套餐
1. **查询充值要求**
```bash
GET /api/h5/wallets/recharge-check?resource_type=iot_card&resource_id=123
# 响应:需要强充 200 元
```
2. **创建充值订单**
```bash
POST /api/h5/wallets/recharge
{
"resource_type": "iot_card",
"resource_id": 123,
"amount": 20000,
"payment_method": "wechat"
}
# 响应:充值订单号 RCH17698320001234567890
```
3. **发起支付**
```bash
POST /api/h5/orders/:id/wechat-pay/jsapi
# 获取微信支付参数,跳转支付
```
4. **支付成功后自动触发**
- 钱包余额增加 200 元
- 累计充值更新
- 满足阈值时触发一次性佣金
5. **创建套餐订单**
```bash
POST /api/h5/orders
{
"order_type": "iot_card",
"resource_id": 123,
"package_ids": [1, 2, 3]
}
# 强充验证通过,订单创建成功
```
---
### 场景 2平台代购订单
1. **预检套餐价格**
```bash
POST /api/admin/orders/purchase-check
{
"order_type": "iot_card",
"resource_id": 456,
"package_ids": [10]
}
# 响应:总价 399 元(代购订单无需强充)
```
2. **创建代购订单**
```bash
POST /api/admin/orders
{
"order_type": "iot_card",
"resource_id": 456,
"package_ids": [10],
"payment_method": "offline"
}
# 响应:订单创建成功,状态直接为"已支付",套餐已激活
```
3. **自动处理**
- 订单状态:已支付
- 套餐激活:立即生效
- 差价佣金:正常计算
- 累计充值:**不更新**
- 一次性佣金:**不触发**
---
## 注意事项
1. **充值订单与套餐订单隔离**
- 不同的订单表tb_recharge_record vs tb_order
- 不同的订单号前缀RCH vs 其他)
- 不同的支付回调处理逻辑
2. **强充验证时机**
- 充值预检:提前告知用户
- 购买预检:计算实际支付金额
- 订单创建:最终验证拦截
3. **代购订单限制**
- 仅平台账号可创建
- 必须使用 offline 支付方式
- 资源必须已分配给代理商
4. **佣金计算规则**
- 充值订单:触发一次性佣金(满足阈值)
- 普通套餐订单:触发差价佣金 + 一次性佣金
- 代购订单:仅触发差价佣金
5. **测试环境配置**
- 需要加载 `.env.local` 环境变量
- 使用 `testutils.NewTestTransaction` 自动回滚事务
- 使用 `testutils.GetTestRedis` 获取全局 Redis 连接
---
## 相关文档
- **设计文档**`openspec/changes/add-force-recharge-system/design.md`
- **任务清单**`openspec/changes/add-force-recharge-system/tasks.md`
- **测试连接管理**`docs/testing/test-connection-guide.md`
- **API 文档生成**`docs/api-documentation-guide.md`

View File

@@ -1,444 +0,0 @@
# 提案完成总结 - add-user-organization-model
**提案ID**: add-user-organization-model
**完成时间**: 2026-01-09
**状态**: ✅ 已完成
---
## 一、概述
本提案实现了系统的用户体系和组织模型,支持四种用户类型(平台用户、代理账号、企业账号、个人客户)和两种组织实体(店铺、企业),建立了完善的多租户数据隔离机制。
---
## 二、完成清单
### 2.1 数据库迁移(全部完成 ✅)
- ✅ 创建 `tb_shop` 表(店铺表)
- ✅ 创建 `tb_enterprise` 表(企业表)
- ✅ 创建 `tb_personal_customer` 表(个人客户表)
- ✅ 修改 `tb_account` 表(添加 `enterprise_id`,移除 `parent_id`
- ✅ 执行数据库迁移并验证表结构
**迁移文件**: `migrations/000002_create_shop_enterprise_personal_customer.up.sql`
**验证结果**:
```
✓ 表 tb_shop 存在,包含 17 列
✓ 表 tb_enterprise 存在,包含 18 列
✓ 表 tb_personal_customer 存在,包含 10 列
✓ 表 tb_account 存在,包含 13 列
✓ tb_account.enterprise_id 字段存在
✓ tb_account.parent_id 字段已移除
```
### 2.2 GORM 模型定义(全部完成 ✅)
- ✅ 创建 `internal/model/shop.go` - Shop 模型
- ✅ 创建 `internal/model/enterprise.go` - Enterprise 模型
- ✅ 创建 `internal/model/personal_customer.go` - PersonalCustomer 模型
- ✅ 修改 `internal/model/account.go` - 更新 Account 模型
- ✅ 验证模型与数据库表结构一致
-**额外工作**: 为所有模型字段添加显式的 `column:` 标签GORM 字段命名规范)
**额外完成**:
- 更新了项目中所有 9 个 GORM 模型文件,确保所有字段都显式指定数据库列名
- 更新了 `CLAUDE.md``openspec/AGENTS.md`,添加了 GORM 字段命名规范
### 2.3 常量定义(全部完成 ✅)
- ✅ 添加用户类型常量(`UserTypeSuperAdmin`, `UserTypePlatform`, `UserTypeAgent`, `UserTypeEnterprise`
- ✅ 添加组织状态常量(`StatusDisabled`, `StatusEnabled`
- ✅ 添加店铺层级相关常量(`MaxShopLevel = 7`
- ✅ 添加 Redis key 生成函数(店铺下级缓存 key
**文件**: `pkg/constants/constants.go`
### 2.4 Store 层实现(全部完成 ✅)
#### Shop Store (`internal/store/postgres/shop_store.go`)
- ✅ Create/Update/Delete/GetByID/List 基础方法
- ✅ GetByCode 按店铺编号查询
-**GetSubordinateShopIDs** 递归查询下级店铺(核心功能)
- ✅ Redis 缓存支持(下级店铺 ID 列表30 分钟 TTL
#### Enterprise Store (`internal/store/postgres/enterprise_store.go`)
- ✅ Create/Update/Delete/GetByID/List 基础方法
- ✅ GetByCode 按企业编号查询
- ✅ GetByOwnerShopID 按归属店铺查询企业列表
#### PersonalCustomer Store (`internal/store/postgres/personal_customer_store.go`)
- ✅ Create/Update/Delete/GetByID/List 基础方法
- ✅ GetByPhone 按手机号查询
- ✅ GetByWxOpenID 按微信 OpenID 查询
#### Account Store 更新 (`internal/store/postgres/account_store.go`)
- ✅ 调整递归查询逻辑(改为基于店铺层级)
- ✅ 添加按 ShopID/EnterpriseID 查询方法
### 2.5 Service 层实现(全部完成 ✅)
#### Shop Service (`internal/service/shop/service.go`)
- ✅ 创建店铺(校验层级不超过 7 级)
- ✅ 更新店铺信息
- ✅ 禁用/启用店铺
- ✅ 获取店铺详情和列表
- ✅ 获取下级店铺 ID 列表
#### Enterprise Service (`internal/service/enterprise/service.go`)
- ✅ 创建企业(关联店铺或平台)
- ✅ 更新企业信息
- ✅ 禁用/启用企业
- ✅ 获取企业详情和列表
#### PersonalCustomer Service (`internal/service/customer/service.go`)
- ✅ 创建/更新个人客户
- ✅ 根据手机号/微信 OpenID 查询
- ✅ 绑定微信信息
### 2.6 测试(核心测试完成 ✅)
- ✅ Shop Store 单元测试8 个测试用例全部通过)
- ✅ Enterprise Store 单元测试8 个测试用例全部通过)
- ✅ PersonalCustomer Store 单元测试7 个测试用例全部通过)
- ✅ 递归查询下级店铺测试(含 Redis 缓存验证)
- ⏭️ Shop Service 单元测试(层级校验)- 可选Store 层已充分测试
**测试文件**:
- `tests/unit/shop_store_test.go`
- `tests/unit/enterprise_store_test.go`
- `tests/unit/personal_customer_store_test.go`
**测试覆盖**:
- 基础 CRUD 操作
- 唯一约束验证
- 递归查询逻辑
- Redis 缓存机制
- 数据过滤条件
- 边界条件和错误处理
### 2.7 文档更新(全部完成 ✅)
- ✅ 更新 `README.md` 说明用户体系设计(添加概览章节)
- ✅ 在 `docs/add-user-organization-model/` 目录添加详细设计文档
**文档文件**:
- `docs/add-user-organization-model/用户体系设计文档.md`6000+ 行包含完整的设计说明、代码示例、FAQ
- `docs/add-user-organization-model/提案完成总结.md`(本文件)
---
## 三、核心功能实现
### 3.1 递归查询下级店铺
**功能描述**: 查询某个店铺及其所有下级店铺的 ID 列表(最多 7 级),支持 Redis 缓存。
**实现方式**:
```sql
WITH RECURSIVE subordinate_shops AS (
SELECT id FROM tb_shop WHERE id = ? AND deleted_at IS NULL
UNION
SELECT s.id FROM tb_shop s
INNER JOIN subordinate_shops ss ON s.parent_id = ss.id
WHERE s.deleted_at IS NULL
)
SELECT id FROM subordinate_shops
```
**缓存策略**:
- Redis Key: `shop:subordinate_ids:{shop_id}`
- TTL: 30 分钟
- 缓存内容: JSON 数组 `[1, 2, 3, 4]`(包含当前店铺自己)
**测试验证**:
```
✓ 查询一级店铺的所有下级(包含自己)- 返回 4 个 ID
✓ 查询二级店铺的下级(包含自己)- 返回 2 个 ID
✓ 查询没有下级的店铺(只返回自己)- 返回 1 个 ID
✓ 验证 Redis 缓存 - 第二次查询命中缓存,结果一致
```
### 3.2 数据权限过滤机制
**设计原则**: 数据权限基于 `shop_id`(店铺归属),而非 `owner_id`(创建者)。
**过滤规则**:
- **平台用户**`user_type = 1 或 2`: 不受过滤限制,可查看所有数据
- **代理账号**`user_type = 3`: `WHERE shop_id IN (当前店铺及下级店铺 ID 列表)`
- **企业账号**`user_type = 4`: `WHERE enterprise_id = 当前企业 ID`
- **个人客户**: 独立表,只能查看自己的数据
**实现位置**: Service 层的 `applyDataPermissionFilter()` 方法
### 3.3 组织层级关系
**店铺层级**:
```
平台
├── 店铺Alevel=1, parent_id=NULL
│ ├── 店铺Blevel=2, parent_id=A
│ │ └── 店铺Clevel=3, parent_id=B
│ └── 店铺Dlevel=2, parent_id=A
└── 店铺Elevel=1, parent_id=NULL
```
**企业归属**:
```
平台
├── 企业Zowner_shop_id=NULL平台直属
├── 店铺A
│ ├── 企业Xowner_shop_id=A
│ └── 企业Yowner_shop_id=A
└── 店铺B
└── 店铺C
└── 企业Wowner_shop_id=C
```
---
## 四、技术亮点
### 4.1 数据库设计
**无外键约束**: 遵循项目原则,不使用数据库外键,所有关联在代码层维护
**软删除**: 使用 `gorm.Model``deleted_at` 字段
**部分唯一索引**: `WHERE deleted_at IS NULL` 确保软删除后可重复编号
**递归查询优化**: PostgreSQL `WITH RECURSIVE` + Redis 缓存
### 4.2 GORM 模型规范
**显式列名映射**: 所有字段使用 `gorm:"column:field_name"` 显式指定数据库列名
**字段注释**: 所有字段都有中文注释说明业务含义
**类型规范**: 字符串长度、数值精度明确定义
**示例**:
```go
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"`
```
### 4.3 测试覆盖
**Table-driven tests**: 使用 Go 惯用的表格驱动测试模式
**测试隔离**: 每个测试用例独立的数据库和 Redis 环境
**自动清理**: `defer testutils.TeardownTestDB()` 自动清理测试数据
**边界条件**: 测试唯一约束、软删除、递归查询、缓存命中等
---
## 五、代码质量
### 5.1 遵守项目规范
**分层架构**: 严格遵守 `Handler → Service → Store → Model` 分层
**错误处理**: 使用统一的 `pkg/errors` 错误码
**常量管理**: 所有常量在 `pkg/constants` 中定义
**Redis Key 规范**: 使用函数生成,格式 `{module}:{purpose}:{identifier}`
**Go 代码风格**: 遵循 Effective Go 和 Go Code Review Comments
### 5.2 代码注释
**导出函数**: 所有导出函数都有 Go 风格的文档注释
**实现细节**: 关键逻辑使用中文注释说明
**字段说明**: 模型字段都有中文注释
### 5.3 性能优化
**Redis 缓存**: 递归查询结果缓存 30 分钟,减少数据库压力
**索引优化**: 为外键字段、唯一字段、查询字段添加索引
**批量查询**: 使用 `WHERE id IN (?)` 避免 N+1 查询
---
## 六、文件清单
### 6.1 数据库迁移
- `migrations/000002_create_shop_enterprise_personal_customer.up.sql`
- `migrations/000002_create_shop_enterprise_personal_customer.down.sql`
### 6.2 GORM 模型
- `internal/model/shop.go`
- `internal/model/enterprise.go`
- `internal/model/personal_customer.go`
- `internal/model/account.go`(修改)
- `internal/model/base.go`(更新 column 标签)
- `internal/model/role.go`(更新 column 标签)
- `internal/model/permission.go`(更新 column 标签)
- `internal/model/account_role.go`(更新 column 标签)
- `internal/model/role_permission.go`(更新 column 标签)
### 6.3 Store 层
- `internal/store/postgres/shop_store.go`
- `internal/store/postgres/enterprise_store.go`
- `internal/store/postgres/personal_customer_store.go`
- `internal/store/postgres/account_store.go`(修改)
### 6.4 Service 层
- `internal/service/shop/service.go`
- `internal/service/enterprise/service.go`
- `internal/service/customer/service.go`
### 6.5 常量定义
- `pkg/constants/constants.go`(添加用户类型、状态、店铺层级常量)
- `pkg/constants/redis_keys.go`(添加 Redis Key 生成函数)
### 6.6 测试文件
- `tests/unit/shop_store_test.go`
- `tests/unit/enterprise_store_test.go`
- `tests/unit/personal_customer_store_test.go`
- `tests/testutils/setup.go`(更新 AutoMigrate
### 6.7 文档文件
- `README.md`(添加用户体系设计章节)
- `docs/add-user-organization-model/用户体系设计文档.md`
- `docs/add-user-organization-model/提案完成总结.md`
- `CLAUDE.md`(添加 GORM 字段命名规范)
### 6.8 提案文件
- `openspec/changes/add-user-organization-model/proposal.md`
- `openspec/changes/add-user-organization-model/design.md`
- `openspec/changes/add-user-organization-model/tasks.md`
---
## 七、后续建议
### 7.1 可选任务
以下任务在当前提案范围外,可作为后续优化:
1. **Shop Service 单元测试**tasks.md 6.4
- Store 层已充分测试Service 层测试优先级较低
- 可在实现 API Handler 时一并补充集成测试
2. **缓存失效策略优化**
- 当前使用简单的 30 分钟 TTL
- 可实现主动失效:店铺创建/更新/删除时清除相关缓存
3. **店铺层级路径字段**
-`tb_shop` 添加 `path` 字段(如 `/1/2/3/`
- 优化递归查询性能,但增加更新复杂度
### 7.2 后续功能扩展
1. **企业多账号支持**
- 取消 `enterprise_id` 唯一约束
- 添加 `is_primary` 字段区分主账号
- 实现企业内部权限分配
2. **店铺层级变更**
- 需要复杂的业务审批流程
- 涉及缓存失效、数据权限重新计算
3. **微信登录集成**
- 个人客户的微信 OAuth 登录
- OpenID/UnionID 绑定逻辑
---
## 八、测试验证
### 8.1 单元测试结果
**Shop Store 测试**:
```
✓ TestShopStore_Create - 创建一级店铺、带父店铺的店铺
✓ TestShopStore_GetByID - 查询存在/不存在的店铺
✓ TestShopStore_GetByCode - 根据店铺编号查询
✓ TestShopStore_Update - 更新店铺信息、更新店铺状态
✓ TestShopStore_Delete - 软删除店铺
✓ TestShopStore_List - 分页查询、带过滤条件查询
✓ TestShopStore_GetSubordinateShopIDs - 递归查询4 个子测试)
✓ TestShopStore_UniqueConstraints - 重复店铺编号应失败
```
**Enterprise Store 测试**:
```
✓ TestEnterpriseStore_Create - 创建平台直属企业、归属店铺的企业
✓ TestEnterpriseStore_GetByID - 查询存在/不存在的企业
✓ TestEnterpriseStore_GetByCode - 根据企业编号查询
✓ TestEnterpriseStore_Update - 更新企业信息、更新企业状态
✓ TestEnterpriseStore_Delete - 软删除企业
✓ TestEnterpriseStore_List - 分页查询、带过滤条件查询
✓ TestEnterpriseStore_GetByOwnerShopID - 查询店铺的企业列表
✓ TestEnterpriseStore_UniqueConstraints - 重复企业编号应失败
```
**PersonalCustomer Store 测试**:
```
✓ TestPersonalCustomerStore_Create - 创建基本客户、带微信信息的客户
✓ TestPersonalCustomerStore_GetByID - 查询存在/不存在的客户
✓ TestPersonalCustomerStore_GetByPhone - 根据手机号查询
✓ TestPersonalCustomerStore_GetByWxOpenID - 根据微信 OpenID 查询
✓ TestPersonalCustomerStore_Update - 更新客户信息、绑定微信、更新状态
✓ TestPersonalCustomerStore_Delete - 软删除客户
✓ TestPersonalCustomerStore_List - 分页查询、带过滤条件查询
✓ TestPersonalCustomerStore_UniqueConstraints - 重复手机号应失败
```
**总计**: 23 个测试用例全部通过 ✅
### 8.2 数据库验证
```bash
$ go run cmd/verify_migration/main.go
数据库迁移验证结果:
✓ 表 tb_shop 存在,包含 17
✓ 表 tb_enterprise 存在,包含 18
✓ 表 tb_personal_customer 存在,包含 10
✓ 表 tb_account 存在,包含 13
✓ tb_account.enterprise_id 字段存在
✓ tb_account.parent_id 字段已移除
所有验证通过!
```
---
## 九、提案状态
**状态**: ✅ 已完成
**完成度**: 100%(除可选的 Service 测试)
**核心任务**: 全部完成 ✅
- 数据库迁移 ✅
- GORM 模型定义 ✅
- 常量定义 ✅
- Store 层实现 ✅
- Service 层实现 ✅
- Store 层单元测试 ✅
- 文档更新 ✅
**可选任务**: 1 个未完成
- Shop Service 单元测试优先级低Store 层已充分测试)
---
## 十、总结
本提案成功实现了系统的用户体系和组织模型,建立了清晰的多租户架构和数据权限过滤机制。所有核心功能都通过了单元测试验证,代码质量符合项目规范。
**核心成果**:
1. ✅ 四种用户类型:平台用户、代理账号、企业账号、个人客户
2. ✅ 两种组织实体店铺7 级层级)、企业(平台直属或归属店铺)
3. ✅ 递归查询下级店铺 + Redis 缓存
4. ✅ 数据权限过滤机制(基于 shop_id
5. ✅ 完整的数据库迁移和模型定义
6. ✅ 全面的单元测试覆盖23 个测试用例)
7. ✅ 详细的设计文档和使用说明
**额外成果**:
- 统一了项目中所有 GORM 模型的字段命名规范
- 更新了 CLAUDE.md 和 AGENTS.md 文档
- 提供了完善的代码示例和 FAQ
---
**提案完成时间**: 2026-01-09
**提案作者**: Claude Sonnet 4.5
**审核状态**: 待用户审核

View File

@@ -1,389 +0,0 @@
# Shop Service 单元测试总结
**测试文件**: `tests/unit/shop_service_test.go`
**完成时间**: 2026-01-09
**状态**: ✅ 全部通过
---
## 一、测试概述
本次为 Shop Service店铺业务服务层编写了完整的单元测试重点验证了层级校验逻辑、业务规则验证和错误处理。
---
## 二、测试覆盖
### 2.1 TestShopService_Create创建店铺
**测试用例数**: 6 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 创建一级店铺成功 | 验证创建一级店铺的基本流程 | ✅ |
| 创建二级店铺成功 | 验证创建下级店铺并正确计算层级 | ✅ |
| 层级校验-创建第8级店铺应失败 | **核心测试**验证最大层级限制7级 | ✅ |
| 店铺编号唯一性检查-重复编号应失败 | 验证店铺编号唯一性约束 | ✅ |
| 上级店铺不存在应失败 | 验证上级店铺存在性检查 | ✅ |
| 未授权访问应失败 | 验证用户授权检查 | ✅ |
**核心测试详解**
```go
// 创建 7 级店铺层级结构
for i := 1; i <= 7; i++ {
var parentID *uint
if i > 1 {
parentID = &shops[i-2].ID
}
// 创建第 i 级店铺
shopModel := &model.Shop{
Level: i,
ParentID: parentID,
// ...
}
err := shopStore.Create(ctx, shopModel)
require.NoError(t, err)
}
// 尝试创建第 8 级店铺(应该失败)
req := &model.CreateShopRequest{
ParentID: &shops[6].ID, // 第7级店铺的ID
// ...
}
result, err := service.Create(ctx, req)
assert.Error(t, err)
// 验证错误码
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeShopLevelExceeded, appErr.Code)
assert.Contains(t, appErr.Message, "不能超过 7 级")
```
### 2.2 TestShopService_Update更新店铺
**测试用例数**: 4 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 更新店铺信息成功 | 验证更新店铺基本信息 | ✅ |
| 更新店铺编号-唯一性检查 | 验证更新时的编号唯一性 | ✅ |
| 更新不存在的店铺应失败 | 验证店铺存在性检查 | ✅ |
| 未授权访问应失败 | 验证用户授权检查 | ✅ |
### 2.3 TestShopService_Disable禁用店铺
**测试用例数**: 3 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 禁用店铺成功 | 验证禁用功能并检查状态变更 | ✅ |
| 禁用不存在的店铺应失败 | 验证店铺存在性检查 | ✅ |
| 未授权访问应失败 | 验证用户授权检查 | ✅ |
### 2.4 TestShopService_Enable启用店铺
**测试用例数**: 3 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 启用店铺成功 | 验证启用功能并检查状态变更 | ✅ |
| 启用不存在的店铺应失败 | 验证店铺存在性检查 | ✅ |
| 未授权访问应失败 | 验证用户授权检查 | ✅ |
**注意事项**
- GORM 在保存时会忽略零值(`Status=0`),导致使用数据库默认值
- 测试中先创建启用状态的店铺,再通过 Update 禁用,最后测试 Enable 功能
### 2.5 TestShopService_GetByID获取店铺详情
**测试用例数**: 2 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 获取存在的店铺 | 验证正常查询流程 | ✅ |
| 获取不存在的店铺应失败 | 验证错误处理 | ✅ |
### 2.6 TestShopService_List查询店铺列表
**测试用例数**: 1 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 查询店铺列表 | 验证列表查询功能 | ✅ |
### 2.7 TestShopService_GetSubordinateShopIDs获取下级店铺ID列表
**测试用例数**: 1 个
**全部通过**: ✅
| 测试用例 | 目的 | 状态 |
|---------|------|------|
| 获取下级店铺 ID 列表 | 验证递归查询功能 | ✅ |
---
## 三、测试结果
```
=== RUN TestShopService_Create
--- PASS: TestShopService_Create (11.25s)
--- PASS: TestShopService_Create/创建一级店铺成功 (0.16s)
--- PASS: TestShopService_Create/创建二级店铺成功 (0.29s)
--- PASS: TestShopService_Create/层级校验-创建第8级店铺应失败 (0.59s)
--- PASS: TestShopService_Create/店铺编号唯一性检查-重复编号应失败 (0.14s)
--- PASS: TestShopService_Create/上级店铺不存在应失败 (0.07s)
--- PASS: TestShopService_Create/未授权访问应失败 (0.00s)
=== RUN TestShopService_Update
--- PASS: TestShopService_Update (7.86s)
--- PASS: TestShopService_Update/更新店铺信息成功 (0.22s)
--- PASS: TestShopService_Update/更新店铺编号-唯一性检查 (0.16s)
--- PASS: TestShopService_Update/更新不存在的店铺应失败 (0.02s)
--- PASS: TestShopService_Update/未授权访问应失败 (0.00s)
=== RUN TestShopService_Disable
--- PASS: TestShopService_Disable (8.01s)
--- PASS: TestShopService_Disable/禁用店铺成功 (0.29s)
--- PASS: TestShopService_Disable/禁用不存在的店铺应失败 (0.02s)
--- PASS: TestShopService_Disable/未授权访问应失败 (0.00s)
=== RUN TestShopService_Enable
--- PASS: TestShopService_Enable (9.29s)
--- PASS: TestShopService_Enable/启用店铺成功 (0.49s)
--- PASS: TestShopService_Enable/启用不存在的店铺应失败 (0.03s)
--- PASS: TestShopService_Enable/未授权访问应失败 (0.00s)
=== RUN TestShopService_GetByID
--- PASS: TestShopService_GetByID (9.27s)
--- PASS: TestShopService_GetByID/获取存在的店铺 (0.18s)
--- PASS: TestShopService_GetByID/获取不存在的店铺应失败 (0.04s)
=== RUN TestShopService_List
--- PASS: TestShopService_List (9.24s)
--- PASS: TestShopService_List/查询店铺列表 (0.45s)
=== RUN TestShopService_GetSubordinateShopIDs
--- PASS: TestShopService_GetSubordinateShopIDs (8.98s)
--- PASS: TestShopService_GetSubordinateShopIDs/获取下级店铺_ID_列表 (0.40s)
PASS
ok command-line-arguments 64.887s
```
**总计**: 20 个测试用例全部通过 ✅
---
## 四、测试要点
### 4.1 Context 用户 ID 模拟
Service 层需要从 Context 中获取当前用户 ID测试中使用辅助函数模拟
```go
// createContextWithUserID 创建带用户 ID 的 context
func createContextWithUserID(userID uint) context.Context {
return context.WithValue(context.Background(), constants.ContextKeyUserID, userID)
}
```
### 4.2 层级校验测试策略
**7 级层级创建**
1. 循环创建 1-7 级店铺,每级店铺的 `parent_id` 指向上一级
2. 验证第 7 级店铺创建成功
3. 尝试创建第 8 级店铺,验证返回 `CodeShopLevelExceeded` 错误
**关键代码**
```go
// 计算新店铺的层级
level = parent.Level + 1
// 校验层级不超过最大值
if level > constants.MaxShopLevel {
return nil, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
```
### 4.3 唯一性约束测试
**店铺编号唯一性**
1. 创建第一个店铺(编号 `CODE_001`
2. 尝试创建第二个相同编号的店铺
3. 验证返回 `CodeShopCodeExists` 错误
**更新时唯一性检查**
1. 创建两个不同编号的店铺(`CODE_001``CODE_002`
2. 尝试将 `CODE_002` 更新为 `CODE_001`
3. 验证返回 `CodeShopCodeExists` 错误
### 4.4 授权检查测试
所有需要授权的方法都测试了未授权访问场景:
- Create
- Update
- Disable
- Enable
使用不带用户 ID 的 `context.Background()` 模拟未授权访问,验证返回 `CodeUnauthorized` 错误。
### 4.5 错误码验证
所有错误测试都验证了具体的错误码:
```go
// 验证错误码
appErr, ok := err.(*errors.AppError)
require.True(t, ok, "错误应该是 AppError 类型")
assert.Equal(t, errors.CodeShopLevelExceeded, appErr.Code)
assert.Contains(t, appErr.Message, "不能超过 7 级")
```
---
## 五、测试覆盖的业务逻辑
### 5.1 Create 方法
✅ 用户授权检查
✅ 店铺编号唯一性检查
✅ 上级店铺存在性验证
✅ 层级计算(`level = parent.Level + 1`
**层级校验(最多 7 级)**
✅ 默认状态设置(`StatusEnabled`
✅ Creator/Updater 字段设置
### 5.2 Update 方法
✅ 用户授权检查
✅ 店铺存在性验证
✅ 店铺编号唯一性检查(如果修改了编号)
✅ 部分字段更新(使用指针判断是否更新)
✅ Updater 字段更新
### 5.3 Disable/Enable 方法
✅ 用户授权检查
✅ 店铺存在性验证
✅ 状态更新
✅ Updater 字段更新
### 5.4 GetByID 方法
✅ 店铺存在性验证
✅ 错误处理(店铺不存在)
### 5.5 List 方法
✅ 列表查询功能
✅ 分页支持
### 5.6 GetSubordinateShopIDs 方法
✅ 递归查询下级店铺
✅ 包含自己(用于数据权限过滤)
---
## 六、测试技巧和最佳实践
### 6.1 Table-Driven Tests
虽然本次测试主要使用 `t.Run()` 子测试,但在 Create 测试中展示了适合多用例的场景。
### 6.2 辅助函数
```go
func createContextWithUserID(userID uint) context.Context {
return context.WithValue(context.Background(), constants.ContextKeyUserID, userID)
}
```
封装常用操作,提高测试代码的可读性和可维护性。
### 6.3 错误验证模式
```go
// 1. 验证有错误
assert.Error(t, err)
assert.Nil(t, result)
// 2. 验证错误类型
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
// 3. 验证错误码
assert.Equal(t, errors.CodeXxx, appErr.Code)
// 4. 验证错误消息
assert.Contains(t, appErr.Message, "关键词")
```
### 6.4 数据准备和清理
使用 `testutils.SetupTestDB()``defer testutils.TeardownTestDB()` 确保测试隔离:
```go
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
```
---
## 七、遗留问题和改进建议
### 7.1 已解决的问题
**问题**: GORM 零值处理
**现象**: 创建 `Status=0`StatusDisabled的店铺时GORM 忽略零值,使用数据库默认值 1
**解决**: 先创建启用状态的店铺,再通过 Update 禁用
**改进建议**: 在 Shop model 中使用 `*int` 指针类型存储 Status或使用 `gorm:"default:0"` 显式指定默认值
### 7.2 未来优化方向
1. **性能测试**: 测试 7 级递归查询的性能
2. **并发测试**: 测试并发创建相同编号的店铺
3. **集成测试**: 测试 Service 层与 Handler 层的集成
4. **边界测试**: 测试极端场景(如超长字符串、特殊字符)
---
## 八、总结
### 完成度
**100% 完成** - 所有计划的测试用例都已实现并通过
### 测试质量
- ✅ 覆盖所有公开方法
- ✅ 重点测试核心业务逻辑(层级校验)
- ✅ 完整的错误处理验证
- ✅ 授权检查覆盖
- ✅ 边界条件测试
### 核心成果
**最重要的测试****层级校验测试**创建第8级店铺应失败
这个测试验证了系统的核心业务规则:
- 店铺层级最多 7 级
- 超过限制时正确返回错误码
- 错误消息清晰明确
这确保了系统在生产环境中不会出现超过 7 级的店铺层级,符合业务需求。
---
**测试完成时间**: 2026-01-09
**测试通过率**: 100% (20/20)
**总耗时**: 64.887s

View File

@@ -1,831 +0,0 @@
# 用户体系设计文档
## 概述
本文档详细说明 junhong_cmp_fiber 系统的用户体系设计,包括四种用户类型、两种组织实体、数据关联关系和权限过滤机制。
**版本**: v1.0
**更新时间**: 2026-01-09
**相关提案**: openspec/changes/add-user-organization-model/
---
## 一、用户类型
系统支持四种用户类型,分别对应不同的登录端口和权限范围:
### 1.1 平台用户Platform User
**定义**: 系统平台的管理员账号,拥有全局管理权限。
**特征**:
- `user_type = 1`(超级管理员)或 `user_type = 2`(平台用户)
- `shop_id = NULL``enterprise_id = NULL`
- 可分配多个角色(通过 `tb_account_role` 表)
- 登录端口Web 后台管理系统
**权限范围**:
- 查看和管理所有店铺、企业、账号数据
- 配置系统级别设置
- 分配平台级别角色和权限
- 不受数据权限过滤限制(可看到全部数据)
**典型用例**:
```go
// 创建平台用户示例
account := &model.Account{
Username: "platform_admin",
Phone: "13800000001",
Password: hashedPassword,
UserType: constants.UserTypePlatform, // 2
ShopID: nil,
EnterpriseID: nil,
Status: constants.StatusEnabled,
}
```
---
### 1.2 代理账号Agent Account
**定义**: 归属于某个店铺(代理商)的员工账号。
**特征**:
- `user_type = 3`
- `shop_id = 店铺ID``enterprise_id = NULL`
- 一个店铺可以有多个代理账号
- 同一店铺的所有代理账号权限相同
- 只能分配一种角色
- 登录端口Web 后台管理系统 + H5 移动端
**权限范围**:
- 查看和管理本店铺及下级店铺的数据
- 查看和管理本店铺及下级店铺的企业客户数据
- 不能查看上级店铺的数据
- 不能查看其他平级店铺的数据
**数据权限过滤逻辑**:
```sql
-- 查询当前店铺及所有下级店铺的 ID 列表(递归查询 + Redis 缓存)
WITH RECURSIVE subordinate_shops AS (
SELECT id FROM tb_shop WHERE id = :current_shop_id
UNION
SELECT s.id FROM tb_shop s
INNER JOIN subordinate_shops ss ON s.parent_id = ss.id
WHERE s.deleted_at IS NULL
)
SELECT id FROM subordinate_shops;
-- 数据过滤条件
WHERE shop_id IN (:shop_ids_list)
```
**典型用例**:
```go
// 创建代理账号示例
shopID := uint(100)
account := &model.Account{
Username: "agent_001",
Phone: "13800000002",
Password: hashedPassword,
UserType: constants.UserTypeAgent, // 3
ShopID: &shopID,
EnterpriseID: nil,
Status: constants.StatusEnabled,
}
```
---
### 1.3 企业账号Enterprise Account
**定义**: 归属于某个企业客户的账号。
**特征**:
- `user_type = 4`
- `shop_id = NULL``enterprise_id = 企业ID`
- 一个企业目前只有一个账号(未来可能扩展为多账号)
- 只能分配一种角色
- 登录端口H5 移动端
**权限范围**:
- 查看和管理本企业的数据
- 查看本企业的物联网卡数据
- 不能查看其他企业的数据
**数据权限过滤逻辑**:
```sql
-- 企业账号只能看到自己企业的数据
WHERE enterprise_id = :current_enterprise_id
```
**典型用例**:
```go
// 创建企业账号示例
enterpriseID := uint(200)
account := &model.Account{
Username: "enterprise_001",
Phone: "13800000003",
Password: hashedPassword,
UserType: constants.UserTypeEnterprise, // 4
ShopID: nil,
EnterpriseID: &enterpriseID,
Status: constants.StatusEnabled,
}
```
---
### 1.4 个人客户Personal Customer
**定义**: 使用 H5 个人端的独立个人用户,不参与 RBAC 权限体系。
**特征**:
- 独立存储在 `tb_personal_customer` 表,不在 `tb_account`
- 不参与角色权限系统(无角色、无权限)
- 支持微信绑定OpenID、UnionID
- 登录端口H5 移动端(个人端)
**字段说明**:
- `phone`: 手机号(唯一标识,用于登录)
- `wx_open_id`: 微信 OpenID微信登录用
- `wx_union_id`: 微信 UnionID跨应用用户识别
- `nickname`: 用户昵称
- `avatar_url`: 头像 URL
**权限范围**:
- 查看和管理自己的个人资料
- 查看和管理自己的物联网卡数据
- 不能查看其他用户的数据
**典型用例**:
```go
// 创建个人客户示例
customer := &model.PersonalCustomer{
Phone: "13800000004",
Nickname: "张三",
AvatarURL: "https://example.com/avatar.jpg",
WxOpenID: "wx_openid_123456",
WxUnionID: "wx_unionid_abcdef",
Status: constants.StatusEnabled,
}
```
---
## 二、组织实体
### 2.1 店铺Shop
**定义**: 代理商组织实体,支持最多 7 级层级关系。
**表名**: `tb_shop`
**核心字段**:
```go
type Shop struct {
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
BaseModel `gorm:"embedded"` // Creator, Updater
ShopName string // 店铺名称
ShopCode string `gorm:"uniqueIndex"` // 店铺编号(唯一)
ParentID *uint `gorm:"index"` // 上级店铺IDNULL表示一级代理
Level int // 层级1-7
ContactName string // 联系人姓名
ContactPhone string // 联系人电话
Province string // 省份
City string // 城市
District string // 区县
Address string // 详细地址
Status int // 状态 0=禁用 1=启用
}
```
**层级关系说明**:
- 一级代理:`parent_id = NULL``level = 1`
- 二级代理:`parent_id = 一级店铺ID``level = 2`
- 最多支持 7 级层级
- 层级关系不可变更(业务约束)
**层级结构示例**:
```
平台
├── 店铺A一级代理level=1, parent_id=NULL
│ ├── 店铺B二级代理level=2, parent_id=店铺A.ID
│ │ └── 店铺C三级代理level=3, parent_id=店铺B.ID
│ └── 店铺D二级代理level=2, parent_id=店铺A.ID
└── 店铺E一级代理level=1, parent_id=NULL
```
**递归查询下级店铺**:
```sql
-- 查询店铺ID=100及其所有下级店铺PostgreSQL WITH RECURSIVE
WITH RECURSIVE subordinate_shops AS (
-- 基础查询:当前店铺自己
SELECT id, shop_name, parent_id, level
FROM tb_shop
WHERE id = 100 AND deleted_at IS NULL
UNION
-- 递归查询:当前店铺的所有下级
SELECT s.id, s.shop_name, s.parent_id, s.level
FROM tb_shop s
INNER JOIN subordinate_shops ss ON s.parent_id = ss.id
WHERE s.deleted_at IS NULL
)
SELECT id FROM subordinate_shops;
```
**Redis 缓存策略**:
- Key 格式:`shop:subordinate_ids:{shop_id}`
- 缓存内容店铺ID及其所有下级店铺的 ID 列表(包含自己)
- 过期时间30 分钟
- 缓存失效:店铺创建、更新、删除时清除相关缓存
**代码示例**:
```go
// Store 层方法
func (s *ShopStore) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
// 1. 尝试从 Redis 读取缓存
cacheKey := constants.RedisShopSubordinateIDsKey(shopID)
cached, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
var ids []uint
if err := json.Unmarshal([]byte(cached), &ids); err == nil {
return ids, nil
}
}
// 2. 缓存未命中,执行递归查询
query := `
WITH RECURSIVE subordinate_shops AS (
SELECT id FROM tb_shop WHERE id = ? AND deleted_at IS NULL
UNION
SELECT s.id FROM tb_shop s
INNER JOIN subordinate_shops ss ON s.parent_id = ss.id
WHERE s.deleted_at IS NULL
)
SELECT id FROM subordinate_shops
`
var ids []uint
if err := s.db.WithContext(ctx).Raw(query, shopID).Scan(&ids).Error; err != nil {
return nil, err
}
// 3. 写入 Redis 缓存
data, _ := json.Marshal(ids)
s.redis.Set(ctx, cacheKey, data, 30*time.Minute)
return ids, nil
}
```
---
### 2.2 企业Enterprise
**定义**: 企业客户组织实体,可归属于店铺或平台。
**表名**: `tb_enterprise`
**核心字段**:
```go
type Enterprise struct {
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
BaseModel `gorm:"embedded"` // Creator, Updater
EnterpriseName string // 企业名称
EnterpriseCode string `gorm:"uniqueIndex"` // 企业编号(唯一)
OwnerShopID *uint `gorm:"index"` // 归属店铺IDNULL表示平台直属
LegalPerson string // 法人代表
ContactName string // 联系人姓名
ContactPhone string // 联系人电话
BusinessLicense string // 营业执照号
Province string // 省份
City string // 城市
District string // 区县
Address string // 详细地址
Status int // 状态 0=禁用 1=启用
}
```
**归属关系说明**:
- 平台直属企业:`owner_shop_id = NULL`(由平台直接管理)
- 店铺归属企业:`owner_shop_id = 店铺ID`(由该店铺管理)
**数据权限规则**:
- 平台用户:可以查看所有企业(包括平台直属和所有店铺的企业)
- 代理账号:可以查看本店铺及下级店铺的企业
- 企业账号:只能查看自己的企业数据
**归属结构示例**:
```
平台
├── 企业Z平台直属owner_shop_id=NULL
├── 店铺A
│ ├── 企业X归属店铺Aowner_shop_id=店铺A.ID
│ └── 企业Y归属店铺Aowner_shop_id=店铺A.ID
└── 店铺B
└── 店铺C
└── 企业W归属店铺Cowner_shop_id=店铺C.ID
```
**代码示例**:
```go
// 创建平台直属企业
enterprise := &model.Enterprise{
EnterpriseName: "测试企业A",
EnterpriseCode: "ENT001",
OwnerShopID: nil, // NULL 表示平台直属
LegalPerson: "张三",
ContactName: "李四",
ContactPhone: "13800000001",
BusinessLicense: "91110000MA001234",
Status: constants.StatusEnabled,
}
// 创建归属店铺的企业
shopID := uint(100)
enterprise := &model.Enterprise{
EnterpriseName: "测试企业B",
EnterpriseCode: "ENT002",
OwnerShopID: &shopID, // 归属店铺ID
LegalPerson: "王五",
ContactName: "赵六",
ContactPhone: "13800000002",
BusinessLicense: "91110000MA005678",
Status: constants.StatusEnabled,
}
```
---
## 三、数据关联关系
### 3.1 关系图
```
┌─────────────────┐
│ tb_account │
│ (平台/代理/企业)│
└────────┬────────┘
├─────────────────┐
│ │
shop_id enterprise_id
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ tb_shop │ │ tb_enterprise│
│ (店铺) │ │ (企业) │
└──────┬──────┘ └──────┬───────┘
│ │
parent_id owner_shop_id
│ │
└──────────────────┘
┌──────────────────────┐
│ tb_personal_customer │
│ (个人客户) │
│ (独立表,不关联账号) │
└──────────────────────┘
```
### 3.2 关联规则
**重要设计原则**:
- 禁止使用数据库外键约束Foreign Key Constraints
- 禁止使用 GORM 的 ORM 关联关系(`foreignKey``references``hasMany``belongsTo` 等标签)
- 表之间的关联通过存储关联 ID 字段手动维护
- 关联数据查询必须在代码层面显式执行
**tb_account 关联规则**:
| user_type | shop_id | enterprise_id | 说明 |
|-----------|---------|---------------|------|
| 1 或 2 (平台用户) | NULL | NULL | 平台级账号 |
| 3 (代理账号) | 必填 | NULL | 归属店铺 |
| 4 (企业账号) | NULL | 必填 | 归属企业 |
**tb_enterprise 关联规则**:
- `owner_shop_id = NULL`:平台直属企业
- `owner_shop_id = 店铺ID`:归属该店铺的企业
**tb_shop 关联规则**:
- `parent_id = NULL`:一级代理
- `parent_id = 上级店铺ID`:下级代理
---
## 四、数据权限过滤
### 4.1 核心设计
数据权限过滤基于 **shop_id**(店铺归属)而非 `owner_id`(账号归属)。
**设计理由**:
1. 同一店铺的所有账号应该能看到店铺的所有数据
2. 上级店铺应该能看到下级店铺的数据
3. `owner_id` 字段保留用于记录数据的创建者(审计用途)
### 4.2 过滤逻辑
**平台用户user_type = 1 或 2**:
- 不受数据权限过滤限制
- 可以查看所有数据
**代理账号user_type = 3**:
1. 查询当前店铺及所有下级店铺的 ID 列表(递归查询 + Redis 缓存)
2. 数据查询条件:`WHERE shop_id IN (:shop_ids_list)`
**企业账号user_type = 4**:
- 数据查询条件:`WHERE enterprise_id = :current_enterprise_id`
### 4.3 代码实现示例
```go
// Service 层数据权限过滤
func (s *SomeService) applyDataPermissionFilter(ctx context.Context, query *gorm.DB) (*gorm.DB, error) {
// 从上下文获取当前用户信息
currentUser := GetCurrentUserFromContext(ctx)
// 平台用户跳过过滤
if currentUser.UserType == constants.UserTypeSuperAdmin ||
currentUser.UserType == constants.UserTypePlatform {
return query, nil
}
// 代理账号:基于 shop_id 过滤
if currentUser.UserType == constants.UserTypeAgent {
if currentUser.ShopID == nil {
return nil, errors.New("代理账号缺少店铺ID")
}
// 查询当前店铺及下级店铺 ID 列表(带 Redis 缓存)
shopIDs, err := s.shopStore.GetSubordinateShopIDs(ctx, *currentUser.ShopID)
if err != nil {
return nil, err
}
return query.Where("shop_id IN ?", shopIDs), nil
}
// 企业账号:基于 enterprise_id 过滤
if currentUser.UserType == constants.UserTypeEnterprise {
if currentUser.EnterpriseID == nil {
return nil, errors.New("企业账号缺少企业ID")
}
return query.Where("enterprise_id = ?", *currentUser.EnterpriseID), nil
}
return query, nil
}
```
---
## 五、数据库表结构
### 5.1 tb_shop店铺表
```sql
CREATE TABLE tb_shop (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
creator INTEGER,
updater INTEGER,
shop_name VARCHAR(100) NOT NULL COMMENT '店铺名称',
shop_code VARCHAR(50) COMMENT '店铺编号',
parent_id INTEGER COMMENT '上级店铺ID',
level INTEGER NOT NULL DEFAULT 1 COMMENT '层级1-7',
contact_name VARCHAR(50) COMMENT '联系人姓名',
contact_phone VARCHAR(20) COMMENT '联系人电话',
province VARCHAR(50) COMMENT '省份',
city VARCHAR(50) COMMENT '城市',
district VARCHAR(50) COMMENT '区县',
address VARCHAR(255) COMMENT '详细地址',
status INTEGER NOT NULL DEFAULT 1 COMMENT '状态 0=禁用 1=启用'
);
CREATE INDEX idx_shop_parent_id ON tb_shop(parent_id);
CREATE UNIQUE INDEX idx_shop_code ON tb_shop(shop_code) WHERE deleted_at IS NULL;
CREATE INDEX idx_shop_deleted_at ON tb_shop(deleted_at);
```
### 5.2 tb_enterprise企业表
```sql
CREATE TABLE tb_enterprise (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
creator INTEGER,
updater INTEGER,
enterprise_name VARCHAR(100) NOT NULL COMMENT '企业名称',
enterprise_code VARCHAR(50) COMMENT '企业编号',
owner_shop_id INTEGER COMMENT '归属店铺ID',
legal_person VARCHAR(50) COMMENT '法人代表',
contact_name VARCHAR(50) COMMENT '联系人姓名',
contact_phone VARCHAR(20) COMMENT '联系人电话',
business_license VARCHAR(100) COMMENT '营业执照号',
province VARCHAR(50) COMMENT '省份',
city VARCHAR(50) COMMENT '城市',
district VARCHAR(50) COMMENT '区县',
address VARCHAR(255) COMMENT '详细地址',
status INTEGER NOT NULL DEFAULT 1 COMMENT '状态 0=禁用 1=启用'
);
CREATE INDEX idx_enterprise_owner_shop_id ON tb_enterprise(owner_shop_id);
CREATE UNIQUE INDEX idx_enterprise_code ON tb_enterprise(enterprise_code) WHERE deleted_at IS NULL;
CREATE INDEX idx_enterprise_deleted_at ON tb_enterprise(deleted_at);
```
### 5.3 tb_personal_customer个人客户表
```sql
CREATE TABLE tb_personal_customer (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
phone VARCHAR(20) COMMENT '手机号',
nickname VARCHAR(50) COMMENT '昵称',
avatar_url VARCHAR(255) COMMENT '头像URL',
wx_open_id VARCHAR(100) COMMENT '微信OpenID',
wx_union_id VARCHAR(100) COMMENT '微信UnionID',
status INTEGER NOT NULL DEFAULT 1 COMMENT '状态 0=禁用 1=启用'
);
CREATE UNIQUE INDEX idx_personal_customer_phone ON tb_personal_customer(phone) WHERE deleted_at IS NULL;
CREATE INDEX idx_personal_customer_wx_open_id ON tb_personal_customer(wx_open_id);
CREATE INDEX idx_personal_customer_wx_union_id ON tb_personal_customer(wx_union_id);
CREATE INDEX idx_personal_customer_deleted_at ON tb_personal_customer(deleted_at);
```
### 5.4 tb_account账号表 - 修改)
```sql
-- 添加字段
ALTER TABLE tb_account ADD COLUMN enterprise_id INTEGER;
CREATE INDEX idx_account_enterprise_id ON tb_account(enterprise_id);
-- 移除字段(如果存在)
ALTER TABLE tb_account DROP COLUMN IF EXISTS parent_id;
```
---
## 六、API 使用示例
### 6.1 创建店铺
**接口**: `POST /api/v1/shops`
**权限**: 平台用户
**请求示例**:
```json
{
"shop_name": "北京一级代理",
"shop_code": "BJ001",
"parent_id": null,
"level": 1,
"contact_name": "张三",
"contact_phone": "13800000001",
"province": "北京市",
"city": "北京市",
"district": "朝阳区",
"address": "朝阳路100号"
}
```
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"shop_name": "北京一级代理",
"shop_code": "BJ001",
"parent_id": null,
"level": 1,
"status": 1,
"created_at": "2026-01-09T10:00:00Z"
}
}
```
### 6.2 创建企业
**接口**: `POST /api/v1/enterprises`
**权限**: 平台用户或代理账号
**请求示例(平台直属)**:
```json
{
"enterprise_name": "测试科技有限公司",
"enterprise_code": "ENT001",
"owner_shop_id": null,
"legal_person": "李四",
"contact_name": "王五",
"contact_phone": "13800000002",
"business_license": "91110000MA001234",
"province": "北京市",
"city": "北京市",
"district": "海淀区",
"address": "中关村大街1号"
}
```
**请求示例(归属店铺)**:
```json
{
"enterprise_name": "测试科技有限公司",
"enterprise_code": "ENT002",
"owner_shop_id": 1,
"legal_person": "赵六",
"contact_name": "孙七",
"contact_phone": "13800000003",
"business_license": "91110000MA005678"
}
```
### 6.3 创建代理账号
**接口**: `POST /api/v1/accounts`
**权限**: 平台用户
**请求示例**:
```json
{
"username": "agent001",
"phone": "13800000004",
"password": "password123",
"user_type": 3,
"shop_id": 1,
"enterprise_id": null
}
```
### 6.4 查询下级店铺
**接口**: `GET /api/v1/shops/{shop_id}/subordinates`
**权限**: 平台用户或对应店铺的代理账号
**响应示例**:
```json
{
"code": 0,
"message": "success",
"data": {
"shop_ids": [1, 2, 3, 4],
"details": [
{
"id": 1,
"shop_name": "一级店铺",
"level": 1,
"parent_id": null
},
{
"id": 2,
"shop_name": "二级店铺1",
"level": 2,
"parent_id": 1
},
{
"id": 3,
"shop_name": "二级店铺2",
"level": 2,
"parent_id": 1
},
{
"id": 4,
"shop_name": "三级店铺",
"level": 3,
"parent_id": 2
}
]
}
}
```
---
## 七、常见问题FAQ
### Q1: 为什么不使用外键约束?
**A**: 这是项目的核心设计原则之一。原因包括:
1. **灵活性**: 业务逻辑完全在代码中控制,不受数据库约束限制
2. **性能**: 无外键约束意味着无数据库层面的引用完整性检查开销
3. **可控性**: 开发者完全掌控何时查询关联数据、查询哪些关联数据
4. **分布式友好**: 在微服务和分布式数据库场景下更容易扩展
### Q2: 为什么不使用 GORM 的关联关系(如 hasMany、belongsTo
**A**: 与不使用外键的理由类似:
1. **显式关联**: 代码中显式执行关联查询,数据流向清晰可见
2. **性能优化**: 避免 GORM 的 N+1 查询问题和自动 JOIN 开销
3. **简单直接**: 手动查询关联数据更容易理解和调试
### Q3: 代理账号的权限是如何继承的?
**A**: 代理账号的权限不是通过"继承"实现,而是通过**数据过滤范围**实现:
- 上级店铺可以查看下级店铺的数据(通过递归查询 shop_id 列表)
- 同一店铺的所有账号看到的数据范围相同
- 下级店铺不能查看上级店铺的数据
### Q4: 如何查询某个店铺的所有下级店铺?
**A**: 使用 `ShopStore.GetSubordinateShopIDs()` 方法:
```go
shopIDs, err := shopStore.GetSubordinateShopIDs(ctx, shopID)
// 返回的 shopIDs 包含当前店铺自己 + 所有下级店铺
```
该方法使用 PostgreSQL 的 `WITH RECURSIVE` 递归查询,并通过 Redis 缓存 30 分钟。
### Q5: 个人客户为什么单独存储,不放在 tb_account 表?
**A**: 因为个人客户与其他用户类型有本质区别:
1. **不参与 RBAC**: 无角色、无权限,不需要 account_role、role_permission 等关联
2. **字段差异**: 需要微信绑定字段wx_open_id、wx_union_id不需要 username
3. **数据量大**: 个人客户数量可能远超账号数量,分表便于扩展和优化
### Q6: 企业账号未来如何支持多账号?
**A**: 当前一个企业只有一个账号(`enterprise_id``tb_account` 中是唯一的)。
未来支持多账号时可以:
1. 取消 `enterprise_id` 的唯一约束
2.`tb_account` 添加 `is_primary` 字段区分主账号和子账号
3. 或者添加 `tb_enterprise_account` 中间表管理企业的多个账号
4. 在 Service 层实现企业内部的权限分配逻辑
### Q7: 店铺层级为什么限制为 7 级?
**A**: 这是业务约束。技术上可以支持更多层级,但考虑到:
1. 代理商管理的复杂度
2. 递归查询的性能影响
3. 实际业务场景中很少需要超过 7 级
### Q8: Redis 缓存失效策略是什么?
**A**: 当前使用简单的 TTL 过期策略30 分钟)。
更完善的缓存失效策略可以是:
- 店铺创建时:清除父店铺及所有祖先店铺的缓存
- 店铺更新时:如果 `parent_id` 变更,清除新旧父店铺的缓存
- 店铺删除时:清除父店铺及所有祖先店铺的缓存
代码示例:
```go
func (s *ShopStore) InvalidateSubordinateCache(ctx context.Context, shopID uint) error {
// 向上递归清除所有祖先店铺的缓存
// ...
}
```
---
## 八、后续优化建议
1. **性能优化**:
- 考虑在 `tb_shop` 添加 `path` 字段存储完整路径(如 `/1/2/3/`),减少递归查询
- 使用物化视图Materialized View缓存店铺层级关系
- 对高频查询的店铺 ID 列表使用 Redis Set 数据结构
2. **功能扩展**:
- 实现企业多账号支持
- 添加店铺层级变更功能(需要复杂的业务审批流程)
- 添加组织架构树的可视化展示
3. **监控和审计**:
- 记录所有组织关系变更的审计日志
- 监控递归查询的性能指标
- 监控 Redis 缓存命中率
4. **安全加固**:
- 限制店铺层级修改的权限(只有超级管理员)
- 防止循环引用(`parent_id` 指向自己或形成环)
- 数据权限过滤的单元测试和集成测试覆盖
---
## 九、相关文档
- **设计文档**: `openspec/changes/add-user-organization-model/design.md`
- **提案文档**: `openspec/changes/add-user-organization-model/proposal.md`
- **任务清单**: `openspec/changes/add-user-organization-model/tasks.md`
- **数据库迁移**: `migrations/000002_create_shop_enterprise_personal_customer.up.sql`
- **单元测试**: `tests/unit/shop_store_test.go`, `tests/unit/enterprise_store_test.go`, `tests/unit/personal_customer_store_test.go`
---
**文档结束**

View File

@@ -1,456 +0,0 @@
# 钱包、换卡、标签系统 - 字段详细说明
## 一、钱包系统字段说明
### 1. tb_wallet钱包表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 钱包唯一标识 | 1 |
| user_id | BIGINT | 是 | 无 | 所属用户ID关联 tb_account.id | 123 |
| wallet_type | VARCHAR(20) | 是 | 无 | 钱包类型:`user`=用户钱包,`agent`=代理钱包 | "user" |
| balance | BIGINT | 是 | 0 | 可用余额单位1元=100分 | 5000005000元 |
| frozen_balance | BIGINT | 是 | 0 | 冻结余额(单位:分),用于待结算的分佣、提现等 | 10000100元 |
| currency | VARCHAR(10) | 是 | 'CNY' | 币种代码ISO 4217 标准 | "CNY" |
| status | INT | 是 | 1 | 钱包状态1=正常2=冻结3=关闭 | 1 |
| version | INT | 是 | 0 | 乐观锁版本号,每次更新余额时+1防止并发冲突 | 5 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 最后更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 11:30:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间NULL表示未删除 | NULL |
**业务规则**
- 同一用户在同一币种下只能有一个同类型的钱包(唯一约束)
- `balance + frozen_balance` = 总资产
- 余额扣减时必须使用乐观锁WHERE version = ?
---
### 2. tb_wallet_transaction钱包交易记录表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 交易记录唯一标识 | 1 |
| wallet_id | BIGINT | 是 | 无 | 钱包ID关联 tb_wallet.id | 123 |
| user_id | BIGINT | 是 | 无 | 用户ID冗余存储便于查询 | 456 |
| transaction_type | VARCHAR(20) | 是 | 无 | 交易类型:`recharge`=充值,`deduct`=扣款,`refund`=退款,`commission`=分佣,`withdrawal`=提现 | "recharge" |
| amount | BIGINT | 是 | 无 | 变动金额(单位:分),正数表示增加,负数表示减少 | 50000+500元 |
| balance_before | BIGINT | 是 | 无 | 变动前余额(单位:分) | 1000001000元 |
| balance_after | BIGINT | 是 | 无 | 变动后余额(单位:分) | 1500001500元 |
| status | INT | 是 | 1 | 交易状态1=成功2=失败3=处理中 | 1 |
| reference_type | VARCHAR(50) | 否 | NULL | 关联业务类型:`order`=订单,`commission`=分佣,`withdrawal`=提现,`topup`=充值 | "order" |
| reference_id | BIGINT | 否 | NULL | 关联业务ID如订单ID、分佣ID | 789 |
| remark | TEXT | 否 | NULL | 备注信息,人工输入或系统生成 | "购买套餐扣款" |
| metadata | JSONB | 否 | NULL | 扩展信息JSON格式存储第三方交易号、手续费等 | {"fee": 50, "channel": "alipay"} |
| creator | BIGINT | 否 | 无 | 创建人ID系统创建时为0 | 0 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 交易时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**业务规则**
- 每次余额变动必须创建一条交易记录
- `balance_after = balance_before + amount`
- 交易记录只能新增,不能修改或删除(审计要求)
**metadata 扩展字段示例**
```json
{
"payment_channel": "alipay", // 支付渠道
"transaction_no": "2025011310000001", // 第三方交易号
"fee": 50, // 手续费(分)
"operator": "admin", // 操作人
"ip": "192.168.1.100" // 操作IP
}
```
---
### 3. tb_recharge_record充值记录表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 充值记录唯一标识 | 1 |
| user_id | BIGINT | 是 | 无 | 充值用户ID | 123 |
| wallet_id | BIGINT | 是 | 无 | 充值目标钱包ID | 456 |
| recharge_no | VARCHAR(50) | 是 | 无 | 充值订单号唯一格式RCH+时间戳+随机数 | "RCH20250113100000001" |
| amount | BIGINT | 是 | 无 | 充值金额(单位:分) | 1000001000元 |
| payment_method | VARCHAR(20) | 是 | 无 | 支付方式:`alipay`=支付宝,`wechat`=微信,`bank`=银行转账,`offline`=线下 | "alipay" |
| payment_channel | VARCHAR(50) | 否 | NULL | 支付渠道(第三方平台),如"支付宝-即时到账" | "alipay_direct" |
| payment_transaction_id | VARCHAR(100) | 否 | NULL | 第三方支付交易号,用于对账 | "2025011322001412345678" |
| status | INT | 是 | 1 | 充值状态1=待支付2=已支付3=已完成4=已关闭5=已退款 | 3 |
| paid_at | TIMESTAMP | 否 | NULL | 支付完成时间 | 2025-01-13 10:05:00 |
| completed_at | TIMESTAMP | 否 | NULL | 充值完成时间(余额到账) | 2025-01-13 10:05:30 |
| creator | BIGINT | 否 | 无 | 创建人ID | 123 |
| updater | BIGINT | 否 | 无 | 更新人ID | 0 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:05:30 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**业务规则**
- `recharge_no` 全局唯一,用于幂等性控制
- 状态流转:待支付 → 已支付 → 已完成
- 超时未支付订单自动关闭30分钟
---
## 二、换卡记录系统字段说明
### tb_card_replacement_record换卡记录表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 换卡记录唯一标识 | 1 |
| replacement_no | VARCHAR(50) | 是 | 无 | 换卡单号唯一格式REP+时间戳+随机数 | "REP20250113100000001" |
| old_card_id | BIGINT | 是 | 无 | 老卡ID关联 tb_iot_card.id | 123 |
| old_iccid | VARCHAR(50) | 是 | 无 | 老卡ICCID冗余存储即使卡删除也能追溯 | "898600..." |
| new_card_id | BIGINT | 是 | 无 | 新卡ID关联 tb_iot_card.id | 456 |
| new_iccid | VARCHAR(50) | 是 | 无 | 新卡ICCID冗余存储 | "898600..." |
| old_owner_type | VARCHAR(20) | 是 | 无 | 老卡所有者类型:`platform`=平台,`agent`=代理,`user`=用户,`device`=设备 | "user" |
| old_owner_id | BIGINT | 是 | 无 | 老卡所有者ID | 789 |
| old_agent_id | BIGINT | 否 | NULL | 老卡代理ID如果有 | 100 |
| new_owner_type | VARCHAR(20) | 是 | 无 | 新卡所有者类型 | "user" |
| new_owner_id | BIGINT | 是 | 无 | 新卡所有者ID | 789 |
| new_agent_id | BIGINT | 否 | NULL | 新卡代理ID | 100 |
| package_snapshot | JSONB | 否 | NULL | 套餐快照JSON格式记录换卡时的套餐状态 | 见下方示例 |
| replacement_reason | VARCHAR(20) | 是 | 无 | 换卡原因:`damaged`=损坏,`lost`=丢失,`malfunction`=故障,`upgrade`=升级,`other`=其他 | "damaged" |
| remark | TEXT | 否 | NULL | 备注说明 | "卡片物理损坏,无法识别" |
| status | INT | 是 | 1 | 换卡状态1=待审批2=已通过3=已拒绝4=已完成 | 4 |
| approved_by | BIGINT | 否 | NULL | 审批人ID | 1 |
| approved_at | TIMESTAMP | 否 | NULL | 审批时间 | 2025-01-13 11:00:00 |
| completed_at | TIMESTAMP | 否 | NULL | 完成时间 | 2025-01-13 11:30:00 |
| creator | BIGINT | 否 | 无 | 创建人ID | 789 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 11:30:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**package_snapshot 字段结构**
```json
{
"package_id": 123,
"package_name": "月包50GB",
"package_type": "formal",
"data_quota": 51200000,
"data_used": 10240000,
"valid_from": "2025-01-01T00:00:00Z",
"valid_to": "2025-01-31T23:59:59Z",
"price": 5000,
"remaining_days": 20,
"transfer_reason": "卡损坏,套餐转移至新卡"
}
```
**字段说明**
- `package_id`: 套餐ID
- `package_name`: 套餐名称
- `package_type`: 套餐类型formal=正式套餐addon=附加套餐)
- `data_quota`: 流量额度KB
- `data_used`: 已使用流量KB
- `valid_from`: 套餐生效时间
- `valid_to`: 套餐失效时间
- `price`: 套餐价格(分)
- `remaining_days`: 剩余天数
- `transfer_reason`: 转移原因
**业务规则**
- 换卡申请需要审批(除非设置为自动通过)
- 老卡状态变为"已停用",新卡状态变为"已激活"
- 套餐信息转移到新卡,剩余流量和有效期保持不变
---
## 三、标签系统字段说明
### 1. tb_tag标签表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 标签唯一标识 | 1 |
| name | VARCHAR(100) | 是 | 无 | 标签名称(全局唯一) | "重点客户" |
| color | VARCHAR(20) | 否 | NULL | 标签颜色(十六进制),用于前端展示 | "#FF5733" |
| usage_count | INT | 是 | 0 | 使用次数,每次打标签+1取消标签-1 | 25 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**业务规则**
- 标签名称全局唯一(不区分大小写)
- `usage_count` 用于展示热门标签(按使用次数降序)
- 标签删除时,关联的资源标签也会软删除
---
### 2. tb_resource_tag资源-标签关联表)
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 关联记录唯一标识 | 1 |
| resource_type | VARCHAR(20) | 是 | 无 | 资源类型:`device`=设备,`iot_card`=IoT卡`number_card`=号卡 | "iot_card" |
| resource_id | BIGINT | 是 | 无 | 资源ID根据 resource_type 关联不同的表) | 123 |
| tag_id | BIGINT | 是 | 无 | 标签ID关联 tb_tag.id | 5 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**资源类型映射**
| resource_type | 关联表 | 说明 |
|---------------|--------|------|
| device | tb_device | 设备 |
| iot_card | tb_iot_card | IoT卡 |
| number_card | tb_number_card | 号卡 |
**业务规则**
- 同一资源不能重复打同一个标签(唯一约束)
- 打标签时,标签的 `usage_count` 自动+1
- 取消标签时,标签的 `usage_count` 自动-1
---
## 四、修改字段说明
### 1. tb_carrier运营商表- 新增字段
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| carrier_type | VARCHAR(20) | 是 | 'CMCC' | 运营商类型(固定枚举):`CMCC`=中国移动,`CUCC`=中国联通,`CTCC`=中国电信,`CBN`=广电 | "CMCC" |
| channel_name | VARCHAR(100) | 否 | NULL | 渠道名称(可自定义),如"广东移动-企业渠道" | "广东移动-企业渠道" |
| channel_code | VARCHAR(50) | 否 | NULL | 渠道编码可自定义用于对接渠道API | "GD_CMCC_ENT" |
**业务规则**
- 同一运营商类型可以有多个渠道(通过 channel_code 区分)
- `carrier_type + channel_code` 组合唯一
---
### 2. tb_order订单表- 新增字段
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| wallet_payment_amount | BIGINT | 是 | 0 | 钱包支付金额(单位:分) | 30000300元 |
| online_payment_amount | BIGINT | 是 | 0 | 在线支付金额(单位:分) | 20000200元 |
**业务规则**
- 订单总金额 `amount = wallet_payment_amount + online_payment_amount`
- 支持纯钱包支付、纯在线支付、混合支付三种模式
- `payment_method` 字段保留,标识主要支付方式
**支付模式示例**
| 模式 | wallet_payment_amount | online_payment_amount | payment_method | 说明 |
|------|----------------------|----------------------|----------------|------|
| 纯钱包支付 | 50000 | 0 | wallet | 全部从钱包扣款 |
| 纯在线支付 | 0 | 50000 | online | 全部在线支付 |
| 混合支付 | 30000 | 20000 | wallet | 钱包不足,补充在线支付 |
---
## 五、数据类型说明
### 1. 金额字段BIGINT
- 单位:**分**1元 = 100分
- 类型BIGINT范围-9223372036854775808 ~ 9223372036854775807
- 最大金额:约 92,233,720,368,547,758 元(足够使用)
**示例**
```go
amount := int64(100000) // 1000元 = 100000分
fmt.Println(amount / 100) // 输出1000
```
### 2. 时间字段TIMESTAMP
- 时区:数据库存储为 UTC 时间,应用层转换为本地时间
- 格式:`2025-01-13 10:00:00`
### 3. JSONB 字段
- PostgreSQL 专有类型,高效存储和查询 JSON 数据
- 支持索引和查询操作
**查询示例**
```sql
-- 查询 metadata 中 fee 大于 100 的交易
SELECT * FROM tb_wallet_transaction
WHERE metadata->>'fee' > '100';
-- 查询套餐快照中剩余天数小于 10 的换卡记录
SELECT * FROM tb_card_replacement_record
WHERE (package_snapshot->>'remaining_days')::int < 10;
```
---
## 六、索引说明
### 1. 唯一索引
| 表名 | 索引名 | 字段 | 条件 |
|------|--------|------|------|
| tb_wallet | idx_wallet_user_type_currency | (user_id, wallet_type, currency) | WHERE deleted_at IS NULL |
| tb_recharge_record | idx_recharge_no | (recharge_no) | WHERE deleted_at IS NULL |
| tb_card_replacement_record | idx_card_replacement_no | (replacement_no) | WHERE deleted_at IS NULL |
| tb_tag | idx_tag_name | (name) | WHERE deleted_at IS NULL |
| tb_resource_tag | idx_resource_tag_unique | (resource_type, resource_id, tag_id) | WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_type_channel | (carrier_type, channel_code) | WHERE deleted_at IS NULL |
### 2. 普通索引
| 表名 | 索引名 | 字段 | 用途 |
|------|--------|------|------|
| tb_wallet | idx_wallet_user | (user_id, deleted_at) | 按用户查询钱包 |
| tb_wallet | idx_wallet_status | (status, deleted_at) | 按状态查询钱包 |
| tb_wallet_transaction | idx_wallet_tx_wallet | (wallet_id, created_at DESC) | 按钱包查询交易记录 |
| tb_wallet_transaction | idx_wallet_tx_user | (user_id, created_at DESC) | 按用户查询交易记录 |
| tb_wallet_transaction | idx_wallet_tx_ref | (reference_type, reference_id) | 按关联业务查询交易 |
| tb_recharge_record | idx_recharge_user | (user_id, created_at DESC) | 按用户查询充值记录 |
| tb_recharge_record | idx_recharge_status | (status, created_at DESC) | 按状态查询充值记录 |
| tb_card_replacement_record | idx_card_replacement_old_card | (old_card_id, created_at DESC) | 按老卡查询换卡记录 |
| tb_card_replacement_record | idx_card_replacement_new_card | (new_card_id, created_at DESC) | 按新卡查询换卡记录 |
| tb_card_replacement_record | idx_card_replacement_old_owner | (old_owner_type, old_owner_id) | 按老卡所有者查询 |
| tb_card_replacement_record | idx_card_replacement_new_owner | (new_owner_type, new_owner_id) | 按新卡所有者查询 |
| tb_card_replacement_record | idx_card_replacement_status | (status, created_at DESC) | 按状态查询换卡记录 |
| tb_tag | idx_tag_usage | (usage_count DESC, deleted_at) | 查询热门标签 |
| tb_resource_tag | idx_resource_tag_resource | (resource_type, resource_id, deleted_at) | 查询资源的标签 |
| tb_resource_tag | idx_resource_tag_tag | (tag_id, deleted_at) | 查询标签的资源 |
| tb_resource_tag | idx_resource_tag_composite | (resource_type, tag_id, deleted_at) | 按资源类型和标签查询 |
---
## 七、字段验证规则
### 1. 钱包字段验证
| 字段 | 验证规则 |
|------|---------|
| balance | ≥ 0不能为负 |
| frozen_balance | ≥ 0不能为负 |
| wallet_type | 必须为 `user``agent` |
| currency | 符合 ISO 4217 标准(如 CNY、USD |
| status | 必须为 1、2、3 |
| version | ≥ 0 |
### 2. 交易字段验证
| 字段 | 验证规则 |
|------|---------|
| amount | 不能为 0 |
| balance_after | 必须等于 `balance_before + amount` |
| transaction_type | 必须为 recharge/deduct/refund/commission/withdrawal |
| status | 必须为 1、2、3 |
### 3. 充值字段验证
| 字段 | 验证规则 |
|------|---------|
| amount | > 0单次充值 ≥ 1元100分 |
| recharge_no | 格式RCH + 17位数字 |
| payment_method | 必须为 alipay/wechat/bank/offline |
| status | 必须为 1、2、3、4、5 |
### 4. 换卡字段验证
| 字段 | 验证规则 |
|------|---------|
| replacement_no | 格式REP + 17位数字 |
| old_card_id | 必须是已存在的卡ID |
| new_card_id | 必须是已存在且状态为"在库"的卡ID |
| replacement_reason | 必须为 damaged/lost/malfunction/upgrade/other |
| status | 必须为 1、2、3、4 |
### 5. 标签字段验证
| 字段 | 验证规则 |
|------|---------|
| name | 长度 ≤ 100不能为空全局唯一 |
| color | 必须符合十六进制颜色格式(#RRGGBB |
| usage_count | ≥ 0 |
| resource_type | 必须为 device/iot_card/number_card |
---
## 八、字段使用注意事项
### 1. 金额计算
```go
// 错误示例:使用浮点数
price := 19.99 // ❌ 浮点数精度问题
total := price * 100 // ❌ 结果1999.0000000002
// 正确示例:使用整数
price := int64(1999) // ✅ 直接使用分为单位
total := price * 2 // ✅ 结果3998
```
### 2. 乐观锁使用
```go
// 查询钱包
wallet, _ := walletStore.GetByID(ctx, walletID)
// 扣款(带乐观锁)
result := db.Model(&Wallet{}).
Where("id = ? AND version = ?", walletID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", amount),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
return errors.New("余额变更失败,请重试") // 并发冲突
}
```
### 3. JSONB 查询
```go
// 查询 metadata 中的字段
var transactions []WalletTransaction
db.Where("metadata->>'payment_channel' = ?", "alipay").Find(&transactions)
// 查询嵌套字段
db.Where("package_snapshot->>'package_type' = ?", "formal").Find(&records)
```
### 4. 软删除查询
```go
// 默认查询(自动排除软删除)
db.Find(&wallets) // WHERE deleted_at IS NULL
// 包含软删除记录
db.Unscoped().Find(&wallets)
// 只查询软删除记录
db.Where("deleted_at IS NOT NULL").Unscoped().Find(&wallets)
```
---
## 九、常见问题
### Q1: 为什么金额使用整数而不是浮点数?
**A**: 浮点数存在精度问题0.1 + 0.2 != 0.3),在金融系统中可能导致账务错误。使用整数(分为单位)可以避免精度问题。
### Q2: 为什么要冗余存储 ICCID
**A**: 换卡记录需要长期保存,即使物联卡被删除,也需要能追溯历史记录。冗余存储 ICCID 可以避免关联查询失败。
### Q3: 为什么标签要记录 usage_count
**A**: 用于展示热门标签,提升用户体验。每次打标签/取消标签时更新,避免每次查询时统计。
### Q4: 软删除后为什么还能创建同名标签?
**A**: 唯一索引包含 `WHERE deleted_at IS NULL` 条件,软删除后该记录不再参与唯一性检查,允许创建同名标签。
### Q5: 乐观锁和悲观锁有什么区别?
**A**:
- 乐观锁:假设冲突少,使用 version 字段判断,适合读多写少场景
- 悲观锁:假设冲突多,使用 `SELECT ... FOR UPDATE` 锁定行,适合写多场景
钱包系统使用乐观锁,因为余额查询频繁,扣款相对较少。

View File

@@ -1,624 +0,0 @@
# 钱包、换卡、标签系统 - 数据模型设计
## 概述
本次变更新增了三个核心业务模块的数据模型设计:
1. **钱包系统**:用户和代理的资金账户管理
2. **换卡记录系统**:物联卡更换历史追溯
3. **标签系统**设备、IoT卡、号卡的分类管理
## 一、钱包系统
### 1.1 表结构
#### tb_wallet钱包表
**业务说明**:每个用户/代理拥有一个或多个钱包(按币种区分),支持充值、消费、提现等操作。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| user_id | BIGINT | 用户ID | idx_wallet_user |
| wallet_type | VARCHAR(20) | 钱包类型user/agent | idx_wallet_user_type_currency (UNIQUE) |
| balance | BIGINT | 余额(分) | - |
| frozen_balance | BIGINT | 冻结余额(分) | - |
| currency | VARCHAR(10) | 币种默认CNY | idx_wallet_user_type_currency (UNIQUE) |
| status | INT | 钱包状态1=正常 2=冻结 3=关闭) | idx_wallet_status |
| version | INT | 版本号(乐观锁) | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 使用 `version` 字段实现乐观锁,防止并发余额冲突
- `frozen_balance` 用于冻结资金(如待结算的分佣)
- 唯一索引:`(user_id, wallet_type, currency) WHERE deleted_at IS NULL`
#### tb_wallet_transaction钱包交易记录表
**业务说明**:记录所有钱包余额变动,用于对账和审计。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| wallet_id | BIGINT | 钱包ID | idx_wallet_tx_wallet |
| user_id | BIGINT | 用户ID | idx_wallet_tx_user |
| transaction_type | VARCHAR(20) | 交易类型recharge/deduct/refund/commission/withdrawal | - |
| amount | BIGINT | 变动金额(分),正数为增加,负数为减少 | - |
| balance_before | BIGINT | 变动前余额(分) | - |
| balance_after | BIGINT | 变动后余额(分) | - |
| status | INT | 交易状态1=成功 2=失败 3=处理中) | - |
| reference_type | VARCHAR(50) | 关联业务类型order/commission/withdrawal/topup | idx_wallet_tx_ref |
| reference_id | BIGINT | 关联业务ID | idx_wallet_tx_ref |
| remark | TEXT | 备注 | - |
| metadata | JSONB | 扩展信息 | - |
| creator | BIGINT | 创建人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 记录 `balance_before``balance_after` 便于对账
- `reference_type` + `reference_id` 关联业务对象
- 使用 JSONB 存储扩展信息(如第三方交易号、手续费等)
#### tb_recharge_record充值记录表
**业务说明**:用户和代理的钱包充值订单,记录支付流程。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| user_id | BIGINT | 用户ID | idx_recharge_user |
| wallet_id | BIGINT | 钱包ID | - |
| recharge_no | VARCHAR(50) | 充值订单号(唯一) | idx_recharge_no (UNIQUE) |
| amount | BIGINT | 充值金额(分) | - |
| payment_method | VARCHAR(20) | 支付方式alipay/wechat/bank/offline | - |
| payment_channel | VARCHAR(50) | 支付渠道 | - |
| payment_transaction_id | VARCHAR(100) | 第三方支付交易号 | - |
| status | INT | 充值状态1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款) | idx_recharge_status |
| paid_at | TIMESTAMP | 支付时间 | - |
| completed_at | TIMESTAMP | 完成时间 | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- `recharge_no` 作为唯一订单号,用于幂等性控制
- 状态流转:待支付 → 已支付 → 已完成
### 1.2 业务流程
#### 充值流程
```
1. 用户发起充值请求 → 创建 RechargeRecordstatus=1 待支付)
2. 调用支付网关 → 获取支付链接
3. 用户完成支付 → 支付回调更新 RechargeRecordstatus=2 已支付)
4. 系统处理充值 → 创建 WalletTransactiontype=recharge
5. 更新 Wallet 余额 → 使用乐观锁version+1
6. 充值完成 → 更新 RechargeRecordstatus=3 已完成)
```
#### 消费流程
```
1. 用户购买套餐 → 检查钱包余额
2. 冻结金额 → 增加 frozen_balance
3. 订单完成 → 扣减 frozen_balance 和 balance
4. 创建 WalletTransactiontype=deduct, reference_type=order
```
---
## 二、换卡记录系统
### 2.1 表结构
#### tb_card_replacement_record换卡记录表
**业务说明**:记录物联卡更换历史,包含套餐快照便于追溯。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| replacement_no | VARCHAR(50) | 换卡单号(唯一) | idx_card_replacement_no (UNIQUE) |
| old_card_id | BIGINT | 老卡ID | idx_card_replacement_old_card |
| old_iccid | VARCHAR(50) | 老卡ICCID冗余存储 | - |
| new_card_id | BIGINT | 新卡ID | idx_card_replacement_new_card |
| new_iccid | VARCHAR(50) | 新卡ICCID冗余存储 | - |
| old_owner_type | VARCHAR(20) | 老卡所有者类型 | idx_card_replacement_old_owner |
| old_owner_id | BIGINT | 老卡所有者ID | idx_card_replacement_old_owner |
| old_agent_id | BIGINT | 老卡代理ID | - |
| new_owner_type | VARCHAR(20) | 新卡所有者类型 | idx_card_replacement_new_owner |
| new_owner_id | BIGINT | 新卡所有者ID | idx_card_replacement_new_owner |
| new_agent_id | BIGINT | 新卡代理ID | - |
| package_snapshot | JSONB | 套餐快照 | - |
| replacement_reason | VARCHAR(20) | 换卡原因damaged/lost/malfunction/upgrade/other | - |
| remark | TEXT | 备注 | - |
| status | INT | 换卡状态1=待审批 2=已通过 3=已拒绝 4=已完成) | idx_card_replacement_status |
| approved_by | BIGINT | 审批人ID | - |
| approved_at | TIMESTAMP | 审批时间 | - |
| completed_at | TIMESTAMP | 完成时间 | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 冗余存储 `old_iccid``new_iccid`,即使卡被删除也能追溯
- `package_snapshot` 使用 JSONB 存储套餐快照套餐ID、名称、剩余流量、有效期等
- 支持审批流程(待审批 → 已通过 → 已完成)
### 2.2 套餐快照结构
```json
{
"package_id": 123,
"package_name": "月包50GB",
"package_type": "formal",
"data_quota": 51200000,
"data_used": 10240000,
"valid_from": "2025-01-01T00:00:00Z",
"valid_to": "2025-01-31T23:59:59Z",
"price": 5000,
"remaining_days": 20,
"transfer_reason": "卡损坏,套餐转移至新卡"
}
```
### 2.3 业务流程
```
1. 用户申请换卡 → 创建 CardReplacementRecordstatus=1 待审批)
2. 记录老卡套餐信息 → 生成 package_snapshot
3. 平台审批 → 更新 status=2已通过或 3已拒绝
4. 执行换卡操作 → 更新卡所有权、停用老卡、激活新卡
5. 转移套餐 → 将套餐绑定到新卡
6. 完成换卡 → 更新 status=4已完成
```
---
## 三、标签系统
### 3.1 表结构
#### tb_tag标签表
**业务说明**:定义可复用的标签,支持自定义颜色。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| name | VARCHAR(100) | 标签名称(唯一) | idx_tag_name (UNIQUE) |
| color | VARCHAR(20) | 标签颜色(十六进制) | - |
| usage_count | INT | 使用次数 | idx_tag_usage |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- `usage_count` 记录标签使用次数,用于展示热门标签
- 标签名称全局唯一(软删除排除)
#### tb_resource_tag资源-标签关联表)
**业务说明**统一管理设备、IoT卡、号卡与标签的多对多关系。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| resource_type | VARCHAR(20) | 资源类型device/iot_card/number_card | idx_resource_tag_unique (UNIQUE) |
| resource_id | BIGINT | 资源ID | idx_resource_tag_unique (UNIQUE) |
| tag_id | BIGINT | 标签ID | idx_resource_tag_unique (UNIQUE) |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 唯一约束:`(resource_type, resource_id, tag_id) WHERE deleted_at IS NULL`
- 支持按资源类型、资源ID、标签ID 多维度查询
### 3.2 支持的资源类型
| 资源类型 | 说明 | 关联表 |
|---------|------|--------|
| device | 设备 | tb_device |
| iot_card | IoT卡 | tb_iot_card |
| number_card | 号卡 | tb_number_card |
### 3.3 业务流程
```
1. 创建标签 → 插入 tb_tagusage_count=0
2. 为资源打标签 → 插入 tb_resource_tag
3. 增加标签使用次数 → UPDATE tb_tag SET usage_count = usage_count + 1
4. 删除资源标签 → 软删除 tb_resource_tag
5. 减少标签使用次数 → UPDATE tb_tag SET usage_count = usage_count - 1
```
---
## 四、修改现有表
### 4.1 tb_carrier运营商表
**新增字段**
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| carrier_type | VARCHAR(20) | 运营商类型CMCC/CUCC/CTCC/CBN | 'CMCC' |
| channel_name | VARCHAR(100) | 渠道名称(可自定义) | NULL |
| channel_code | VARCHAR(50) | 渠道编码(可自定义) | NULL |
**新增索引**
- `idx_carrier_type_channel`: `(carrier_type, channel_code) WHERE deleted_at IS NULL` (UNIQUE)
**设计说明**
- `carrier_type` 为固定枚举(四大运营商)
- `channel_name``channel_code` 可自定义(如"移动-广东渠道"
- 同一运营商可以有多个渠道
### 4.2 tb_order订单表
**新增字段**
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| wallet_payment_amount | BIGINT | 钱包支付金额(分) | 0 |
| online_payment_amount | BIGINT | 在线支付金额(分) | 0 |
**设计说明**
- 支持混合支付:`amount = wallet_payment_amount + online_payment_amount`
- `payment_method` 字段保留,用于标识主要支付方式
---
## 五、索引策略
### 5.1 唯一索引
所有唯一索引都必须包含 `WHERE deleted_at IS NULL` 条件,支持软删除后重复创建。
### 5.2 查询索引
- 钱包相关按用户ID、钱包ID、时间范围查询
- 换卡记录按卡ID、所有者、状态查询
- 标签按资源类型、资源ID、标签ID查询
### 5.3 索引维护
- 定期分析慢查询日志,优化索引
- 使用 `EXPLAIN ANALYZE` 验证查询计划
---
## 六、数据一致性保证
### 6.1 乐观锁(钱包)
```sql
UPDATE tb_wallet
SET balance = balance - 1000, version = version + 1
WHERE id = 123 AND version = 5;
```
如果 `version` 不匹配,表示并发冲突,需要重试。
### 6.2 事务保证
所有涉及多表操作的业务逻辑必须在事务中执行:
- 充值RechargeRecord + WalletTransaction + Wallet
- 换卡CardReplacementRecord + IotCard老卡、新卡+ PackageUsage
### 6.3 幂等性
- 充值订单:使用 `recharge_no` 唯一约束
- 钱包交易:使用 `request_id` Redis 锁
---
## 七、性能优化
### 7.1 缓存策略
| 数据 | Redis Key | 过期时间 | 说明 |
|------|-----------|----------|------|
| 钱包余额 | `wallet:balance:{wallet_id}` | 5分钟 | 高频查询缓存 |
| 热门标签 | `tag:cache:list` | 1小时 | 标签列表缓存 |
| 资源标签 | `resource:tags:{type}:{id}` | 30分钟 | 资源标签关联缓存 |
### 7.2 批量操作
- 批量查询钱包余额:使用 `IN` 查询
- 批量更新标签使用次数:使用 `CASE WHEN`
### 7.3 分页查询
所有列表查询必须分页:
- 默认 20 条/页
- 最大 100 条/页
---
## 八、安全性设计
### 8.1 权限控制
- 用户只能操作自己的钱包
- 代理可查询下级用户的钱包(通过数据权限过滤)
### 8.2 敏感信息保护
- 不在日志中记录完整的支付交易号
- 钱包余额变更必须记录操作人
### 8.3 风控
- 单次充值金额限制
- 单日充值次数限制
- 异常交易告警
---
## 九、扩展性考虑
### 9.1 多币种支持
- 钱包表已支持 `currency` 字段
- 未来可扩展美元、欧元等币种
### 9.2 多钱包类型
- 当前支持user用户、agent代理
- 未来可扩展enterprise企业、platform平台
### 9.3 标签扩展
- 当前支持设备、IoT卡、号卡
- 未来可扩展:订单、用户等资源类型
---
## 十、数据迁移说明
### 10.1 现有订单数据迁移
对于已存在的订单,需要初始化钱包支付字段:
```sql
UPDATE tb_order
SET wallet_payment_amount = amount
WHERE payment_method = 'wallet';
UPDATE tb_order
SET online_payment_amount = amount
WHERE payment_method IN ('online', 'carrier');
```
### 10.2 运营商数据迁移
根据现有 `carrier_code` 推断 `carrier_type`
```sql
UPDATE tb_carrier
SET carrier_type = 'CMCC'
WHERE carrier_code LIKE '%CMCC%' OR carrier_code LIKE '%移动%';
UPDATE tb_carrier
SET carrier_type = 'CUCC'
WHERE carrier_code LIKE '%CUCC%' OR carrier_code LIKE '%联通%';
UPDATE tb_carrier
SET carrier_type = 'CTCC'
WHERE carrier_code LIKE '%CTCC%' OR carrier_code LIKE '%电信%';
UPDATE tb_carrier
SET carrier_type = 'CBN'
WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
```
---
## 十一、测试要点
### 11.1 钱包系统测试
- [ ] 充值流程完整性测试
- [ ] 并发扣款乐观锁测试
- [ ] 余额不足校验测试
- [ ] 混合支付计算测试
- [ ] 冻结余额解冻测试
### 11.2 换卡系统测试
- [ ] 套餐快照完整性测试
- [ ] 审批流程测试
- [ ] 新旧卡状态变更测试
- [ ] 套餐转移测试
### 11.3 标签系统测试
- [ ] 标签创建唯一性测试
- [ ] 资源标签关联测试
- [ ] 使用次数统计测试
- [ ] 标签删除级联测试
---
## 十二、回滚方案
如果需要回滚,执行 `000007_add_wallet_transfer_tag_tables.down.sql`
1. 删除新增表
2. 删除新增字段
3. 删除新增索引
**注意**:回滚会丢失所有新表的数据,请谨慎操作。
---
## 十三、变更历史
### 2026-01-13: 钱包和标签系统多租户改造(迁移 #000008
**变更 ID**: `fix-wallet-tag-multi-tenant`
**变更原因**:
1. **钱包归属设计缺陷**
- 原设计:钱包绑定到 `user_id`(用户账号)
- 问题:个人客户的卡/设备转手时,钱包无法随资源流转
- 示例:个人客户 A 购买单卡充值 100 元,使用 50 元后转手给个人客户 BB 登录后看不到剩余 50 元余额
2. **标签系统缺少多租户隔离**
- 原设计:标签表无 `enterprise_id``shop_id` 字段
- 问题:企业 A 创建"测试标签"后,企业 B 无法创建同名标签(全局唯一冲突)
- 问题:企业 A 可以看到企业 B 的所有标签(数据泄露)
**核心变更**:
#### 1. 钱包表tb_wallet结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ❌ 删除 | `user_id` | 不再绑定用户账号 |
| ✅ 添加 | `resource_type` | 资源类型:`iot_card`(单卡)/ `device`(设备)/ `shop`(店铺) |
| ✅ 添加 | `resource_id` | 资源 ID |
**钱包归属新设计**
```
个人客户单卡钱包:
resource_type = 'iot_card'
resource_id = 卡ID
→ 卡转手时,新用户通过 ICCID 登录,钱包余额跟随卡流转
个人客户设备钱包(多卡共享):
resource_type = 'device'
resource_id = 设备ID
→ 设备中的 3-4 张卡共享一个钱包
代理商店铺钱包:
resource_type = 'shop'
resource_id = 店铺ID
→ 店铺内多个代理账号共享钱包,支持预存款采购
```
**索引变更**
- 删除:`idx_wallet_user_type_currency``idx_wallet_user`
- 添加:`idx_wallet_resource_type_currency`(唯一)、`idx_wallet_resource`
#### 2. 标签表tb_tag结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ✅ 添加 | `enterprise_id` | 企业 ID企业标签 |
| ✅ 添加 | `shop_id` | 店铺 ID店铺标签 |
**标签三级隔离模型**
| 标签类型 | enterprise_id | shop_id | 可见范围 | 唯一性 |
|---------|--------------|---------|---------|--------|
| 平台全局标签 | NULL | NULL | 所有用户 | 全局唯一 |
| 企业标签 | 企业ID | NULL | 仅该企业 | 企业内唯一 |
| 店铺标签 | NULL | 店铺ID | 该店铺及下级 | 店铺内唯一 |
**示例**
- 企业 A 创建"测试标签"`enterprise_id=5, shop_id=NULL`
- 企业 B 也可以创建"测试标签"`enterprise_id=8, shop_id=NULL`
- 两个标签相互隔离,互不可见
**索引变更**
- 删除:`idx_tag_name`(全局唯一)
- 添加:`idx_tag_enterprise_name`(企业内唯一)
- 添加:`idx_tag_shop_name`(店铺内唯一)
- 添加:`idx_tag_global_name`(全局标签唯一)
#### 3. 资源标签表tb_resource_tag结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ✅ 添加 | `enterprise_id` | 企业 ID从资源推断 |
| ✅ 添加 | `shop_id` | 店铺 ID从资源推断 |
**数据权限自动过滤**
通过 GORM Callback 自动注入过滤条件:
```go
// 代理用户查询标签
WHERE shop_id IN (当前店铺及下级店铺)
OR (enterprise_id IS NULL AND shop_id IS NULL)
// 企业用户查询标签
WHERE enterprise_id = 当前企业ID
OR (enterprise_id IS NULL AND shop_id IS NULL)
// 个人客户查询标签
WHERE enterprise_id IS NULL AND shop_id IS NULL
```
**数据迁移策略**
1. **代理钱包迁移**
```sql
UPDATE tb_wallet w
SET resource_type = 'shop', resource_id = a.shop_id
FROM tb_account a
WHERE w.user_id = a.id AND w.wallet_type = 'agent';
```
2. **用户钱包处理**
- 标记为 `PENDING_USER`,需要业务人员手动确认归属
3. **标签归属推断**
```sql
-- 从 creator 推断企业标签
UPDATE tb_tag t SET enterprise_id = a.enterprise_id
FROM tb_account a WHERE a.id = t.creator;
-- 从 creator 推断店铺标签
UPDATE tb_tag t SET shop_id = a.shop_id
FROM tb_account a WHERE a.id = t.creator AND t.enterprise_id IS NULL;
```
**迁移验证结果**
- ✅ 备份表已创建:`tb_wallet_backup`、`tb_tag_backup`、`tb_resource_tag_backup`
- ✅ 钱包表字段变更成功
- ✅ 标签表字段变更成功
- ✅ 资源标签表字段变更成功
- ✅ 迁移耗时300-960ms
- ✅ 回滚耗时500-960ms
- ✅ 可重复执行(已处理备份表冲突)
**参考文档**
- OpenSpec 变更提案:`openspec/changes/fix-wallet-tag-multi-tenant/proposal.md`
- 技术设计文档:`openspec/changes/fix-wallet-tag-multi-tenant/design.md`
- 实施清单:`openspec/changes/fix-wallet-tag-multi-tenant/tasks.md`
**测试覆盖**
- ✅ 9 个单元测试验证标签多租户过滤(`pkg/gorm/callback_test.go`
- ✅ 迁移和回滚功能验证通过
- ✅ OpenSpec 验证通过(`openspec validate --strict`
**回滚方案**
如需回滚,执行:
```bash
./scripts/migrate.sh down 1
```
回滚会从备份表恢复数据,但会丢失备份后的新增数据。

View File

@@ -1,292 +0,0 @@
# 钱包、换卡、标签系统 - 迁移验证报告
## 迁移执行信息
**执行时间**2025-01-13
**迁移版本**6 → 7
**迁移文件**`000007_add_wallet_transfer_tag_tables`
**执行耗时**282.5 毫秒
**执行状态**:✅ 成功
## 数据库信息
- **数据库类型**PostgreSQL
- **数据库名称**junhong_cmp_test
- **主机地址**cxd.whcxd.cn:16159
- **数据库用户**erp_pgsql
## 验证结果
### 1. 新增表验证
| 表名 | 状态 | 初始记录数 | 说明 |
|------|------|-----------|------|
| tb_wallet | ✅ 存在 | 0 | 钱包表 |
| tb_wallet_transaction | ✅ 存在 | 0 | 钱包交易记录表 |
| tb_recharge_record | ✅ 存在 | 0 | 充值记录表 |
| tb_card_replacement_record | ✅ 存在 | 0 | 换卡记录表 |
| tb_tag | ✅ 存在 | 0 | 标签表 |
| tb_resource_tag | ✅ 存在 | 0 | 资源-标签关联表 |
**总计**6 张新表全部创建成功 ✅
### 2. 修改表字段验证
#### tb_carrier 新增字段
| 字段名 | 数据类型 | 状态 | 说明 |
|--------|---------|------|------|
| carrier_type | character varying | ✅ | 运营商类型CMCC/CUCC/CTCC/CBN |
| channel_name | character varying | ✅ | 渠道名称 |
| channel_code | character varying | ✅ | 渠道编码 |
#### tb_order 新增字段
| 字段名 | 数据类型 | 状态 | 说明 |
|--------|---------|------|------|
| wallet_payment_amount | bigint | ✅ | 钱包支付金额(分) |
| online_payment_amount | bigint | ✅ | 在线支付金额(分) |
**总计**5 个新字段全部创建成功 ✅
### 3. 唯一索引验证
| 表名 | 索引名 | 状态 | 涉及字段 |
|------|--------|------|---------|
| tb_wallet | idx_wallet_user_type_currency | ✅ | (user_id, wallet_type, currency) WHERE deleted_at IS NULL |
| tb_recharge_record | idx_recharge_no | ✅ | (recharge_no) WHERE deleted_at IS NULL |
| tb_card_replacement_record | idx_card_replacement_no | ✅ | (replacement_no) WHERE deleted_at IS NULL |
| tb_tag | idx_tag_name | ✅ | (name) WHERE deleted_at IS NULL |
| tb_resource_tag | idx_resource_tag_unique | ✅ | (resource_type, resource_id, tag_id) WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_type_channel | ✅ | (carrier_type, channel_code) WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_code | ✅ | (carrier_code) WHERE deleted_at IS NULL已存在 |
**总计**7 个唯一索引全部创建成功 ✅
**验证要点**
- ✅ 所有新增唯一索引都包含 `WHERE deleted_at IS NULL` 条件
- ✅ 支持软删除后重复创建相同值的记录
### 4. 普通索引验证(部分)
| 表名 | 索引类型 | 数量 | 状态 |
|------|---------|------|------|
| tb_wallet | 查询索引 | 2 | ✅ |
| tb_wallet_transaction | 查询索引 | 3 | ✅ |
| tb_recharge_record | 查询索引 | 2 | ✅ |
| tb_card_replacement_record | 查询索引 | 5 | ✅ |
| tb_tag | 查询索引 | 1 | ✅ |
| tb_resource_tag | 查询索引 | 3 | ✅ |
**总计**:约 21 个索引全部创建成功 ✅
## 数据初始化验证
### tb_carrier 数据迁移
执行了现有数据的 `carrier_type` 字段初始化:
```sql
UPDATE tb_carrier SET carrier_type = 'CMCC' WHERE carrier_code LIKE '%CMCC%' OR carrier_code LIKE '%移动%';
UPDATE tb_carrier SET carrier_type = 'CUCC' WHERE carrier_code LIKE '%CUCC%' OR carrier_code LIKE '%联通%';
UPDATE tb_carrier SET carrier_type = 'CTCC' WHERE carrier_code LIKE '%CTCC%' OR carrier_code LIKE '%电信%';
UPDATE tb_carrier SET carrier_type = 'CBN' WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
```
**状态**:✅ 成功(根据 carrier_code 推断)
### tb_order 数据迁移
执行了现有订单的支付金额字段初始化:
```sql
UPDATE tb_order SET wallet_payment_amount = amount WHERE payment_method = 'wallet';
UPDATE tb_order SET online_payment_amount = amount WHERE payment_method IN ('online', 'carrier');
```
**状态**:✅ 成功(根据 payment_method 回填)
## 回滚测试
**回滚脚本**`000007_add_wallet_transfer_tag_tables.down.sql`
**回滚逻辑**
1. 删除 6 张新表tb_wallet, tb_wallet_transaction, tb_recharge_record, tb_card_replacement_record, tb_tag, tb_resource_tag
2. 删除 tb_carrier 新增字段carrier_type, channel_name, channel_code
3. 删除 tb_carrier 新增索引idx_carrier_type_channel
4. 删除 tb_order 新增字段wallet_payment_amount, online_payment_amount
**回滚测试**:暂未执行(生产环境不建议回滚)
**回滚风险**
- ⚠️ 回滚会丢失所有新表的数据
- ⚠️ tb_carrier 和 tb_order 的新增字段数据会丢失
## 性能评估
### 迁移执行时间
| 操作 | 耗时 | 说明 |
|------|------|------|
| 创建 6 张新表 | ~150ms | 包含索引创建 |
| 修改 2 张表(添加字段) | ~50ms | tb_carrier + tb_order |
| 创建索引 | ~50ms | 约 21 个索引 |
| 数据初始化 | ~30ms | tb_carrier + tb_order |
| **总计** | **282.5ms** | 符合预期 |
### 表大小估算(初期)
| 表名 | 当前记录数 | 预估增长 | 磁盘占用 |
|------|-----------|---------|---------|
| tb_wallet | 0 | 1万用户 × 1钱包 = 1万 | ~1MB |
| tb_wallet_transaction | 0 | 1万用户 × 100交易/年 = 100万 | ~100MB |
| tb_recharge_record | 0 | 1万用户 × 10充值/年 = 10万 | ~10MB |
| tb_card_replacement_record | 0 | 10万卡 × 1%换卡率 = 1000 | ~100KB |
| tb_tag | 0 | 固定 100 个标签 | ~10KB |
| tb_resource_tag | 0 | 10万资源 × 平均3标签 = 30万 | ~30MB |
**总计**(首年预估):~150MB
## 潜在问题排查
### 1. 乐观锁并发测试
**测试场景**100 并发更新同一钱包余额
**测试方法**
```go
// 模拟 100 个并发扣款
for i := 0; i < 100; i++ {
go func() {
wallet, _ := walletStore.GetByID(ctx, walletID)
result := db.Model(&Wallet{}).
Where("id = ? AND version = ?", walletID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", 100),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
// 并发冲突,需要重试
}
}()
}
```
**预期结果**:只有 1 个成功,其余 99 个触发乐观锁冲突
**实际测试**:待后续业务逻辑实现后测试
### 2. JSONB 查询性能
**测试查询**
```sql
-- 查询套餐快照中剩余天数 < 10 的换卡记录
SELECT * FROM tb_card_replacement_record
WHERE (package_snapshot->>'remaining_days')::int < 10;
```
**优化建议**
- 如果查询频繁,考虑添加 GIN 索引:
```sql
CREATE INDEX idx_package_snapshot ON tb_card_replacement_record
USING GIN (package_snapshot);
```
**实际测试**:待有数据后测试
### 3. 唯一索引性能
**测试方法**
```sql
-- 测试软删除后重复创建
INSERT INTO tb_tag (name, color) VALUES ('重点客户', '#FF5733');
UPDATE tb_tag SET deleted_at = NOW() WHERE name = '重点客户';
INSERT INTO tb_tag (name, color) VALUES ('重点客户', '#00FF00'); -- 应该成功
```
**预期结果**:第二次插入成功(唯一索引排除了 deleted_at IS NOT NULL 的记录)
**实际测试**:待后续业务逻辑实现后测试
## 监控建议
### 1. 表增长监控
```sql
-- 每日监控表大小
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE tablename IN ('tb_wallet', 'tb_wallet_transaction', 'tb_recharge_record',
'tb_card_replacement_record', 'tb_tag', 'tb_resource_tag')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### 2. 索引使用率监控
```sql
-- 检查未使用的索引
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE tablename IN ('tb_wallet', 'tb_wallet_transaction', 'tb_recharge_record',
'tb_card_replacement_record', 'tb_tag', 'tb_resource_tag')
ORDER BY idx_scan ASC;
```
### 3. 慢查询监控
在 PostgreSQL 配置中启用慢查询日志:
```ini
log_min_duration_statement = 200 # 记录超过 200ms 的查询
```
## 总结
### ✅ 成功项
- ✅ 6 张新表全部创建成功
- ✅ 5 个新字段全部添加成功
- ✅ 21+ 个索引全部创建成功
- ✅ 所有唯一索引包含软删除条件
- ✅ 现有数据迁移成功tb_carrier, tb_order
- ✅ 迁移执行时间符合预期282.5ms
- ✅ LSP 诊断全部通过
- ✅ OpenSpec 验证通过
### ⚠️ 待测试项
- ⏳ 乐观锁并发冲突测试
- ⏳ JSONB 查询性能测试
- ⏳ 软删除唯一索引测试
- ⏳ 混合支付业务逻辑测试
- ⏳ 回滚脚本测试(非必需)
### 📝 后续工作
1. **业务逻辑实现**
- WalletStore/Service/Handler钱包充值、扣款、退款
- CardReplacementStore/Service/Handler换卡申请、审批
- TagStore/Service/Handler标签管理
2. **测试**
- 单元测试Model 验证、常量验证)
- 集成测试(并发扣款、混合支付)
- 压力测试(高并发钱包操作)
3. **监控**
- 配置表大小监控
- 配置索引使用率监控
- 配置慢查询监控
---
**报告生成时间**2025-01-13
**报告状态**:✅ 迁移成功,所有验证通过

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,265 +0,0 @@
# 代理开放接口对接说明
## 1. 接入说明
代理开放接口挂载在 `/api/open/v1`,面向代理店铺第三方系统调用。接入方使用代理账号登录信息进行签名认证,通过认证后即可调用卡查询、套餐查询、预充值钱包查询和套餐购买接口。
请通过 HTTPS 调用开放接口。接入方不要在客户端日志、浏览器控制台或异常信息中记录账号密码、签名、随机串等敏感信息。
## 2. 认证 Header
每个请求必须携带以下 Header
| Header | 是什么 | 怎么来 | 示例 | 注意事项 |
| --- | --- | --- | --- | --- |
| `X-Agent-Account` | 代理账号标识 | 填写代理登录平台使用的用户名或手机号,由平台开通代理账号时提供 | `agent001``13800000000` | 必须与签名原文最后一行 `account` 完全一致 |
| `X-Agent-Password` | 代理账号当前登录密码 | 由代理账号持有人提供,和登录平台的密码一致 | `Passw0rd123` | 同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名;不要写入日志 |
| `X-Agent-Timestamp` | 请求发起时间 | 调用方在每次请求前生成 | `1715400000``1715400000000``2024-05-11T10:00:00+08:00` | 支持 Unix 秒、Unix 毫秒、RFC3339必须与签名原文 `timestamp` 行完全一致;客户端服务器时间需同步 |
| `X-Agent-Nonce` | 请求随机串 | 调用方每次请求自行生成,不需要平台提前分配 | `n-1715400000-001` 或 UUID | 同一账号 5 分钟内不可重复,重复会被判定为重放请求 |
| `X-Agent-Sign` | 请求签名 | 调用方按下方签名规则实时计算 | `5f2d...` | 不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化 |
Header 生成顺序建议:
1. 先确定 `X-Agent-Account``X-Agent-Password`
2. 每次请求生成新的 `X-Agent-Timestamp``X-Agent-Nonce`
3. 按下方规则拼出签名原文。
4.`X-Agent-Password` 对签名原文做 HMAC-SHA256得到 `X-Agent-Sign`
5. 带上 5 个 Header 发起请求。
签名原文:
```text
METHOD
PATH
canonical_query
body_sha256
timestamp
nonce
account
```
拼接规则:
- 使用换行符 `\n` 连接 7 行,最后一行后不追加换行。
- `METHOD` 使用大写 HTTP 方法,例如 `GET``POST`
- `PATH` 只取请求路径,例如 `/api/open/v1/cards/status`,不包含域名和 query。
- `canonical_query` 为 query 参数规范化结果:排除 `sign` 字段参数名按升序排列同名多值按值升序排列key 和 value 使用 URL QueryEscape 编码后按 `key=value` 拼接;多个参数用 `&` 连接;无 query 时为空字符串。
- `body_sha256` 为原始请求 body 字节的 SHA256 小写十六进制值。GET 或空 body 使用空字符串的 SHA256`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
- POST JSON 请求必须先确定最终发送的 body 字符串,再用同一个字符串计算 `body_sha256` 并发送请求;签名后不要再重新格式化 JSON、调整字段顺序或改变空格。
- `timestamp``nonce``account` 必须与 Header 中的值完全一致。
签名值为:
```text
lowercase_hex(HMAC-SHA256(password, sign_payload))
```
其中 `password``X-Agent-Password` 的原始值,`sign_payload` 为上面 7 行拼接后的字符串。
### 2.1 GET 签名示例
请求:
```text
GET /api/open/v1/cards/status?card_no=89860000000000000001
X-Agent-Account: agent001
X-Agent-Password: Passw0rd123
X-Agent-Timestamp: 1715400000
X-Agent-Nonce: n-1715400000-001
```
签名原文:
```text
GET
/api/open/v1/cards/status
card_no=89860000000000000001
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1715400000
n-1715400000-001
agent001
```
Node.js 示例:
```javascript
const crypto = require("crypto");
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function queryEscape(value) {
return new URLSearchParams({ v: String(value) }).toString().slice(2);
}
function canonicalQuery(params) {
return Object.entries(params)
.filter(([key]) => key.toLowerCase() !== "sign")
.flatMap(([key, value]) => {
const values = Array.isArray(value) ? value : [value];
return values.sort().map((item) => [key, item]);
})
.sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey === bKey) return String(aValue).localeCompare(String(bValue));
return aKey.localeCompare(bKey);
})
.map(([key, value]) => `${queryEscape(key)}=${queryEscape(value)}`)
.join("&");
}
function sign({ method, path, query, body, timestamp, nonce, account, password }) {
const payload = [
method.toUpperCase(),
path,
canonicalQuery(query),
sha256Hex(body || ""),
timestamp,
nonce,
account,
].join("\n");
return crypto.createHmac("sha256", password).update(payload).digest("hex");
}
```
完整 GET 调用示例:
```javascript
const https = require("https");
const account = "agent001";
const password = "Passw0rd123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = `nonce-${Date.now()}`;
const method = "GET";
const path = "/api/open/v1/cards/status";
const query = { card_no: "89860000000000000001" };
const body = "";
const requestQuery = canonicalQuery(query);
const requestPath = `${path}?${requestQuery}`;
const signature = sign({ method, path, query, body, timestamp, nonce, account, password });
const req = https.request(
{
hostname: "open.example.com",
path: requestPath,
method,
headers: {
"X-Agent-Account": account,
"X-Agent-Password": password,
"X-Agent-Timestamp": timestamp,
"X-Agent-Nonce": nonce,
"X-Agent-Sign": signature,
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
}
);
req.end();
```
完整 POST 调用示例:
```javascript
const https = require("https");
const account = "agent001";
const password = "Passw0rd123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = `nonce-${Date.now()}`;
const method = "POST";
const path = "/api/open/v1/wallet/package-orders";
const query = {};
const body = JSON.stringify({
card_nos: ["89860000000000000001", "89860000000000000002"],
package_code: "PKG001",
});
const signature = sign({ method, path, query, body, timestamp, nonce, account, password });
const req = https.request(
{
hostname: "open.example.com",
path,
method,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"X-Agent-Account": account,
"X-Agent-Password": password,
"X-Agent-Timestamp": timestamp,
"X-Agent-Nonce": nonce,
"X-Agent-Sign": signature,
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
}
);
req.write(body);
req.end();
```
curl 调用示例:
```bash
curl -G "https://open.example.com/api/open/v1/cards/status" \
--data-urlencode "card_no=89860000000000000001" \
-H "X-Agent-Account: agent001" \
-H "X-Agent-Password: Passw0rd123" \
-H "X-Agent-Timestamp: 1715400000" \
-H "X-Agent-Nonce: n-1715400000-001" \
-H "X-Agent-Sign: 上面代码计算出的签名"
```
## 3. 接口列表
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/open/v1/cards/traffic` | 按 `card_no` 查询单卡流量和套餐 |
| `GET` | `/api/open/v1/cards/status` | 按 `card_no` 查询单卡正常/停机状态 |
| `GET` | `/api/open/v1/cards/realname-status` | 按 `card_no` 查询实名状态 |
| `GET` | `/api/open/v1/packages` | 查询可购买套餐列表 |
| `POST` | `/api/open/v1/wallet/package-orders` | 使用预充值钱包给多张卡购买同一个套餐 |
| `GET` | `/api/open/v1/wallet/balance` | 查询预充值钱包余额 |
| `GET` | `/api/open/v1/wallet/transactions` | 查询预充值钱包流水 |
`card_no` 支持 ICCID、虚拟号、MSISDN。开放接口只支持单卡设备标识会返回参数错误。
## 4. 字段口径
卡流量字段:
- `total_flow_mb`:套餐总流量,单位 MB。
- `used_flow_mb`:已用流量,单位 MB。
- `remaining_flow_mb`:剩余流量,单位 MB。
套餐列表字段:
- `cost_price`:套餐结算价,单位分。
- `retail_price`:套餐销售价,单位分。
- `total_flow_mb`:套餐总流量,单位 MB。
- `valid_days`:套餐有效天数。
钱包购买接口一次只允许一个 `package_code``card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。任一卡购买失败时,外层 `code`/`msg` 返回首条失败卡的错误码和原因,同时 `data.failed_cards` 保留失败明细,`data.orders` 保留已成功订单;接入方应以 `success_count``failed_count``orders``failed_cards` 判断单卡结果,避免整批重试造成重复下单。
## 5. 错误码
| 错误码 | 说明 |
| --- | --- |
| `1048` | 开放接口认证失败 |
| `1049` | 开放接口签名无效 |
| `1190` | 开放接口时间戳无效 |
| `1191` | 重复请求,请勿重放 |
| `1005` | 无权限操作该资源或资源不存在 |
参数校验失败统一返回 `1001`。认证类失败只返回统一错误码和消息。

View File

@@ -1,227 +0,0 @@
# 代理预充值功能
## 功能概述
代理商(店铺)余额钱包的在线充值系统,支持微信在线支付和线下转账两种充值方式,具备完整的 Service/Handler/回调处理链路。充值仅针对余额钱包(`wallet_type=main`),佣金钱包通过分佣自动入账。
### 背景与动机
原有 `tb_agent_recharge_record` 表和 Store 层骨架已存在,但缺少 Service 层和 Handler 层,无法通过 API 发起充值。本次补全完整实现,并集成至支付配置管理体系(按 `payment_config_id` 动态路由至微信直连或富友通道)。
## 核心流程
### 在线充值流程(微信)
```
代理/平台 → POST /api/admin/agent-recharges
├─ 验证权限:代理只能充自己店铺,平台可指定任意店铺
├─ 验证金额范围1 分~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 | 是 | 充值金额(单位:分),范围 1~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 = 1 // 最小充值1 分
AgentRechargeMaxAmount = 100000000 // 最大充值100 万元(单位:分)
```
## 已知限制(留桩)
**客户端支付发起未实现**:在线充值(`payment_method=wechat`)创建订单成功后,前端获取支付参数的接口本次未实现。充值回调处理已完整实现——等支付发起改造完成后,完整的充值支付闭环即可联通。

View File

@@ -1,40 +0,0 @@
# 领域文档
工程类 Skill`improve-codebase-architecture``diagnose``tdd` 等)在探索代码库前应如何消费本仓库的领域文档。
## 探索前请先阅读
- 根目录的 **`CONTEXT.md`**(单 Context 布局)
- 根目录的 **`docs/adr/`** —— 阅读与当前改动相关的 ADR
如果这些文件还不存在,**直接跳过,不要提示缺失,也不要主动建议现在就创建它们**。这两个文件由 `/grill-with-docs` 在术语或决定真正确定下来时按需创建。
## 与 openspec/ 的关系
本仓库已经使用 `openspec/``openspec/specs/` 存放规范、`openspec/changes/` 存放变更提案)作为正式的提案与规范工作流,详见根目录 `CLAUDE.md` 的「OpenSpec 工作流」一节。`CONTEXT.md` / `docs/adr/` 是补充性质的轻量级领域词汇表和架构决定记录,不会替代 openspec 流程:
- 涉及接口/数据库等实际变更 → 走 openspec 提案
- 记录领域词汇、命名约定、历史架构权衡 → 写入 `CONTEXT.md` / `docs/adr/`
## 文件结构(单 Context
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-xxx.md
│ └── 0002-xxx.md
└── openspec/
```
## 使用词汇表中的术语
当输出内容涉及某个领域概念时issue 标题、重构提案、假设、测试名称),使用 `CONTEXT.md` 中定义的术语,不要漂移到词汇表明确避免使用的同义词。
如果你需要的概念还不在词汇表中,这是一个信号——可能是你在发明项目并未使用的语言(应重新考虑),也可能是真实存在的空白(记录下来留给 `/grill-with-docs`)。
## 标记与 ADR 的冲突
如果你的输出与某条已有 ADR 冲突,必须显式指出,而不是悄悄覆盖:
> 与 ADR-0007事件溯源订单冲突——但值得重新讨论原因是……

View File

@@ -1,19 +0,0 @@
# Issue 追踪方式:本地 Markdown
本仓库的 Issue 和 PRD 以 Markdown 文件形式存放在 `.scratch/` 目录下。
## 约定
- 每个功能一个目录:`.scratch/<feature-slug>/`
- PRD 文件:`.scratch/<feature-slug>/PRD.md`
- 实现类 Issue`.scratch/<feature-slug>/issues/<NN>-<slug>.md`,从 `01` 开始编号
- Triage 状态记录在每个 Issue 文件顶部的 `Status:` 行(状态字符串见 `triage-labels.md`
- 评论和讨论记录追加在文件底部的 `## Comments` 标题下
## 当某个 Skill 说"发布到 issue tracker"时
`.scratch/<feature-slug>/` 下创建一个新文件(目录不存在则先创建)。
## 当某个 Skill 说"获取对应的 ticket"时
读取所引用路径对应的文件。用户通常会直接给出文件路径或 Issue 编号。

View File

@@ -1,15 +0,0 @@
# Triage 标签
这些 Skill 用五个统一的 Triage 角色来表达状态。本文件将这些角色映射到本仓库实际使用的标签字符串。
| Skill 中的角色 | 本仓库使用的标签 | 含义 |
| ----------------- | ------------------ | -------------------------------- |
| `needs-triage` | `needs-triage` | 需要维护者评估该 issue |
| `needs-info` | `needs-info` | 等待提交者补充信息 |
| `ready-for-agent` | `ready-for-agent` | 已完整描述AFK Agent 可直接处理 |
| `ready-for-human` | `ready-for-human` | 需要人工实现 |
| `wontfix` | `wontfix` | 不予处理 |
由于本仓库使用本地 Markdown 作为 issue tracker这些标签以每个 issue 文件顶部的 `Status: <label>` 行体现(见 `issue-tracker.md`)。
当某个 Skill 提到某个角色时(例如"打上 AFK-ready 的 triage 标签"),使用本表中对应的标签字符串。

View File

@@ -1,265 +0,0 @@
# AI 助手 DTO 规范指引更新
## 📋 更新概览
**更新日期**: 2026-01-20
**目的**: 确保未来的 AI 助手在创建/修改 DTO 时自动遵循规范
---
## ✅ 已更新的文件
### 1. `AGENTS.md`(第 96 行)
添加了完整的 **DTO 规范(重要!)** 章节,包括:
- ✅ Description 标签规范
- ✅ 枚举字段必须列出所有可能值(中文)
- ✅ 验证标签与 OpenAPI 标签一致
- ✅ 请求参数类型标签
- ✅ 响应 DTO 完整性
-**AI 助手必须执行的检查清单**
- ✅ 常见枚举字段标准值
### 2. `CLAUDE.md`(第 99 行)
添加了 **DTO 规范API 文档生成基础)** 章节,包括:
- ✅ 所有字段必须使用 `description` 标签
- ✅ 枚举字段必须列出所有可能值(使用中文)
- ✅ validate 标签必须与 OpenAPI 标签一致
- ✅ Query 和 Path 参数必须添加对应标签
-**AI 助手在创建/修改 DTO 后必须执行的检查**
---
## 🎯 AI 助手自动检查清单
未来的 AI 助手在创建或修改任何 DTO 文件后,会**自动执行**以下检查:
### 必须执行的检查(来自 AGENTS.md
```markdown
1. ✅ 检查所有字段是否有 `description` 标签
2. ✅ 检查枚举字段是否列出了所有可能值(中文)
3. ✅ 检查状态字段是否说明了 0 和 1 的含义
4. ✅ 检查 validate 标签与 OpenAPI 标签是否一致
5. ✅ 检查是否禁止使用行内注释替代 description
6. ✅ 检查枚举值是否使用中文而非英文
7. ✅ 重新生成 OpenAPI 文档验证:`go run cmd/gendocs/main.go`
```
### 详细检查清单位置
- **完整清单**: `docs/code-review-checklist.md`
- **在线引用**: 两个文件都包含此引用
---
## 📚 标准枚举值参考
两个文件都包含了常见枚举字段的标准值:
```go
// 用户类型
description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"
// 角色类型
description:"角色类型 (1:平台角色, 2:客户角色)"
// 权限类型
description:"权限类型 (1:菜单, 2:按钮)"
// 适用端口
description:"适用端口 (all:全部, web:Web后台, h5:H5端)"
// 状态
description:"状态 (0:禁用, 1:启用)"
// 店铺层级
description:"店铺层级 (1-7级)"
```
---
## 🔄 工作流程
### 1. AI 助手创建/修改 DTO 文件
```go
type CreateAccountRequest struct {
Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"用户名"`
UserType int `json:"user_type" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
Status int `json:"status" validate:"required,min=0,max=1" required:"true" minimum:"0" maximum:"1" description:"状态 (0:禁用, 1:启用)"`
}
```
### 2. AI 助手自动执行检查
根据 `AGENTS.md``CLAUDE.md` 中的指引AI 会:
- ✅ 验证所有字段都有 `description` 标签
- ✅ 验证枚举字段包含完整的中文说明
- ✅ 验证 validate 和 OpenAPI 标签一致性
- ✅ 执行 `go run cmd/gendocs/main.go` 生成文档
### 3. AI 助手验证生成的文档
```bash
# 检查用户类型字段
grep -A 3 "user_type:" docs/admin-openapi.yaml
# 输出应该包含完整的中文枚举说明:
# description: 用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)
```
### 4. AI 助手报告检查结果
AI 会主动告知用户:
- ✅ 所有 DTO 字段都符合规范
- ✅ OpenAPI 文档已重新生成
- ✅ 所有枚举值使用中文说明
---
## 📊 对比:更新前 vs 更新后
### 更新前 ❌
**AI 助手行为**
- ❌ 可能忘记添加 `description` 标签
- ❌ 可能使用英文枚举值
- ❌ 可能使用行内注释而非 `description`
- ❌ 不会自动验证文档生成
**开发者需要**
- 🔧 手动检查每个 DTO 文件
- 🔧 手动修复不符合规范的字段
- 🔧 手动重新生成文档
### 更新后 ✅
**AI 助手行为**
- ✅ **自动**为所有字段添加 `description` 标签
- ✅ **自动**使用中文枚举值
- ✅ **自动**确保 validate 和 OpenAPI 标签一致
- ✅ **自动**重新生成并验证文档
**开发者只需**
- 👀 Code Review 时参考 `docs/code-review-checklist.md`
- ✅ 信任 AI 已按规范执行
---
## 🎓 示例AI 助手的标准操作流程
### 用户请求
```
"请创建一个企业管理的 DTO包括创建、更新和列表查询"
```
### AI 助手执行步骤
1. **读取规范**(自动)
-`AGENTS.md``CLAUDE.md` 读取 DTO 规范
- 了解必须遵循的标签格式
2. **创建 DTO**(遵循规范)
```go
type CreateEnterpriseRequest struct {
EnterpriseName string `json:"enterprise_name" validate:"required" required:"true" description:"企业名称"`
Status int `json:"status" validate:"required,min=0,max=1" required:"true" description:"状态 (0:禁用, 1:启用)"`
}
```
3. **执行检查**(自动)
- ✅ 所有字段都有 `description`
- ✅ Status 字段说明了 0/1 含义
- ✅ validate 和 OpenAPI 标签一致
4. **生成文档**(自动)
```bash
go run cmd/gendocs/main.go
```
5. **验证文档**(自动)
```bash
grep "状态" docs/admin-openapi.yaml
# 确认输出包含: description: 状态 (0:禁用, 1:启用)
```
6. **报告完成**(主动告知用户)
```
✅ 已创建 enterprise_dto.go
✅ 所有字段都符合 DTO 规范
✅ OpenAPI 文档已更新docs/admin-openapi.yaml
✅ 所有枚举值使用中文说明
```
---
## 🔗 相关文档
| 文档 | 路径 | 说明 |
|------|------|------|
| AI 开发规范 | `AGENTS.md` | 包含 DTO 规范和检查清单(第 96 行) |
| Claude 规范 | `CLAUDE.md` | 包含 DTO 规范和检查清单(第 99 行) |
| Code Review 清单 | `docs/code-review-checklist.md` | 完整的检查清单 |
| DTO 改进总结 | `docs/dto-improvement-summary.md` | 本次修复的详细记录 |
| OpenAPI 文档 | `docs/admin-openapi.yaml` | 自动生成的 API 文档 |
---
## 🎯 预期效果
### 对未来 AI 助手的影响
1. ✅ **自动合规**:所有新建的 DTO 自动符合规范
2. ✅ **减少返工**:不再需要手动修复不规范的代码
3. ✅ **文档同步**:代码即文档,文档永远是最新的
4. ✅ **一致性**:所有枚举字段使用统一的中文说明格式
### 对开发团队的影响
1. ✅ **Code Review 简化**:只需对照 `docs/code-review-checklist.md` 快速检查
2. ✅ **前端友好**API 文档清晰,前端开发效率提升
3. ✅ **新人友好**:规范明确,容易上手
4. ✅ **维护成本降低**:规范统一,代码可读性强
---
## 📈 统计数据
### 文件更新统计
| 文件 | 添加行数 | 章节 | 位置 |
|------|---------|------|------|
| `AGENTS.md` | ~120 行 | DTO 规范(重要!) | 第 96 行 |
| `CLAUDE.md` | ~30 行 | DTO 规范API 文档生成基础) | 第 99 行 |
### 覆盖的检查项
- ✅ **7 个自动检查项**AGENTS.md
- ✅ **5 个核心规范点**CLAUDE.md
- ✅ **6 种标准枚举类型**
---
## 🚀 下一步
### 立即生效
- ✅ 未来所有与此项目交互的 AI 助手都会自动读取这些规范
- ✅ 新建或修改 DTO 时会自动执行检查
- ✅ 文档生成和验证自动化
### 团队协作
- 📝 在 Code Review 时参考 `docs/code-review-checklist.md`
- 📝 在 PR 模板中添加"DTO 规范检查"项
- 📝 定期运行 `go run cmd/gendocs/main.go` 确保文档最新
---
**最后更新**: 2026-01-20
**维护者**: 开发团队

View File

@@ -1,155 +0,0 @@
# 支付宝支付链接前端对接说明
## 1. 对接范围
本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK后端会返回已签名的支付宝 WAP 支付链接。
覆盖场景:
1. 套餐订单支付
2. 钱包充值
3. 强制充值购包场景
微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`
具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。
## 2. 支付方式取值
前端统一使用 `payment_method` 区分支付方式:
| 值 | 含义 | 前端处理 |
| --- | --- | --- |
| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 |
| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 |
| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 |
注意事项:
- `app_type` 只在微信支付时需要。
- 支付宝支付时不需要传 `app_type`
- 支付宝支付返回的是支付链接,不是 JSAPI 参数。
## 3. 前端需要关注的响应字段
当选择支付宝支付时,接口响应中会返回 `payment_link`
```json
{
"payment_link": {
"payment_no": "支付单号",
"qr_link": "支付宝支付链接,可用于生成二维码",
"copy_link": "支付宝支付链接,可复制或直接打开",
"pay_expire_at": "支付链接过期时间RFC3339 格式"
}
}
```
字段说明:
| 字段 | 用途 |
| --- | --- |
| `payment_no` | 支付单号,问题排查 / 客服查询使用 |
| `qr_link` | 前端可用该链接生成二维码 |
| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 |
| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 |
## 4. 推荐交互流程
### 4.1 支付宝支付流程
前端拿到 `payment_link` 后:
1. H5 / 浏览器场景:直接跳转 `copy_link`
2. PC 场景:使用 `qr_link` 生成二维码。
3. App / WebView 场景:在外部浏览器或支付宝可识别环境中打开 `copy_link`
支付成功后:
- 不要只依赖支付宝返回页判断支付成功。
- 最终支付状态以服务端订单 / 充值记录状态为准。
- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。
## 5. 各业务场景说明
### 5.1 套餐订单支付
普通套餐下单后,如果需要支付,前端调用订单支付接口,并传:
```json
{
"payment_method": "alipay"
}
```
支付宝支付响应示例:
```json
{
"payment_method": "alipay",
"payment_link": {
"payment_no": "...",
"qr_link": "...",
"copy_link": "...",
"pay_expire_at": "..."
}
}
```
前端根据 `payment_link` 发起支付宝支付。
### 5.2 钱包充值
创建钱包充值单时传:
```json
{
"payment_method": "alipay"
}
```
支付宝场景返回 `payment_link`;微信场景返回 `pay_config`
### 5.3 强制充值购包
创建订单时,如果后端判断需要强制充值,前端可指定:
```json
{
"payment_method": "alipay"
}
```
如果是支付宝强充,响应中会返回:
```json
{
"order_type": "recharge",
"recharge": {},
"payment_link": {}
}
```
前端按支付宝支付链接流程处理。
## 6. 前端展示建议
支付宝支付页面建议展示:
- 支付金额
- 支付宝二维码或“去支付宝支付”按钮
- 支付链接过期倒计时
- “我已完成支付”按钮
- “重新获取支付链接”按钮
点击“我已完成支付”时,不直接判定成功,应查询服务端状态。
## 7. 注意事项
1. 金额单位仍然是 **分**
2. 支付宝支付不需要 `app_type`
3. `pay_config` 只用于微信支付。
4. `payment_link` 只用于支付宝支付。
5. 支付状态以服务端为准,不以前端跳转结果为准。
6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。
7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。

View File

@@ -1,380 +0,0 @@
# API 文档自动生成更新总结
## 📝 更新概述
为了将 B 端认证系统的所有端点包含在自动生成的 OpenAPI 文档中,我们进行了以下更新:
---
## 🔧 更新内容
### 1. **路由注册函数更新**
**文件**:
- `internal/routes/admin.go`
- `internal/routes/h5.go`
**改动**:将认证端点从直接注册改为使用 `Register` 辅助函数,以便生成文档
**修改前**:
```go
router.Post("/login", h.Login)
router.Post("/refresh-token", h.RefreshToken)
```
**修改后**:
```go
Register(router, doc, basePath, "POST", "/login", h.Login, RouteSpec{
Summary: "后台登录",
Tags: []string{"认证"},
Input: new(model.LoginRequest),
Output: new(model.LoginResponse),
})
```
**新增端点文档**(共 10 个):
-`POST /api/admin/login` - 后台登录
-`POST /api/admin/logout` - 登出
-`POST /api/admin/refresh-token` - 刷新 Token
-`GET /api/admin/me` - 获取当前用户信息
-`PUT /api/admin/password` - 修改密码
-`POST /api/h5/login` - H5 登录
-`POST /api/h5/logout` - 登出
-`POST /api/h5/refresh-token` - 刷新 Token
-`GET /api/h5/me` - 获取当前用户信息
-`PUT /api/h5/password` - 修改密码
---
### 2. **OpenAPI 生成器增强**
**文件**: `pkg/openapi/generator.go`
**新增功能**: 自动添加 Bearer Token 认证定义
**新增代码**:
```go
// addBearerAuth 添加 Bearer Token 认证定义
func (g *Generator) addBearerAuth() {
bearerFormat := "JWT"
g.Reflector.Spec.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem(
"BearerAuth",
openapi3.SecuritySchemeOrRef{
SecurityScheme: &openapi3.SecurityScheme{
HTTPSecurityScheme: &openapi3.HTTPSecurityScheme{
Scheme: "bearer",
BearerFormat: &bearerFormat,
},
},
},
)
}
```
**效果**: 在 `openapi.yaml` 中自动生成:
```yaml
components:
securitySchemes:
BearerAuth:
bearerFormat: JWT
scheme: bearer
type: http
```
---
### 3. **文档生成脚本更新**
**文件**: `cmd/api/docs.go`
**新增 Handler**:
-`AdminAuth` - 后台认证 Handler
-`H5Auth` - H5 认证 Handler
-`Shop` - 店铺管理 Handler
-`ShopAccount` - 店铺账号 Handler
**修改前**(只有 3 个 Handler:
```go
accHandler := admin.NewAccountHandler(nil)
roleHandler := admin.NewRoleHandler(nil)
permHandler := admin.NewPermissionHandler(nil)
handlers := &bootstrap.Handlers{
Account: accHandler,
Role: roleHandler,
Permission: permHandler,
}
```
**修改后**7 个 Handler:
```go
adminAuthHandler := admin.NewAuthHandler(nil, nil)
h5AuthHandler := h5.NewAuthHandler(nil, nil)
accHandler := admin.NewAccountHandler(nil)
roleHandler := admin.NewRoleHandler(nil)
permHandler := admin.NewPermissionHandler(nil)
shopHandler := admin.NewShopHandler(nil)
shopAccHandler := admin.NewShopAccountHandler(nil)
handlers := &bootstrap.Handlers{
AdminAuth: adminAuthHandler,
H5Auth: h5AuthHandler,
Account: accHandler,
Role: roleHandler,
Permission: permHandler,
Shop: shopHandler,
ShopAccount: shopAccHandler,
}
```
**新增路由注册**:
```go
// 注册后台路由到文档生成器
adminGroup := app.Group("/api/admin")
routes.RegisterAdminRoutes(adminGroup, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/admin")
// 注册 H5 路由到文档生成器
h5Group := app.Group("/api/h5")
routes.RegisterH5Routes(h5Group, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/h5")
```
---
## 📊 生成的文档内容
### 认证端点示例
#### 1. 后台登录
```yaml
/api/admin/login:
post:
summary: 后台登录
tags:
- 认证
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ModelLoginRequest'
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ModelLoginResponse'
```
#### 2. 获取当前用户
```yaml
/api/admin/me:
get:
summary: 获取当前用户信息
tags:
- 认证
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ModelUserInfo'
```
### 请求/响应模型
自动生成的数据模型包括:
-`ModelLoginRequest` - 登录请求
-`ModelLoginResponse` - 登录响应
-`ModelRefreshTokenRequest` - 刷新 Token 请求
-`ModelRefreshTokenResponse` - 刷新 Token 响应
-`ModelChangePasswordRequest` - 修改密码请求
-`ModelUserInfo` - 用户信息
---
## 🎯 如何使用生成的文档
### 查看文档
生成的 OpenAPI 文档位于项目根目录:
```bash
cat openapi.yaml
```
### 使用 Swagger UI 查看
1. **在线工具**:
- 访问 https://editor.swagger.io/
-`openapi.yaml` 内容粘贴进去
2. **本地启动 Swagger UI**:
```bash
docker run -p 8080:8080 \
-e SWAGGER_JSON=/openapi.yaml \
-v $(pwd)/openapi.yaml:/openapi.yaml \
swaggerapi/swagger-ui
```
然后访问 http://localhost:8080
### 导入到 Postman
1. 打开 Postman
2. 点击 "Import"
3. 选择 `openapi.yaml` 文件
4. 自动生成所有 API 请求集合
---
## 🔄 文档生成流程
### 自动生成
文档在每次启动 API 服务时自动生成:
```go
// cmd/api/main.go
func main() {
// ...
// 12. 生成 OpenAPI 文档
generateOpenAPIDocs("./openapi.yaml", appLogger)
// 13. 启动服务器
startServer(app, cfg, appLogger, cancelWatch)
}
```
### 手动生成
如果只想生成文档而不启动服务:
```bash
# 编译
go build -o /tmp/api_docs ./cmd/api/
# 运行并立即停止(文档会在启动时生成)
timeout 3s /tmp/api_docs || true
# 查看生成的文档
cat openapi.yaml
```
---
## 🎨 文档分类Tags
生成的文档按以下标签分类:
- **认证** - 所有认证相关端点(登录、登出、刷新等)
- **H5 认证** - H5 端认证端点
- **账号相关** - 账号管理CRUD、角色分配等
- **角色** - 角色管理
- **权限** - 权限管理
- **店铺** - 店铺管理
- **店铺账号** - 店铺账号管理
---
## ✅ 验证清单
- [x] 所有认证端点已包含在文档中
- [x] Bearer Token 认证方式已定义
- [x] 请求/响应模型完整
- [x] 端点描述清晰(中文 Summary
- [x] 端点按标签正确分类
- [x] 后台和 H5 端点都已包含
---
## 📌 注意事项
### 1. **文档与实际路由同步**
由于使用了统一的 `Register` 函数,所有注册的路由都会自动出现在文档中。
确保不会出现文档与实际路由不一致的情况。
### 2. **nil 依赖 Handler**
文档生成时使用 `nil` 依赖创建 Handler
```go
adminAuthHandler := admin.NewAuthHandler(nil, nil)
```
这是安全的,因为文档生成只需要路由结构,不会实际执行 Handler 逻辑。
### 3. **安全认证标记**
目前文档中的 `BearerAuth` 安全方案已定义,但未自动标记哪些端点需要认证。
**未来改进**(可选):
可以在 `RouteSpec` 中添加 `RequireAuth bool` 字段,自动为需要认证的端点添加:
```yaml
security:
- BearerAuth: []
```
---
## 🔮 后续可能的改进
### 1. **错误响应文档**
当前只定义了 200 成功响应,可以添加错误响应:
```go
// 在 RouteSpec 中添加
type RouteSpec struct {
Summary string
Tags []string
Input interface{}
Output interface{}
ErrorOutput interface{} // 新增
}
```
### 2. **安全端点标记**
为需要认证的端点自动添加安全要求:
```go
// 在 AddOperation 中添加逻辑
if spec.RequireAuth {
op.Security = []openapi3.SecurityRequirement{
{"BearerAuth": []string{}},
}
}
```
### 3. **示例值**
为请求/响应添加示例值,便于前端开发者理解:
```yaml
examples:
LoginExample:
value:
username: "admin"
password: "Admin@123456"
```
---
## 📖 相关文档
- [API 文档](docs/api/auth.md) - 手写的详细 API 文档
- [使用指南](docs/auth-usage-guide.md) - 认证系统使用指南
- [架构说明](docs/auth-architecture.md) - 认证系统架构设计
---
## 总结
通过这次更新,我们实现了:
1.**认证端点完整性** - 所有 10 个认证端点都已包含
2.**安全定义** - Bearer Token 认证方式已定义
3.**自动同步** - 路由与文档自动保持一致
4.**易于维护** - 使用统一的 Register 函数
**OpenAPI 文档现在已经完整可以直接用于前端开发、API 测试和文档展示!** 🎉

View File

@@ -1,700 +0,0 @@
# API 文档生成规范
**版本**: 1.1
**最后更新**: 2026-01-24
## 目录
- [核心原则](#核心原则)
- [新增 Handler 检查清单](#新增-handler-检查清单)
- [路由注册规范](#路由注册规范)
- [DTO 规范](#dto-规范)
- [文档生成流程](#文档生成流程)
- [常见问题](#常见问题)
---
## 核心原则
### ✅ 强制要求
**所有 HTTP 接口必须使用统一的 `Register()` 函数注册,以确保自动加入 OpenAPI 文档生成。**
```go
// ✅ 正确:使用 Register() 函数
Register(router, doc, basePath, "POST", "/path", handler.Method, RouteSpec{
Summary: "操作说明",
Tags: []string{"分类"},
Input: new(model.RequestDTO),
Output: new(model.ResponseDTO),
Auth: true,
})
// ❌ 错误:直接注册(不会生成文档)
router.Post("/path", handler.Method)
```
### 为什么这样做?
1. **文档自动同步**:代码即文档,避免文档与实现脱节
2. **前后端协作**:生成标准 OpenAPI 规范,前端可直接导入
3. **API 测试**Swagger UI / Postman 可直接使用
4. **类型安全**:通过 DTO 结构体自动生成准确的字段定义
---
## 新增 Handler 检查清单
> ⚠️ **重要**: 新增 Handler 时,必须完成以下所有步骤,否则接口不会出现在 OpenAPI 文档中!
### 必须完成的 4 个步骤
| 步骤 | 文件位置 | 操作 |
|------|---------|------|
| 1⃣ | `internal/bootstrap/types.go` | 在 `Handlers` 结构体中添加新 Handler 字段 |
| 2⃣ | `internal/bootstrap/handlers.go` | 实例化新 Handler |
| 3⃣ | `internal/routes/admin.go` | 调用路由注册函数 |
| 4⃣ | `cmd/api/docs.go``cmd/gendocs/main.go` | **添加 Handler 到文档生成器** |
### 详细说明
#### 步骤 1: 添加 Handler 字段
```go
// internal/bootstrap/types.go
type Handlers struct {
// ... 现有 Handler
IotCard *admin.IotCardHandler // 新增
IotCardImport *admin.IotCardImportHandler // 新增
}
```
#### 步骤 2: 实例化 Handler
```go
// internal/bootstrap/handlers.go
func initHandlers(services *Services) *Handlers {
return &Handlers{
// ... 现有 Handler
IotCard: admin.NewIotCardHandler(services.IotCard),
IotCardImport: admin.NewIotCardImportHandler(services.IotCardImport),
}
}
```
#### 步骤 3: 调用路由注册
```go
// internal/routes/admin.go
func RegisterAdminRoutes(...) {
// ... 现有路由
if handlers.IotCard != nil {
registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath)
}
}
```
#### 步骤 4: 更新文档生成器 ⚠️ 最容易遗漏!
**必须同时更新两个文件:**
```go
// cmd/api/docs.go
func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers := &bootstrap.Handlers{
// ... 现有 Handler
IotCard: admin.NewIotCardHandler(nil), // 添加
IotCardImport: admin.NewIotCardImportHandler(nil), // 添加
}
// ...
}
```
```go
// cmd/gendocs/main.go
func generateAdminDocs(outputPath string) error {
handlers := &bootstrap.Handlers{
// ... 现有 Handler
IotCard: admin.NewIotCardHandler(nil), // 添加
IotCardImport: admin.NewIotCardImportHandler(nil), // 添加
}
// ...
}
```
### 验证检查
完成上述步骤后,运行以下命令验证:
```bash
# 1. 编译检查
go build ./...
# 2. 重新生成文档
go run cmd/gendocs/main.go
# 3. 验证接口是否出现在文档中
grep "你的接口路径" docs/admin-openapi.yaml
```
---
## 路由注册规范
### 1. 基本结构
所有路由注册必须在 `internal/routes/` 目录中完成:
```
internal/routes/
├── registry.go # Register() 函数定义
├── routes.go # 总入口
├── admin.go # Admin 域路由
├── h5.go # H5 域路由
├── account.go # 账号管理路由
├── role.go # 角色管理路由
└── ...
```
### 2. 注册函数签名
```go
func registerXxxRoutes(
api fiber.Router, // Fiber 路由组
h *admin.XxxHandler, // Handler 实例
doc *openapi.Generator, // 文档生成器(可能为 nil
basePath string, // 基础路径(如 "/api/admin"
) {
// 路由注册逻辑
}
```
### 3. RouteSpec 结构
```go
type RouteSpec struct {
Summary string // 操作摘要(中文,简短,一行)
Description string // 详细说明,支持 Markdown 语法(可选)
Input interface{} // 请求参数 DTO
Output interface{} // 响应结果 DTO
Tags []string // 分类标签(用于文档分组)
Auth bool // 是否需要认证
}
```
### 4. Description 字段Markdown 说明)
`Description` 字段用于添加接口的详细说明,支持 **CommonMark Markdown** 语法。Apifox 等 OpenAPI 工具会正确渲染这些 Markdown 内容。
**使用场景**
- 业务规则说明
- 请求频率限制
- 注意事项
- 错误码说明
- 数据格式说明
**示例**
```go
Register(router, doc, basePath, "POST", "/login", handler.Login, RouteSpec{
Summary: "后台登录",
Description: `## 登录说明
**请求频率限制**:每分钟最多 10 次
### 注意事项
1. 密码错误 5 次后账号将被锁定 30 分钟
2. Token 有效期为 24 小时
### 返回码说明
| 错误码 | 说明 |
|--------|------|
| 1001 | 用户名或密码错误 |
| 1002 | 账号已被锁定 |
`,
Tags: []string{"认证"},
Input: new(dto.LoginRequest),
Output: new(dto.LoginResponse),
Auth: false,
})
```
**支持的 Markdown 语法**
- 标题:`#``##``###`
- 列表:`-``1.`
- 表格:`| 列1 | 列2 |`
- 代码:`` `code` `` 和 ` ```code block``` `
- 强调:`**粗体**``*斜体*`
- 链接:`[文本](url)`
**最佳实践**
- 保持简洁,控制在 500 字以内
- 使用结构化的 Markdown标题、列表、表格提高可读性
- 避免使用 HTML 标签(兼容性较差)
### 5. 完整示例
```go
func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "店铺列表",
Tags: []string{"店铺管理"},
Input: new(model.ShopListRequest),
Output: new(model.ShopPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建店铺",
Tags: []string{"店铺管理"},
Input: new(model.CreateShopRequest),
Output: new(model.ShopResponse),
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新店铺",
Tags: []string{"店铺管理"},
Input: new(model.UpdateShopParams), // 组合参数(路径 + Body
Output: new(model.ShopResponse),
Auth: true,
})
Register(shops, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除店铺",
Tags: []string{"店铺管理"},
Input: new(model.IDReq), // 仅路径参数
Output: nil,
Auth: true,
})
}
```
---
## DTO 规范
### 1. Description 标签(必须)
**所有字段必须使用 `description` 标签,禁止使用行内注释。**
```go
// ❌ 错误
type CreateShopRequest struct {
ShopName string `json:"shop_name" validate:"required,min=1,max=100"` // 店铺名称
}
// ✅ 正确
type CreateShopRequest struct {
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"`
}
```
### 2. 枚举字段规范
**必须在 `description` 中列出所有可能值(中文)。**
```go
type CreateShopRequest struct {
Status int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"`
Level int `json:"level" validate:"required,min=1,max=7" required:"true" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
}
```
### 3. 验证标签与 OpenAPI 标签一致
| validate 标签 | OpenAPI 标签 | 说明 |
|--------------|--------------|------|
| `required` | `required:"true"` | 必填字段 |
| `min=N,max=M` | `minimum:"N" maximum:"M"` | 数值范围 |
| `min=N,max=M` (字符串) | `minLength:"N" maxLength:"M"` | 字符串长度 |
| `len=N` | `minLength:"N" maxLength:"N"` | 固定长度 |
| `oneof=A B C` | `description` 中说明 | 枚举值 |
### 4. 请求参数类型标签
```go
// Query 参数
type ListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
}
// Path 参数
type IDReq struct {
ID uint `path:"id" description:"ID" required:"true"`
}
// Body 参数(默认)
type CreateRequest struct {
Name string `json:"name" validate:"required" required:"true" description:"名称"`
}
```
### 5. 组合参数(路径 + Body
对于 `PUT /:id` 类型的端点,需要创建组合参数 DTO
```go
// 定义在 internal/model/common.go
type UpdateShopParams struct {
IDReq // 路径参数
UpdateShopRequest // Body 参数
}
```
### 6. 分页响应规范
```go
type ShopPageResult struct {
Items []ShopResponse `json:"items" description:"店铺列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
```
### 7. 响应 Envelope 格式
**所有 API 响应都会被自动包裹在统一的 envelope 结构中。**
OpenAPI 文档会自动为成功响应生成以下结构:
```yaml
responses:
"200":
content:
application/json:
schema:
type: object
properties:
code:
type: integer
example: 0
description: 响应码
msg:
type: string
example: success
description: 响应消息
data:
$ref: '#/components/schemas/YourDTO' # 你定义的 DTO
timestamp:
type: string
format: date-time
description: 时间戳
```
**注意事项**:
- DTO 中只需定义 `data` 字段的内容,无需定义 envelope 字段
- 错误响应使用 `msg` 字段(不是 `message`
- 删除操作等无返回数据的接口,`data` 字段为 `null`
**示例**:
```go
// DTO 定义(只定义 data 部分)
type LoginResponse struct {
Token string `json:"token" description:"访问令牌"`
Customer *PersonalCustomerDTO `json:"customer" description:"客户信息"`
}
// 实际 API 响应(自动包裹 envelope
{
"code": 0,
"msg": "success",
"data": {
"token": "eyJhbGciOiJI...",
"customer": {
"id": 1,
"phone": "13800000000"
}
},
"timestamp": "2026-01-30T10:00:00Z"
}
```
---
## 文档生成流程
### 1. 自动生成
```bash
# 方式1独立生成工具
go run cmd/gendocs/main.go
# 方式2启动 API 服务时自动生成
go run cmd/api/main.go
```
生成的文档位置:
- `docs/admin-openapi.yaml` - 独立生成
- `logs/openapi.yaml` - 运行时生成
### 2. 验证文档
```bash
# 1. 检查生成的路径数量
python3 -c "
import yaml
with open('docs/admin-openapi.yaml', 'r', encoding='utf-8') as f:
doc = yaml.safe_load(f)
paths = list(doc.get('paths', {}).keys())
print(f'总路径数: {len(paths)}')
for p in sorted(paths):
print(f' {p}')
"
# 2. 在 Swagger UI 中测试
# 访问 https://editor.swagger.io/
# 粘贴 docs/admin-openapi.yaml 内容
```
### 3. 更新文档生成器
如果新增了 Handler需要在 `cmd/gendocs/main.go` 中添加:
```go
// 3. 创建 Handler使用 nil 依赖,因为只需要路由结构)
newHandler := admin.NewXxxHandler(nil)
handlers := &bootstrap.Handlers{
// ... 其他 Handler
Xxx: newHandler, // 添加新 Handler
}
```
---
## 常见问题
### Q1: 为什么我的接口没有出现在文档中?
> ⚠️ **最常见原因**: 忘记在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中添加新 Handler
**检查清单(按优先级排序)**
1.**【最常遗漏】** 是否在文档生成器中添加了 Handler
必须同时检查两个文件:
```go
// cmd/api/docs.go
handlers := &bootstrap.Handlers{
Xxx: admin.NewXxxHandler(nil), // 是否添加?
}
// cmd/gendocs/main.go
handlers := &bootstrap.Handlers{
Xxx: admin.NewXxxHandler(nil), // 是否添加?
}
```
2. ✅ 是否使用了 `Register()` 函数?
```go
// ❌ 错误
router.Post("/path", handler.Method)
// ✅ 正确
Register(router, doc, basePath, "POST", "/path", handler.Method, RouteSpec{...})
```
3. ✅ 路由注册函数是否接收了 `doc *openapi.Generator` 参数?
```go
func registerXxxRoutes(router fiber.Router, handler *admin.XxxHandler, doc *openapi.Generator, basePath string)
```
4. ✅ 是否调用了路由注册函数?
- 检查 `internal/routes/admin.go` 中是否调用了 `registerXxxRoutes()`
- 检查 `internal/routes/routes.go` 是否调用了 `RegisterAdminRoutes()`
**快速定位问题**
```bash
# 检查 Handler 是否在文档生成器中注册
grep "NewXxxHandler" cmd/api/docs.go cmd/gendocs/main.go
```
### Q2: 文档生成时报错 "undefined path parameter"
**原因**:路径参数(如 `/:id`)的 DTO 缺少对应字段。
**解决方案**:创建组合参数 DTO
```go
// ❌ 错误:直接使用 Body DTO
Register(router, doc, basePath, "PUT", "/:id", handler.Update, RouteSpec{
Input: new(model.UpdateShopRequest), // 缺少 id 参数
})
// ✅ 正确:使用组合参数
type UpdateShopParams struct {
IDReq // 包含 id 参数
UpdateShopRequest // 包含 Body 参数
}
Register(router, doc, basePath, "PUT", "/:id", handler.Update, RouteSpec{
Input: new(model.UpdateShopParams),
})
```
### Q3: DTO 字段在文档中没有描述?
**检查**
1. ✅ 是否添加了 `description` 标签?
```go
ShopName string `json:"shop_name" description:"店铺名称"`
```
2. ✅ 是否使用了行内注释(不会被识别)?
```go
// ❌ 错误
ShopName string `json:"shop_name"` // 店铺名称
// ✅ 正确
ShopName string `json:"shop_name" description:"店铺名称"`
```
### Q4: 如何为新模块添加路由?
**完整步骤**(共 6 步):
1. **创建 Handler**`internal/handler/admin/xxx.go`
2. **添加到 Handlers 结构体**`internal/bootstrap/types.go`
```go
type Handlers struct {
Xxx *admin.XxxHandler
}
```
3. **实例化 Handler**`internal/bootstrap/handlers.go`
```go
Xxx: admin.NewXxxHandler(services.Xxx),
```
4. **创建路由文件**`internal/routes/xxx.go`
```go
func registerXxxRoutes(api fiber.Router, h *admin.XxxHandler, doc *openapi.Generator, basePath string) {
// 使用 Register() 注册路由
}
```
5. **调用路由注册**`internal/routes/admin.go`
```go
if handlers.Xxx != nil {
registerXxxRoutes(authGroup, handlers.Xxx, doc, basePath)
}
```
6. **更新文档生成器**(⚠️ 两个文件都要改):
- `cmd/api/docs.go`
- `cmd/gendocs/main.go`
```go
handlers := &bootstrap.Handlers{
Xxx: admin.NewXxxHandler(nil),
}
```
7. **验证**
```bash
go build ./...
go run cmd/gendocs/main.go
grep "/api/admin/xxx" docs/admin-openapi.yaml
```
### Q5: 如何为个人客户路由(/api/c/v1添加文档
个人客户路由需要在独立的路由文件中注册,并使用 `Register()` 函数以纳入 OpenAPI 文档。
**示例**`internal/routes/personal.go`
```go
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
// 公开路由(不需要认证)
publicGroup := router.Group("")
Register(publicGroup, doc, basePath, "POST", "/login/send-code", handlers.PersonalCustomer.SendCode, RouteSpec{
Summary: "发送验证码",
Description: "向指定手机号发送登录验证码",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &apphandler.SendCodeRequest{},
Output: nil,
})
Register(publicGroup, doc, basePath, "POST", "/login", handlers.PersonalCustomer.Login, RouteSpec{
Summary: "手机号登录",
Description: "使用手机号和验证码登录",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &apphandler.LoginRequest{},
Output: &apphandler.LoginResponse{},
})
// 需要认证的路由
authGroup := router.Group("")
authGroup.Use(personalAuthMiddleware.Authenticate())
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{
Summary: "获取个人资料",
Description: "获取当前登录客户的个人资料",
Tags: []string{"个人客户 - 账户"},
Auth: true,
Input: nil,
Output: &apphandler.PersonalCustomerDTO{},
})
}
```
**在 `routes.go` 中调用**
```go
func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator) {
// ... 其他路由
// 个人客户路由 (挂载在 /api/c/v1)
personalGroup := app.Group("/api/c/v1")
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
}
```
**关键点**
- basePath 必须是完整路径(如 `/api/c/v1`
- 需要传入 `personalAuthMiddleware` 以支持认证路由组
- Tags 使用中文并包含模块前缀(如 "个人客户 - 认证"
### Q6: 如何调试文档生成?
```bash
# 1. 查看生成的 YAML 文件
cat docs/admin-openapi.yaml
# 2. 验证 YAML 格式
python3 -c "
import yaml
with open('docs/admin-openapi.yaml', 'r', encoding='utf-8') as f:
doc = yaml.safe_load(f)
print('YAML 格式正确')
"
# 3. 检查特定路径
python3 -c "
import yaml
with open('docs/admin-openapi.yaml', 'r', encoding='utf-8') as f:
doc = yaml.safe_load(f)
path = '/api/admin/shops'
if path in doc['paths']:
import json
print(json.dumps(doc['paths'][path], indent=2, ensure_ascii=False))
"
```
---
## 参考资料
- [OpenAPI 3.0 规范](https://swagger.io/specification/)
- [Swagger UI](https://swagger.io/tools/swagger-ui/)
- [项目 DTO 规范](../AGENTS.md#dto-规范重要)
- [已有实现示例](../internal/routes/account.go)

View File

@@ -1,511 +0,0 @@
# B 端认证 API 文档
本文档描述君鸿卡管系统 B 端认证接口(后台管理和 H5包括登录、登出、Token 刷新、用户信息查询和密码修改功能。
---
## 概述
### 基础信息
- **后台 API 前缀**: `/api/admin`
- **H5 API 前缀**: `/api/h5`
- **认证方式**: Bearer Token (存储在 Redis)
- **Token 类型**:
- Access Token24 小时有效期,用于 API 访问
- Refresh Token7 天有效期,用于刷新 Access Token
### 用户类型限制
| 平台 | 允许的用户类型 |
|------|---------------|
| 后台 | SuperAdmin(1)、Platform(2)、Agent(3) |
| H5 | Agent(3)、Enterprise(4) |
---
## 公开接口(无需认证)
### 1. 用户登录
**后台**: `POST /api/admin/login`
**H5**: `POST /api/h5/login`
使用用户名或手机号 + 密码登录,返回访问令牌。
#### 请求体
```json
{
"username": "admin",
"password": "Admin@123456",
"device": "web"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 用户名或手机号 |
| password | string | 是 | 密码 |
| device | string | 否 | 设备标识web/ios/android |
#### 响应示例
**成功200**:
```json
{
"code": 0,
"message": "操作成功",
"data": {
"access_token": "550e8400-e29b-41d4-a716-446655440000",
"refresh_token": "660f9500-f39c-52e5-b827-557766551111",
"expires_in": 86400,
"user": {
"id": 1,
"username": "admin",
"phone": "13800000000",
"user_type": 1,
"user_type_name": "超级管理员",
"shop_id": 0,
"enterprise_id": 0
},
"permissions": [
"user:create",
"user:update",
"user:delete",
"role:manage"
]
},
"timestamp": "2026-01-15T16:05:00+08:00"
}
```
#### 错误码
| 错误码 | 消息 | 说明 |
|--------|------|------|
| 1001 | 参数验证失败 | 请求参数不完整或格式错误 |
| 1040 | 用户名或密码错误 | 凭证无效 |
| 1011 | 账号已禁用 | 账号被禁用,无法登录 |
| 1041 | 账号已锁定 | 账号被锁定 |
#### cURL 示例
```bash
# 后台登录
curl -X POST http://localhost:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "Admin@123456",
"device": "web"
}'
# H5 登录
curl -X POST http://localhost:8080/api/h5/login \
-H "Content-Type: application/json" \
-d '{
"username": "agent001",
"password": "password123",
"device": "ios"
}'
```
---
### 2. 刷新访问令牌
**后台**: `POST /api/admin/refresh-token`
**H5**: `POST /api/h5/refresh-token`
使用 Refresh Token 获取新的 Access Token。
#### 请求体
```json
{
"refresh_token": "660f9500-f39c-52e5-b827-557766551111"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| refresh_token | string | 是 | 刷新令牌 |
#### 响应示例
**成功200**:
```json
{
"code": 0,
"message": "操作成功",
"data": {
"access_token": "770a0600-a40d-63f6-c938-668877662222",
"expires_in": 86400
},
"timestamp": "2026-01-15T16:06:00+08:00"
}
```
#### 错误码
| 错误码 | 消息 | 说明 |
|--------|------|------|
| 1001 | 参数验证失败 | refresh_token 缺失 |
| 1003 | 无效或过期的令牌 | Refresh Token 无效或已过期 |
#### cURL 示例
```bash
curl -X POST http://localhost:8080/api/admin/refresh-token \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "660f9500-f39c-52e5-b827-557766551111"
}'
```
---
## 受保护接口(需要认证)
所有受保护接口需在请求头中携带 Access Token
```
Authorization: Bearer {access_token}
```
---
### 3. 用户登出
**后台**: `POST /api/admin/logout`
**H5**: `POST /api/h5/logout`
撤销当前 Access Token 和 Refresh Token如果提供
#### 请求头
```
Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
```
#### 请求体(可选)
```json
{
"refresh_token": "660f9500-f39c-52e5-b827-557766551111"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| refresh_token | string | 否 | 如果提供,同时撤销 Refresh Token |
#### 响应示例
**成功200**:
```json
{
"code": 0,
"message": "操作成功",
"data": null,
"timestamp": "2026-01-15T16:07:00+08:00"
}
```
#### 错误码
| 错误码 | 消息 | 说明 |
|--------|------|------|
| 1002 | 缺失认证令牌 | 请求头未携带 Authorization |
| 1003 | 无效或过期的令牌 | Access Token 无效或已过期 |
| 1004 | 未授权访问 | Token 已被撤销 |
#### cURL 示例
```bash
curl -X POST http://localhost:8080/api/admin/logout \
-H "Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "660f9500-f39c-52e5-b827-557766551111"
}'
```
---
### 4. 获取当前用户信息
**后台**: `GET /api/admin/me`
**H5**: `GET /api/h5/me`
获取当前登录用户的详细信息和权限列表。
#### 请求头
```
Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
```
#### 响应示例
**成功200**:
```json
{
"code": 0,
"message": "操作成功",
"data": {
"user": {
"id": 1,
"username": "admin",
"phone": "13800000000",
"user_type": 1,
"user_type_name": "超级管理员",
"shop_id": 0,
"enterprise_id": 0,
"status": 1,
"created_at": "2026-01-01T00:00:00+08:00"
},
"permissions": [
"user:create",
"user:update",
"user:delete",
"role:manage",
"permission:manage"
]
},
"timestamp": "2026-01-15T16:08:00+08:00"
}
```
#### 错误码
| 错误码 | 消息 | 说明 |
|--------|------|------|
| 1002 | 缺失认证令牌 | 请求头未携带 Authorization |
| 1003 | 无效或过期的令牌 | Access Token 无效或已过期 |
| 1004 | 未授权访问 | 用户信息查询失败 |
#### cURL 示例
```bash
curl -X GET http://localhost:8080/api/admin/me \
-H "Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000"
```
---
### 5. 修改密码
**后台**: `POST /api/admin/password`
**H5**: `POST /api/h5/password`
修改当前用户密码,修改成功后所有旧 Token 将失效。
#### 请求头
```
Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
```
#### 请求体
```json
{
"old_password": "Admin@123456",
"new_password": "NewPassword@2026"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| old_password | string | 是 | 当前密码 |
| new_password | string | 是 | 新密码6-20 位,包含字母和数字) |
#### 响应示例
**成功200**:
```json
{
"code": 0,
"message": "操作成功",
"data": null,
"timestamp": "2026-01-15T16:09:00+08:00"
}
```
#### 错误码
| 错误码 | 消息 | 说明 |
|--------|------|------|
| 1001 | 参数验证失败 | 密码格式不符合要求 |
| 1002 | 缺失认证令牌 | 请求头未携带 Authorization |
| 1003 | 无效或过期的令牌 | Access Token 无效或已过期 |
| 1043 | 旧密码错误 | 提供的旧密码不正确 |
#### cURL 示例
```bash
curl -X POST http://localhost:8080/api/admin/password \
-H "Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{
"old_password": "Admin@123456",
"new_password": "NewPassword@2026"
}'
```
---
## 错误码对照表
### 认证相关错误1000-1099
| 错误码 | 消息 | HTTP 状态码 | 说明 |
|--------|------|-------------|------|
| 1001 | 参数验证失败 | 400 | 请求参数不完整或格式错误 |
| 1002 | 缺失认证令牌 | 401 | Authorization 请求头缺失 |
| 1003 | 无效或过期的令牌 | 401 | Token 无效或已过期 |
| 1004 | 未授权访问 | 401 | 无权访问该资源 |
| 1005 | 禁止访问 | 403 | 用户类型不允许访问 |
| 1040 | 用户名或密码错误 | 401 | 登录凭证错误 |
| 1011 | 账号已禁用 | 403 | 账号被管理员禁用 |
| 1041 | 账号已锁定 | 403 | 账号因安全原因被锁定 |
| 1042 | 密码已过期 | 403 | 需要更新密码 |
| 1043 | 旧密码错误 | 400 | 修改密码时提供的旧密码不正确 |
### 服务端错误2000-2999
| 错误码 | 消息 | HTTP 状态码 | 说明 |
|--------|------|-------------|------|
| 2001 | 内部服务器错误 | 500 | 服务器内部错误 |
| 2002 | 数据库错误 | 500 | 数据库操作失败 |
| 2003 | Redis 错误 | 500 | Redis 操作失败 |
| 2004 | 服务不可用 | 503 | 服务暂时不可用 |
---
## 使用场景示例
### 场景 1完整登录流程
```bash
# 1. 用户登录
curl -X POST http://localhost:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin@123456"}' \
| jq
# 响应获取 access_token 和 refresh_token
# {
# "code": 0,
# "data": {
# "access_token": "550e8400-...",
# "refresh_token": "660f9500-..."
# }
# }
# 2. 使用 access_token 访问受保护接口
curl -X GET http://localhost:8080/api/admin/me \
-H "Authorization: Bearer 550e8400-..." \
| jq
# 3. Access Token 过期后,使用 Refresh Token 刷新
curl -X POST http://localhost:8080/api/admin/refresh-token \
-H "Content-Type: application/json" \
-d '{"refresh_token":"660f9500-..."}' \
| jq
# 4. 登出
curl -X POST http://localhost:8080/api/admin/logout \
-H "Authorization: Bearer 550e8400-..." \
-H "Content-Type: application/json" \
-d '{"refresh_token":"660f9500-..."}' \
| jq
```
### 场景 2修改密码
```bash
# 1. 登录获取 token
TOKEN=$(curl -s -X POST http://localhost:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin@123456"}' \
| jq -r '.data.access_token')
# 2. 修改密码
curl -X POST http://localhost:8080/api/admin/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"old_password": "Admin@123456",
"new_password": "NewPassword@2026"
}' \
| jq
# 3. 旧 token 失效,需要重新登录
curl -X POST http://localhost:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"NewPassword@2026"}' \
| jq
```
---
## 安全注意事项
1. **Token 存储**
- 前端应将 Token 存储在安全位置(如 HttpOnly Cookie 或 secure storage
- 不要将 Token 暴露在 URL 中
2. **Token 传输**
- 始终使用 HTTPS 传输 Token
- Token 仅在 Authorization 请求头中传递
3. **密码要求**
- 长度6-20 位
- 必须包含字母和数字
- 建议包含特殊字符
4. **Token 生命周期**
- Access Token24 小时自动过期
- Refresh Token7 天自动过期
- 修改密码后所有旧 Token 立即失效
5. **并发登录**
- 系统支持同一用户多设备同时登录
- 每个设备拥有独立的 Token 对
- 登出只撤销当前设备的 Token
---
## 常见问题
**Q: 如何判断 Access Token 是否过期?**
A: 接口返回 `1003` 错误码时表示 Token 过期,应使用 Refresh Token 刷新。
**Q: Refresh Token 过期后怎么办?**
A: Refresh Token 过期后需要重新登录。
**Q: 修改密码后需要重新登录吗?**
A: 是的,修改密码后所有设备的 Token 都会失效,需要重新登录。
**Q: 后台用户可以使用 H5 接口吗?**
A: 不可以。后台和 H5 有不同的用户类型限制,使用错误的端点会返回 `1005` 错误。
**Q: 如何撤销所有设备的登录状态?**
A: 调用 `/api/admin/password` 修改密码,所有设备的 Token 会自动失效。
---
## 相关文档
- [使用指南](../auth-usage-guide.md) - 如何在代码中集成认证
- [架构说明](../auth-architecture.md) - 认证系统架构设计
- [错误处理指南](../003-error-handling/使用指南.md) - 统一错误处理
---
**文档版本**: v1.0
**最后更新**: 2026-01-15
**维护者**: 君鸿卡管系统开发团队

View File

@@ -1,366 +0,0 @@
# 套餐接口架构图
## 1. 整体架构
```
┌─────────────────────────────────────────────────────────────────┐
│ HTTP 请求 │
│ POST /api/admin/packages │
└────────────────────────────┬────────────────────────────────────┘
┌────────▼────────┐
│ 认证中间件 │
│ (AdminAuth) │
└────────┬────────┘
┌────────────────────▼────────────────────┐
│ 路由注册 (routes/admin.go) │
│ registerPackageRoutes() │
│ ↓ │
│ internal/routes/package.go │
└────────────────────┬────────────────────┘
┌────────────────────▼────────────────────┐
│ PackageHandler │
│ (internal/handler/admin/package.go) │
│ │
│ - List() │
│ - Create() │
│ - Get() │
│ - Update() │
│ - Delete() │
│ - UpdateStatus() │
│ - UpdateShelfStatus() │
│ - UpdateRetailPrice() │
└────────────────────┬────────────────────┘
┌────────────────────▼────────────────────┐
│ PackageService │
│ (internal/service/package/service.go) │
│ │
│ 业务逻辑: │
│ - 虚流量校验 │
│ - 周期类型校验 │
│ - 代理权限检查 │
│ - DTO 转换 │
└────────────────────┬────────────────────┘
┌────────────────────▼────────────────────┐
│ PackageStore │
│ (internal/store/postgres/...) │
│ │
│ - PackageStore │
│ - PackageSeriesStore │
│ - ShopPackageAllocationStore │
│ - ShopSeriesAllocationStore │
└────────────────────┬────────────────────┘
┌────────────────────▼────────────────────┐
│ PostgreSQL 数据库 │
│ │
│ - tb_package │
│ - tb_package_series │
│ - tb_shop_package_allocation │
│ - tb_shop_series_allocation │
└─────────────────────────────────────────┘
```
---
## 2. 分层详细结构
### Handler 层
```
PackageHandler
├── List(c *fiber.Ctx)
│ └── 解析查询参数 → 调用 Service.List() → 返回分页结果
├── Create(c *fiber.Ctx)
│ └── 解析请求体 → 调用 Service.Create() → 返回创建结果
├── Get(c *fiber.Ctx)
│ └── 解析 ID 参数 → 调用 Service.Get() → 返回详情
├── Update(c *fiber.Ctx)
│ └── 解析 ID 和请求体 → 调用 Service.Update() → 返回更新结果
├── Delete(c *fiber.Ctx)
│ └── 解析 ID 参数 → 调用 Service.Delete() → 返回删除结果
├── UpdateStatus(c *fiber.Ctx)
│ └── 解析 ID 和状态 → 调用 Service.UpdateStatus() → 返回结果
├── UpdateShelfStatus(c *fiber.Ctx)
│ └── 解析 ID 和上架状态 → 调用 Service.UpdateShelfStatus() → 返回结果
└── UpdateRetailPrice(c *fiber.Ctx)
└── 解析 ID 和零售价 → 调用 Service.UpdateRetailPrice() → 返回结果
```
### Service 层
```
PackageService
├── Create(ctx, req)
│ ├── 校验套餐编码唯一性
│ ├── 校验虚流量配置
│ │ ├── 启用时虚流量 > 0
│ │ └── 虚流量 ≤ 真流量
│ ├── 校验周期类型和时长
│ │ ├── natural_month: 需要 duration_months
│ │ └── by_day: 需要 duration_days
│ ├── 查询套餐系列信息
│ ├── 创建 Package 模型
│ └── 调用 Store.Create()
├── List(ctx, req)
│ ├── 获取用户类型和店铺 ID
│ ├── 代理用户额外过滤
│ │ └── INNER JOIN tb_shop_package_allocation
│ ├── 应用其他过滤条件
│ └── 调用 Store.List()
├── Get(ctx, id)
│ └── 调用 Store.GetByID()
├── Update(ctx, id, req)
│ ├── 获取现有套餐
│ ├── 更新字段
│ └── 调用 Store.Update()
├── Delete(ctx, id)
│ └── 调用 Store.Delete()
├── UpdateStatus(ctx, id, status)
│ └── 更新状态字段
├── UpdateShelfStatus(ctx, id, shelfStatus)
│ └── 更新上架状态字段
└── UpdateRetailPrice(ctx, id, price)
└── 更新零售价字段
```
### Store 层
```
PackageStore
├── Create(ctx, pkg)
│ └── db.Create(pkg)
├── GetByID(ctx, id)
│ └── db.First(&pkg, id)
├── GetByCode(ctx, code)
│ └── db.Where("package_code = ?", code).First(&pkg)
├── Update(ctx, pkg)
│ └── db.Save(pkg)
├── Delete(ctx, id)
│ └── db.Delete(&Package{}, id)
└── List(ctx, opts, filters)
├── 构建基础查询
├── 代理用户 JOIN 分配表
├── 应用过滤条件
│ ├── package_name (模糊搜索)
│ ├── series_id
│ ├── status
│ ├── shelf_status
│ └── package_type
├── 计算总数
├── 应用分页
└── 执行查询
```
---
## 3. 数据模型关系
```
┌──────────────────────────────────────────────────────────────┐
│ tb_package (套餐表) │
├──────────────────────────────────────────────────────────────┤
│ id (PK) │
│ package_code (UNIQUE) │
│ package_name │
│ series_id (FK → tb_package_series) │
│ package_type (formal/addon) │
│ duration_months │
│ duration_days ← 关键字段 │
│ calendar_type (natural_month/by_day) │
│ real_data_mb │
│ virtual_data_mb │
│ enable_virtual_data │
│ data_reset_cycle │
│ expiry_base │
│ cost_price │
│ suggested_retail_price │
│ shelf_status │
│ status │
│ created_at, updated_at, deleted_at │
└──────────────────────────────────────────────────────────────┘
│ 1:N
┌──────────────────────────────────────────────────────────────┐
│ tb_package_usage (套餐使用表) │
├──────────────────────────────────────────────────────────────┤
│ id (PK) │
│ order_id (FK) │
│ package_id (FK → tb_package) │
│ usage_type (single_card/device) │
│ iot_card_id / device_id │
│ data_limit_mb │
│ data_usage_mb │
│ activated_at, expires_at │
│ status │
│ priority │
│ master_usage_id (加油包关联主套餐) │
└──────────────────────────────────────────────────────────────┘
│ 1:N
┌──────────────────────────────────────────────────────────────┐
│ tb_package_usage_daily_record (日记录表) │
├──────────────────────────────────────────────────────────────┤
│ id (PK) │
│ package_usage_id (FK) │
│ date │
│ daily_usage_mb │
│ cumulative_usage_mb │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ tb_package_series (套餐系列表) │
├──────────────────────────────────────────────────────────────┤
│ id (PK) │
│ series_code (UNIQUE) │
│ series_name │
│ description │
│ enable_one_time_commission │
│ one_time_commission_config (JSONB) │
│ status │
└──────────────────────────────────────────────────────────────┘
│ 1:N
┌──────────────────────────────────────────────────────────────┐
│ tb_shop_package_allocation (分配表) │
├──────────────────────────────────────────────────────────────┤
│ id (PK) │
│ shop_id (FK → tb_shop) │
│ package_id (FK → tb_package) │
│ retail_price │
│ shelf_status │
│ status │
└──────────────────────────────────────────────────────────────┘
```
---
## 4. 请求流程示例
### 创建套餐(按天)
```
1. HTTP 请求
POST /api/admin/packages
{
"package_code": "PKG_DAY_30",
"package_name": "30天套餐",
"calendar_type": "by_day",
"duration_days": 30,
...
}
2. Handler.Create()
├─ 解析请求体 → CreatePackageRequest
└─ 调用 Service.Create(ctx, req)
3. Service.Create()
├─ 校验 package_code 唯一性
│ └─ Store.GetByCode(ctx, "PKG_DAY_30") → nil ✓
├─ 校验虚流量配置 ✓
├─ 校验周期类型
│ ├─ calendar_type = "by_day" ✓
│ ├─ duration_days = 30 ✓
│ └─ 校验通过 ✓
├─ 查询套餐系列 (可选)
├─ 创建 Package 模型
└─ Store.Create(ctx, pkg)
4. Store.Create()
└─ db.Create(pkg)
└─ INSERT INTO tb_package (...)
5. Service 返回 PackageResponse
└─ Handler 返回 HTTP 200
6. HTTP 响应
{
"code": 0,
"data": {
"id": 1,
"package_code": "PKG_DAY_30",
"duration_days": 30,
...
},
"msg": "success"
}
```
---
## 5. 代理用户套餐查询流程
```
1. 代理用户请求
GET /api/admin/packages?status=1
2. Handler.List()
└─ Service.List(ctx, req)
3. Service.List()
├─ 获取用户类型: UserTypeAgent ✓
├─ 获取店铺 ID: 10 ✓
└─ Store.List(ctx, opts, filters)
4. Store.List()
├─ 构建基础查询
│ └─ SELECT * FROM tb_package
├─ 检测代理用户
│ └─ isAgent = true ✓
├─ 添加 JOIN 条件
│ └─ INNER JOIN tb_shop_package_allocation
│ ON tb_shop_package_allocation.package_id = tb_package.id
│ AND tb_shop_package_allocation.deleted_at IS NULL
├─ 添加 WHERE 条件
│ └─ WHERE tb_shop_package_allocation.shop_id = 10
│ AND tb_shop_package_allocation.status = 1
├─ 应用其他过滤
│ └─ AND tb_package.status = 1
├─ 计算总数
├─ 应用分页
└─ 执行查询
5. 返回结果
└─ 只返回已分配给店铺 10 的套餐
```
---
## 6. 字段验证流程
```
CreatePackageRequest
├─ package_code
│ └─ validate: required, min=1, max=100
│ └─ Service: 检查唯一性
├─ package_name
│ └─ validate: required, min=1, max=255
├─ calendar_type
│ └─ validate: oneof=natural_month by_day
│ └─ Service: 根据类型校验时长字段
├─ duration_months
│ └─ validate: required, min=1, max=120
│ └─ Service: 当 calendar_type=natural_month 时必填
├─ duration_days
│ └─ validate: omitempty, min=1, max=3650
│ └─ Service: 当 calendar_type=by_day 时必填
├─ real_data_mb
│ └─ validate: omitempty, min=0
├─ virtual_data_mb
│ └─ validate: omitempty, min=0
│ └─ Service: 启用虚流量时必须 > 0 且 ≤ real_data_mb
├─ enable_virtual_data
│ └─ Service: 虚流量配置校验
├─ cost_price
│ └─ validate: required, min=0
└─ suggested_retail_price
└─ validate: omitempty, min=0
```

View File

@@ -1,260 +0,0 @@
# 资产详情重构 API 变更说明
> 适用版本asset-detail-refactor 提案上线后
> 文档更新2026-03-14
---
## 一、现有接口字段变更
### 1. `device_no` 重命名为 `virtual_no`
所有涉及设备标识符的接口,响应中的 `device_no` 字段已统一改名为 `virtual_no`**JSON key 同步变更**,前端需全局替换。
受影响接口:
| 接口 | 变更字段 |
|------|---------|
| `GET /api/admin/devices`(列表/详情响应) | `device_no``virtual_no` |
| `GET /api/admin/devices/import/tasks/:id` | `failed_items[].device_no``virtual_no` |
| `GET /api/admin/enterprises/:id/devices`(企业设备列表) | `device_no``virtual_no` |
| `GET /api/admin/shop-commission/records` | `device_no``virtual_no` |
| `GET /api/admin/my-commission/records` | `device_no``virtual_no` |
| 企业卡授权相关响应中的设备字段 | `device_no``virtual_no` |
---
### 2. 套餐接口新增 `virtual_ratio` 字段
`GET /api/admin/packages` 及套餐详情响应新增:
| 新增字段 | 类型 | 说明 |
|---------|------|------|
| `virtual_ratio` | float64 | 虚流量比例real_data_mb / virtual_data_mb。启用虚流量时计算否则为 1.0 |
---
### 3. IoT 卡接口新增 `virtual_no` 字段
卡列表/详情响应新增:
| 新增字段 | 类型 | 说明 |
|---------|------|------|
| `virtual_no` | string | 虚拟号(可空) |
---
## 二、新增接口
### 基础说明
- 资产相关路由已统一为 `:identifier` 口径(虚拟号 / ICCID / IMEI / SN / MSISDN
- 企业账号调用 `resolve` 接口会返回 403
---
### `GET /api/admin/assets/resolve/:identifier`
通过任意标识符查询设备或卡的完整详情。支持虚拟号、ICCID、IMEI、SN、MSISDN。
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `asset_type` | string | `card``device` |
| `asset_id` | uint | 数据库 ID |
| `virtual_no` | string | 虚拟号 |
| `status` | int | 资产状态 |
| `batch_no` | string | 批次号 |
| `shop_id` | uint | 所属店铺 ID |
| `shop_name` | string | 所属店铺名称 |
| `series_id` | uint | 套餐系列 ID |
| `series_name` | string | 套餐系列名称 |
| `real_name_status` | int | 实名状态0 未实名 / 1 实名中 / 2 已实名 |
| `network_status` | int | 网络状态0 停机 / 1 开机(仅 card |
| `current_package` | string | 当前套餐名称(无则空) |
| `current_package_usage_id` | uint | 当前主套餐的套餐使用记录 ID无主套餐时为 null |
| `real_total_mb` | int64 | 当前主套餐真实总量 MB |
| `real_used_mb` | int64 | 当前主套餐真实已用量 MB |
| `virtual_total_mb` | int64 | 当前主套餐业务停机阈值 MB |
| `virtual_used_mb` | float64 | 当前主套餐展示已用量 MB |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `enable_virtual_data` | bool | 当前主套餐是否启用虚流量(按套餐使用记录快照返回) |
| `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device |
| `activated_at` | time | 激活时间(未来准备移除,请勿依赖) |
| `created_at` | time | 创建时间 |
| `updated_at` | time | 更新时间 |
| **绑定关系card 时)** | | |
| `iccid` | string | 卡 ICCID |
| `bound_device_id` | uint | 绑定设备 ID |
| `bound_device_no` | string | 绑定设备虚拟号 |
| `bound_device_name` | string | 绑定设备名称 |
| **绑定关系device 时)** | | |
| `bound_card_count` | int | 绑定卡数量 |
| `cards[]` | array | 绑定卡列表,每项含:`card_id` / `iccid` / `msisdn` / `network_status` / `real_name_status` / `slot_position` |
| **设备专属字段card 时为空)** | | |
| `device_name` | string | 设备名称 |
| `imei` | string | IMEI |
| `sn` | string | 序列号 |
| `device_model` | string | 设备型号 |
| `device_type` | string | 设备类型 |
| `max_sim_slots` | int | 最大插槽数 |
| `manufacturer` | string | 制造商 |
| **卡专属字段device 时为空)** | | |
| `carrier_type` | string | 运营商类型 |
| `carrier_name` | string | 运营商名称 |
| `msisdn` | string | 手机号 |
| `imsi` | string | IMSI |
| `card_category` | string | 卡业务类型 |
| `supplier` | string | 供应商 |
| `activation_status` | int | 激活状态 |
| `enable_polling` | bool | 是否参与轮询 |
---
### `GET /api/admin/assets/:identifier/realtime-status`
读取资产实时状态(直接读 DB/Redis不调网关
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `asset_type` | string | `card``device` |
| `asset_id` | uint | 资产 ID |
| `network_status` | int | 网络状态(仅 card |
| `real_name_status` | int | 实名状态(仅 card |
| `current_month_usage_mb` | float64 | 本月已用流量 MB仅 card |
| `last_sync_time` | time | 最后同步时间(仅 card |
| `last_data_check_at` | time | 最后一次流量检查时间(仅 card |
| `last_real_name_check_at` | time | 最后一次实名检查时间(仅 card |
| `last_card_status_check_at` | time | 最后一次卡状态检查时间(仅 card |
| `device_protect_status` | string | 保护期:`none` / `stop` / `start`(仅 device |
| `cards[]` | array | 所有绑定卡的状态(仅 device同 resolve 的 cards 结构 |
---
### `POST /api/admin/assets/:identifier/refresh`
主动调网关拉取最新数据后返回,响应结构与 `realtime-status` 完全相同。
> 设备有 **30 秒冷却期**,冷却中调用返回 429。
---
### `GET /api/admin/assets/:identifier/packages`
查询该资产所有套餐记录,含虚流量换算字段。
**响应为数组,每项字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `package_usage_id` | uint | 套餐使用记录 ID |
| `package_id` | uint | 套餐 ID |
| `package_name` | string | 套餐名称 |
| `package_type` | string | `formal`(正式套餐)/ `addon`(加油包) |
| `status` | int | 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效 |
| `status_name` | string | 状态中文名 |
| `real_total_mb` | int64 | 套餐真实总量 MB |
| `real_used_mb` | int64 | 套餐真实已用量 MB |
| `virtual_total_mb` | int64 | 套餐业务停机阈值 MB |
| `virtual_used_mb` | float64 | 套餐展示已用量 MB |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `enable_virtual_data` | bool | 是否启用虚流量(按套餐使用记录快照返回) |
| `activated_at` | time | 激活时间 |
| `expires_at` | time | 到期时间 |
| `master_usage_id` | uint | 主套餐 ID加油包时有值 |
| `priority` | int | 优先级 |
| `created_at` | time | 创建时间 |
---
### `GET /api/admin/assets/:identifier/current-package`
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 `200 + null`
---
### `POST /api/admin/assets/device/:device_id/stop`
批量停机设备下所有已实名卡,停机成功后设置 **1 小时停机保护期**(保护期内禁止复机)。
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `message` | string | 操作结果描述 |
| `success_count` | int | 成功停机的卡数量 |
| `failed_cards[]` | array | 停机失败列表,每项含 `iccid``reason` |
---
### `POST /api/admin/assets/device/:device_id/start`
批量复机设备下所有已实名卡,复机成功后设置 **1 小时复机保护期**(保护期内禁止停机)。
无响应 bodyHTTP 200 即成功。
---
### `POST /api/admin/assets/card/:iccid/stop`
手动停机单张卡(通过 ICCID。若卡绑定的设备在**复机保护期**内,返回 403。
无响应 bodyHTTP 200 即成功。
---
### `POST /api/admin/assets/card/:iccid/start`
手动复机单张卡(通过 ICCID。若卡绑定的设备在**停机保护期**内,返回 403。
无响应 bodyHTTP 200 即成功。
---
## 三、删除的接口
### IoT 卡
| 删除的接口 | 替代接口 |
|-----------|---------|
| `GET /api/admin/iot-cards/by-iccid/:iccid` | `GET /api/admin/assets/resolve/:iccid` |
| `GET /api/admin/iot-cards/:iccid/gateway-status` | `GET /api/admin/assets/card/:id/realtime-status` |
| `GET /api/admin/iot-cards/:iccid/gateway-flow` | `GET /api/admin/assets/card/:id/realtime-status` |
| `GET /api/admin/iot-cards/:iccid/gateway-realname` | `GET /api/admin/assets/card/:id/realtime-status` |
| `POST /api/admin/iot-cards/:iccid/stop` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /api/admin/iot-cards/:iccid/start` | `POST /api/admin/assets/card/:iccid/start` |
### 设备
| 删除的接口 | 替代接口 |
|-----------|---------|
| `GET /api/admin/devices/:id` | `GET /api/admin/assets/resolve/:virtual_no` |
| `GET /api/admin/devices/by-identifier/:identifier` | `GET /api/admin/assets/resolve/:identifier` |
| `GET /api/admin/devices/by-identifier/:identifier/gateway-info` | `GET /api/admin/assets/device/:id/realtime-status` |
### 企业卡Admin
| 删除的接口 | 替代接口 |
|-----------|---------|
| `POST /api/admin/enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /api/admin/enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
### 企业设备H5
| 删除的接口 | 替代接口 |
|-----------|---------|
| `POST /api/h5/enterprise/devices/:device_id/suspend-card` | `POST /api/admin/assets/device/:device_id/stop` |
| `POST /api/h5/enterprise/devices/:device_id/resume-card` | `POST /api/admin/assets/device/:device_id/start` |
---
## 四、新增错误码说明
| HTTP 状态码 | 触发场景 |
|------------|---------|
| 403 | 设备在保护期内(停机 1h 内禁止复机,反之亦然);企业账号调用 resolve 接口 |
| 404 | 标识符未匹配到任何资产;当前无生效套餐 |
| 429 | 设备刷新冷却中30 秒内只能主动刷新一次) |

View File

@@ -1,407 +0,0 @@
# B 端认证系统架构说明
本文档描述君鸿卡管系统 B 端认证的架构设计、技术决策和安全机制。
---
## 系统概述
### 核心特性
- **双令牌机制**Access Token短期+ Refresh Token长期
- **Redis 存储**Token 存储在 Redis支持快速撤销
- **多平台支持**后台管理Admin和 H5 移动端
- **用户类型隔离**:不同平台限制不同的用户类型访问
- **无状态验证**Token 验证无需查询数据库
### 技术栈
| 组件 | 技术选型 | 理由 |
|------|----------|------|
| Token 生成 | UUID v4 | 高度随机,不可预测 |
| Token 存储 | Redis | 快速查询,支持 TTL 自动过期 |
| 密码哈希 | bcrypt | 慢哈希算法,抗暴力破解 |
| HTTP 框架 | Fiber v2 | 高性能,类 Express API |
| 数据库 | PostgreSQL | ACID 保证,可靠性高 |
---
## 架构图
### 认证流程
```mermaid
sequenceDiagram
participant Client as 客户端
participant Handler as AuthHandler
participant Service as AuthService
participant TokenMgr as TokenManager
participant Redis as Redis
participant DB as PostgreSQL
Note over Client,DB: 1. 登录流程
Client->>Handler: POST /api/admin/login
Handler->>Service: Login(username, password)
Service->>DB: 查询账号信息
DB-->>Service: 返回账号(含密码哈希)
Service->>Service: bcrypt 验证密码
Service->>DB: 查询用户权限
DB-->>Service: 返回权限列表
Service->>TokenMgr: GenerateTokenPair(userInfo)
TokenMgr->>Redis: 存储 access_token24h
TokenMgr->>Redis: 存储 refresh_token7天
TokenMgr-->>Service: 返回 token 对
Service-->>Handler: 返回 token + 用户信息
Handler-->>Client: 200 OK + JSON响应
Note over Client,DB: 2. 访问受保护接口
Client->>Handler: GET /api/admin/me + Bearer Token
Handler->>TokenMgr: ValidateAccessToken(token)
TokenMgr->>Redis: GET auth:token:{token}
Redis-->>TokenMgr: 返回 TokenInfo
TokenMgr-->>Handler: 返回用户上下文
Handler->>Service: GetCurrentUser(userID)
Service->>DB: 查询用户信息
DB-->>Service: 返回用户数据
Service-->>Handler: 返回用户+权限
Handler-->>Client: 200 OK + JSON响应
Note over Client,DB: 3. Token 刷新
Client->>Handler: POST /api/admin/refresh-token
Handler->>Service: RefreshToken(refresh_token)
Service->>TokenMgr: ValidateRefreshToken(token)
TokenMgr->>Redis: GET auth:refresh:{token}
Redis-->>TokenMgr: 返回 TokenInfo
TokenMgr->>TokenMgr: GenerateNewAccessToken
TokenMgr->>Redis: 存储新 access_token
TokenMgr-->>Service: 返回新 access_token
Service-->>Handler: 返回新 token
Handler-->>Client: 200 OK + new token
```
### 中间件执行顺序
```
HTTP 请求
[Recover 中间件]
[RequestID 中间件]
[Logger 中间件]
[Auth 中间件] ← 本系统
├─ 提取 Token
├─ 验证 Token调用 TokenManager
├─ 检查用户类型
└─ 设置用户上下文
[路由处理器]
├─ 从 context 获取用户信息
└─ 执行业务逻辑
HTTP 响应
```
---
## 核心组件设计
### 1. TokenManagerToken 管理器)
**职责**
- Token 生成:使用 UUID v4 生成不可预测的 Token
- Token 验证:从 Redis 查询并解析 TokenInfo
- Token 撤销:单个撤销或批量撤销用户所有 Token
- Token 刷新:验证 Refresh Token 并生成新的 Access Token
**数据结构**
```go
type TokenInfo struct {
UserID uint // 用户 ID
UserType int // 用户类型1-4
ShopID uint // 店铺 ID代理商
EnterpriseID uint // 企业 ID企业客户
Username string // 用户名
LoginTime time.Time // 登录时间
Device string // 设备类型
IP string // 登录 IP
}
```
**Redis 存储结构**
```
# Access Token
Key: auth:token:{token_uuid}
Value: JSON(TokenInfo)
TTL: 24 小时
# Refresh Token
Key: auth:refresh:{token_uuid}
Value: JSON(TokenInfo)
TTL: 7 天
# 用户 Token 列表(用于批量撤销)
Key: auth:user:{user_id}:tokens
Value: SET[token1, token2, ...]
TTL: 7 天
```
### 2. AuthService认证服务
**职责**
- 登录验证:查询账号、验证密码、生成 Token
- 权限查询:查询用户的角色和权限列表
- Token 管理:登出、刷新、批量撤销
- 密码管理:修改密码(含旧 Token 撤销)
**依赖注入**
```go
type Service struct {
accountStore *postgres.AccountStore // 账号查询
accountRoleStore *postgres.AccountRoleStore // 账号-角色关联
rolePermStore *postgres.RolePermissionStore // 角色-权限关联
permissionStore *postgres.PermissionStore // 权限查询
tokenManager *auth.TokenManager // Token 管理
logger *zap.Logger // 日志记录
}
```
### 3. Auth Middleware认证中间件
**职责**
- Token 提取:从 `Authorization: Bearer {token}` 提取 Token
- Token 验证:调用 TokenManager 验证合法性
- 用户类型检查:根据平台限制用户类型
- 上下文设置:将用户信息设置到 Fiber 和 Go Context
**配置示例**
```go
// 后台认证中间件
AdminAuth := middleware.Auth(middleware.AuthConfig{
TokenValidator: func(token string) (*middleware.UserContextInfo, error) {
// 验证 token
tokenInfo, err := tokenManager.ValidateAccessToken(ctx, token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "令牌无效")
}
// 检查用户类型:后台只允许 SuperAdmin、Platform、Agent
if tokenInfo.UserType != constants.UserTypeSuperAdmin &&
tokenInfo.UserType != constants.UserTypePlatform &&
tokenInfo.UserType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &middleware.UserContextInfo{...}, nil
},
SkipPaths: []string{"/api/admin/login", "/api/admin/refresh-token"},
})
```
---
## 安全机制
### 1. 密码安全
**Bcrypt 哈希**
- 使用 bcrypt 算法cost=10存储密码
- 每个密码有唯一的 salt防止彩虹表攻击
- 慢哈希算法,增加暴力破解成本
```go
// 密码哈希(注册时)
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 10)
// 密码验证(登录时)
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
```
### 2. Token 安全
**不可预测性**
- 使用 UUID v4 生成128 位随机数
- 碰撞概率极低(约 1/2^122
**短生命周期**
- Access Token24 小时自动过期
- Refresh Token7 天自动过期
- 修改密码后立即撤销所有旧 Token
**传输安全**
- 仅通过 Authorization 请求头传递(不在 URL 中)
- 生产环境强制 HTTPS
### 3. 用户类型隔离
| 平台 | 允许访问 | 拒绝访问 |
|------|----------|----------|
| 后台 | SuperAdmin(1), Platform(2), Agent(3) | Enterprise(4), PersonalCustomer |
| H5 | Agent(3), Enterprise(4) | SuperAdmin(1), Platform(2), PersonalCustomer |
### 4. 防御措施
**防止暴力破解**
- 计划引入登录失败次数限制(待实现)
- 使用慢哈希算法bcrypt增加单次尝试成本
**防止 Token 泄露**
- Token 不出现在日志中(敏感信息脱敏)
- Token 不出现在 URL 中
- Redis 连接使用密码保护
**防止会话劫持**
- Token 绑定设备和 IP存储在 TokenInfo 中,可用于审计)
- 可选:实现设备指纹验证(待实现)
---
## 设计决策
### 为什么选择 Redis 而非 JWT
| 对比项 | Redis Token | JWT |
|--------|-------------|-----|
| 撤销能力 | ✅ 立即生效 | ❌ 无法撤销 |
| 性能 | ✅ 5msRedis 查询) | ✅ 0ms本地验证 |
| 存储负担 | ⚠️ Redis 内存 | ✅ 无服务端存储 |
| 灵活性 | ✅ 可存储复杂信息 | ⚠️ Payload 有大小限制 |
| 适用场景 | B 端系统(需要撤销) | C 端系统(高并发) |
**决策理由**
- B 端用户数量有限(< 1000Redis 内存负担可接受
- 修改密码、账号禁用等场景需要立即撤销 Token
- 需要存储完整的用户上下文信息ShopID、EnterpriseID 等)
### 为什么使用双令牌机制?
**问题**:如果只有一个 Token
- 短生命周期:用户频繁掉线,体验差
- 长生命周期Token 泄露风险增加
**解决方案**
- Access Token24小时用于 API 访问,频繁传输,短生命周期降低泄露风险
- Refresh Token7天用于刷新 Access Token低频传输长生命周期减少掉线
### 为什么密码修改要撤销所有 Token
**安全原因**
- 假设:用户发现密码泄露,立即修改密码
- 如果不撤销旧 Token攻击者仍可使用旧 Token 访问
**实现**
```go
func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword, newPassword string) error {
// 1. 验证旧密码
// 2. 哈希新密码
// 3. 更新数据库
// 4. 撤销所有旧 Token
return s.tokenManager.RevokeAllUserTokens(ctx, userID)
}
```
---
## 性能考量
### Redis 性能
**预期负载**
- 用户数:< 1000
- 每用户平均 Token 数2-3 个
- 总 Token 数:< 3000
- Redis 内存占用:< 3MB每个 TokenInfo 约 1KB
**性能指标**
- Token 验证:< 5msRedis GET 操作)
- Token 生成:< 10msRedis SET + SADD 操作)
- Token 撤销:< 5msRedis DEL 操作)
### 数据库查询优化
**登录流程优化**
1. 账号查询:使用 `username``phone` 索引(< 10ms
2. 权限查询:使用 `account_id` 索引(< 20ms
3. 总耗时:< 50ms
**缓存策略**(待实现):
- 用户权限列表可缓存 30 分钟
- 减少数据库查询压力
---
## 扩展性
### 水平扩展
**无状态设计**
- 认证服务无状态,可水平扩展
- Token 存储在 Redis所有实例共享
**Redis 集群**
- 当前使用单机 Redis
- 需要时可升级为 Redis Cluster 或 Sentinel
### 功能扩展
**可选功能**
- [ ] 设备指纹验证
- [ ] 登录失败次数限制
- [ ] 异地登录提醒
- [ ] 在线设备管理
- [ ] Token 黑名单
---
## 监控和审计
### 关键指标
| 指标 | 说明 | 告警阈值 |
|------|------|----------|
| 登录成功率 | 成功次数 / 总次数 | < 95% |
| Token 验证失败率 | 失败次数 / 总次数 | > 5% |
| Redis 可用性 | Ping 响应时间 | > 10ms |
| Token 平均验证时间 | P95 响应时间 | > 20ms |
### 审计日志
**记录事件**
- 用户登录(成功/失败)
- Token 撤销(单个/批量)
- 密码修改
- 账号状态变更
**日志格式**
```json
{
"level": "info",
"timestamp": "2026-01-15T16:15:00+08:00",
"event": "user_login",
"user_id": 1,
"username": "admin",
"ip": "127.0.0.1",
"device": "web",
"success": true
}
```
---
## 相关文档
- [API 文档](api/auth.md) - 完整的 API 接口说明
- [使用指南](auth-usage-guide.md) - 如何在代码中集成认证
- [错误处理指南](003-error-handling/使用指南.md) - 统一错误处理
---
**文档版本**: v1.0
**最后更新**: 2026-01-15
**维护者**: 君鸿卡管系统开发团队

View File

@@ -1,507 +0,0 @@
# B 端认证系统使用指南
本文档指导开发者如何在君鸿卡管系统中使用 B 端认证功能,包括在新路由中集成认证、获取用户信息、撤销 Token 等操作。
---
## 目录
- [快速开始](#快速开始)
- [在路由中集成认证](#在路由中集成认证)
- [获取当前用户信息](#获取当前用户信息)
- [Token 管理](#token-管理)
- [常见问题](#常见问题)
- [最佳实践](#最佳实践)
---
## 快速开始
###认证系统已集成到项目的 bootstrap 流程中,无需额外配置即可使用。
### 核心组件
| 组件 | 位置 | 用途 |
|------|------|------|
| TokenManager | `pkg/auth/token.go` | Token 生成、验证、撤销 |
| AuthService | `internal/service/auth/service.go` | 认证业务逻辑 |
| Auth Middleware | `pkg/middleware/auth.go` | 认证中间件 |
| Auth Handler | `internal/handler/{admin,h5}/auth.go` | 认证接口处理器 |
### 配置项
通过环境变量配置 Token 有效期:
```bash
# JWT 配置
export JUNHONG_JWT_SECRET_KEY="your-secret-key-here"
export JUNHONG_JWT_TOKEN_DURATION="24h" # JWT 有效期(个人客户)
export JUNHONG_JWT_ACCESS_TOKEN_TTL="24h" # Access Token 有效期B端
export JUNHONG_JWT_REFRESH_TOKEN_TTL="168h" # Refresh Token 有效期B端7天
```
详细配置说明见 [环境变量配置文档](environment-variables.md)
---
## 在路由中集成认证
### 1. 使用现有的认证中间件
后台和 H5 的认证中间件已在 `internal/bootstrap/middlewares.go` 中配置好。
**后台路由示例**
```go
// internal/routes/admin.go
func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
// 公开路由(无需认证)
router.Post(basePath+"/login", handlers.AdminAuth.Login)
router.Post(basePath+"/refresh-token", handlers.AdminAuth.RefreshToken)
// 受保护路由(需要认证)
authGroup := router.Group("", middlewares.AdminAuth)
authGroup.Post(basePath+"/logout", handlers.AdminAuth.Logout)
authGroup.Get(basePath+"/me", handlers.AdminAuth.GetMe)
authGroup.Post(basePath+"/password", handlers.AdminAuth.ChangePassword)
// 添加其他需要认证的路由
authGroup.Get(basePath+"/users", handlers.User.List)
authGroup.Post(basePath+"/users", handlers.User.Create)
}
```
**H5 路由示例**
```go
// internal/routes/h5.go
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
// 公开路由
router.Post(basePath+"/login", handlers.H5Auth.Login)
// 受保护路由
authGroup := router.Group("", middlewares.H5Auth)
authGroup.Get(basePath+"/orders", handlers.Order.List)
}
```
### 2. 创建自定义认证中间件
如果需要自定义认证逻辑(例如特殊权限检查),可以创建自己的中间件:
```go
// internal/middleware/custom_auth.go
package middleware
import (
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/gofiber/fiber/v2"
)
// SuperAdminOnly 只允许超级管理员访问
func SuperAdminOnly(tokenManager *auth.TokenManager) 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, "令牌无效")
}
// 只允许超级管理员
if tokenInfo.UserType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &pkgmiddleware.UserContextInfo{
UserID: tokenInfo.UserID,
UserType: tokenInfo.UserType,
ShopID: tokenInfo.ShopID,
EnterpriseID: tokenInfo.EnterpriseID,
}, nil
},
SkipPaths: []string{}, // 无公开路径
})
}
```
---
## 获取当前用户信息
### 1. 在 Handler 中获取用户 ID
使用 `pkg/middleware` 提供的工具函数:
```go
// internal/handler/admin/user.go
package admin
import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
)
type UserHandler struct {
userService *user.Service
}
func (h *UserHandler) GetProfile(c *fiber.Ctx) error {
// 从 context 获取当前用户 ID
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 使用 userID 查询用户信息
profile, err := h.userService.GetProfile(c.UserContext(), userID)
if err != nil {
return err
}
return response.Success(c, profile)
}
```
### 2. 获取完整的用户上下文
```go
func (h *UserHandler) DoSomething(c *fiber.Ctx) error {
ctx := c.UserContext()
// 获取各种用户信息
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
// 根据用户类型执行不同逻辑
switch userType {
case constants.UserTypeSuperAdmin:
// 超级管理员逻辑
case constants.UserTypeAgent:
// 代理商逻辑,使用 shopID
case constants.UserTypeEnterprise:
// 企业客户逻辑,使用 enterpriseID
}
return response.Success(c, nil)
}
```
### 3. 在 Service 层使用用户信息
Service 层应通过参数接收用户信息,而不是直接从 context 获取:
```go
// internal/service/order/service.go
package order
type Service struct {
orderStore *postgres.OrderStore
}
// 推荐:显式传递 userID
func (s *Service) ListOrders(ctx context.Context, userID uint, filters *OrderFilters) ([]*model.Order, error) {
// 根据用户权限过滤订单
return s.orderStore.ListByUser(ctx, userID, filters)
}
// 不推荐:从 context 中获取
// func (s *Service) ListOrders(ctx context.Context, filters *OrderFilters) ([]*model.Order, error) {
// userID := middleware.GetUserIDFromContext(ctx) // 不推荐
// ...
// }
```
---
## Token 管理
### 1. 生成 Token
在认证服务中已实现,无需手动调用。如需在其他场景使用:
```go
package myservice
import (
"github.com/break/junhong_cmp_fiber/pkg/auth"
)
func (s *Service) IssueTokenForUser(ctx context.Context, userID uint) (string, string, error) {
tokenInfo := &auth.TokenInfo{
UserID: userID,
UserType: 1,
ShopID: 0,
EnterpriseID: 0,
Username: "user",
Device: "web",
IP: "127.0.0.1",
}
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
if err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
```
### 2. 验证 Token
Token 验证已由中间件自动完成。如需手动验证:
```go
func (s *Service) ManuallyValidateToken(ctx context.Context, token string) (*auth.TokenInfo, error) {
tokenInfo, err := s.tokenManager.ValidateAccessToken(ctx, token)
if err != nil {
return nil, err
}
return tokenInfo, nil
}
```
### 3. 撤销 Token
**撤销单个 Token**
```go
func (s *Service) RevokeToken(ctx context.Context, token string) error {
return s.tokenManager.RevokeToken(ctx, token)
}
```
**撤销用户所有 Token**(例如修改密码后):
```go
func (s *Service) RevokeAllUserTokens(ctx context.Context, userID uint) error {
return s.tokenManager.RevokeAllUserTokens(ctx, userID)
}
```
---
## 常见问题
### Q1: 如何测试需要认证的接口?
**方法 1使用真实 Token**
```bash
# 1. 先登录获取 token
TOKEN=$(curl -s -X POST http://localhost:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin@123456"}' \
| jq -r '.data.access_token')
# 2. 使用 token 访问接口
curl -X GET http://localhost:8080/api/admin/users \
-H "Authorization: Bearer $TOKEN"
```
**方法 2在集成测试中模拟**
```go
// tests/integration/user_test.go
func TestListUsers(t *testing.T) {
// 创建测试账号
account := createTestAccount(t)
// 生成 token
tokenManager := auth.NewTokenManager(redisClient, 24*time.Hour, 7*24*time.Hour)
accessToken, _, err := tokenManager.GenerateTokenPair(ctx, &auth.TokenInfo{
UserID: account.ID,
UserType: account.UserType,
Username: account.Username,
})
require.NoError(t, err)
// 发送请求
req := httptest.NewRequest("GET", "/api/admin/users", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
```
### Q2: 如何处理 Token 过期?
前端应捕获 `1003` 错误码,自动使用 Refresh Token 刷新:
```javascript
// 前端示例(伪代码)
async function apiRequest(url, options) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${getAccessToken()}`
}
});
// Token 过期
if (response.status === 401 && response.data.code === 1003) {
// 刷新 token
const newToken = await refreshAccessToken();
setAccessToken(newToken);
// 重试原请求
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`
}
});
}
return response;
}
async function refreshAccessToken() {
const response = await fetch('/api/admin/refresh-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: getRefreshToken() })
});
const data = await response.json();
return data.data.access_token;
}
```
### Q3: 如何区分后台和 H5 用户?
通过 `userType` 字段区分:
```go
userType := middleware.GetUserTypeFromContext(ctx)
switch userType {
case constants.UserTypeSuperAdmin: // 1
// 超级管理员
case constants.UserTypePlatform: // 2
// 平台用户
case constants.UserTypeAgent: // 3
// 代理商(后台和 H5 都可以)
case constants.UserTypeEnterprise: // 4
// 企业客户(仅 H5
}
```
### Q4: 如何实现"记住我"功能?
当前系统不支持"记住我"。如需实现:
1. 增加一个长期 Token 类型30 天)
2. 前端存储到 LocalStorage 或 Cookie
3. 后端需要额外的安全机制(如设备指纹)
---
## 最佳实践
### 1. 安全实践
**推荐做法**
- 所有敏感操作(修改密码、删除数据)要求二次验证
- Token 存储在 HttpOnly Cookie 或安全存储中
- 使用 HTTPS 传输
- 定期更新密码
- 修改密码后撤销所有旧 Token
**避免做法**
- 不要在 URL 中传递 Token
- 不要在浏览器 LocalStorage 中存储 TokenXSS 风险)
- 不要在日志中记录完整 Token
- 不要与他人分享 Token
### 2. 错误处理
Handler 应返回 `*errors.AppError`,由全局 ErrorHandler 统一处理:
```go
func (h *UserHandler) Create(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
// 返回 AppError不要自己构造 JSON
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
// ... 业务逻辑
return response.Success(c, result)
}
```
### 3. 性能优化
- Token 验证操作已由 Redis 优化,平均耗时 < 5ms
- 避免在循环中重复验证 Token
- 使用批量操作减少 Redis 调用
### 4. 日志记录
记录关键认证事件:
```go
import "go.uber.org/zap"
// 登录成功
logger.Info("用户登录成功",
zap.Uint("user_id", userID),
zap.String("username", username),
zap.String("ip", clientIP),
zap.String("device", device),
)
// 登录失败
logger.Warn("登录失败",
zap.String("username", username),
zap.String("ip", clientIP),
zap.String("reason", "密码错误"),
)
// Token 撤销
logger.Info("Token 已撤销",
zap.Uint("user_id", userID),
zap.String("reason", "修改密码"),
)
```
### 5. 测试覆盖
确保以下场景有测试覆盖:
- [x] 登录成功
- [x] 登录失败(密码错误、账号禁用)
- [x] Token 验证成功
- [x] Token 过期处理
- [x] Token 刷新
- [x] 修改密码后 Token 失效
- [x] 并发访问
---
## 相关文档
- [API 文档](api/auth.md) - 完整的 API 接口说明
- [架构说明](auth-architecture.md) - 认证系统架构设计
- [错误处理指南](003-error-handling/使用指南.md) - 统一错误处理
---
**文档版本**: v1.0
**最后更新**: 2026-01-15
**维护者**: 君鸿卡管系统开发团队

View File

@@ -1,128 +0,0 @@
# 客户端接口数据模型基础准备 - 功能总结
## 概述
本提案作为客户端接口系列的前置基础完成三类工作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

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
# 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_token5分钟有效
微信授权(前端完成)
├── 公众号 → [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 → 进入主页面
```
## 核心设计
### 有状态 JWTJWT + Redis
- JWT payload 仅含 `customer_id` + `exp`
- 登录时将 token 写入 RedisTTL 与 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` | 认证 Handler7 个端点) |
| `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` | 是否要求绑定手机号 |

View File

@@ -1,122 +0,0 @@
# 客户端核心业务 API — 功能总结
## 概述
本提案为客户端C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。
**前置依赖**:提案 0数据模型修复、提案 1C 端认证系统)。
## 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

View File

@@ -1,94 +0,0 @@
# 客户端换货系统功能总结
## 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 级问题)。

View File

@@ -1,30 +0,0 @@
# C 端微信 AppID 获取接口功能总结
## 功能概述
新增 C 端免登录接口 `GET /api/c/v1/wechat/appid`,用于返回当前生效微信配置中的公众号 `AppID`
## 接口行为
- 无需登录即可访问
- 统一从当前生效微信配置读取 `oa_app_id`
- 响应 `data` 中仅返回 `app_id` 字段
- 当不存在生效配置或公众号 `AppID` 为空时,返回 `微信配置不可用`
## 适用场景
- 前端在发起公众号授权前,先获取当前环境应使用的 `AppID`
- 前端在初始化微信相关能力时,避免硬编码 `AppID`
## 返回示例
```json
{
"code": 0,
"msg": "success",
"data": {
"app_id": "wx1234567890abcdef"
},
"timestamp": 1745999999
}
```

View File

@@ -1,326 +0,0 @@
# Code Review 检查清单
## 📋 DTO 规范检查清单
在提交 Pull Request 前,请确保所有 DTO 文件遵循以下规范。
---
## ✅ 必须项MUST
### 1. Description 标签规范
**所有字段必须使用 `description` 标签,禁止使用行内注释**
**错误示例**
```go
type CreateUserRequest struct {
Username string `json:"username"` // 用户名
Status int `json:"status"` // 状态
}
```
**正确示例**
```go
type CreateUserRequest struct {
Username string `json:"username" description:"用户名"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
}
```
---
### 2. 枚举字段必须列出所有可能值(中文说明)
**所有枚举类型字段必须在 `description` 中列出所有可能值和对应的中文含义**
#### 用户类型
```go
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
```
#### 角色类型
```go
RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"`
```
#### 权限类型
```go
PermType int `json:"perm_type" description:"权限类型 (1:菜单, 2:按钮)"`
```
#### 状态字段
```go
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
```
#### 适用端口
```go
Platform string `json:"platform" description:"适用端口 (all:全部, web:Web后台, h5:H5端)"`
```
---
### 3. 验证标签与 OpenAPI 标签一致
**所有验证约束必须同时在 `validate` 和 OpenAPI 标签中声明**
**正确示例**
```go
Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"用户名"`
```
**标签对照表**
| validate 标签 | OpenAPI 标签 | 说明 |
|--------------|--------------|------|
| `required` | `required:"true"` | 必填字段 |
| `min=N,max=M` | `minimum:"N" maximum:"M"` | 数值范围 |
| `min=N,max=M` (字符串) | `minLength:"N" maxLength:"M"` | 字符串长度 |
| `len=N` | `minLength:"N" maxLength:"N"` | 固定长度 |
| `oneof=A B C` | `description` 中说明 | 枚举值 |
---
### 4. 请求参数类型标签
**Query 参数和 Path 参数必须添加对应标签**
#### Query 参数
```go
type ListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
UserType *int `json:"user_type" query:"user_type" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"用户类型"`
}
```
#### Path 参数
```go
type IDReq struct {
ID uint `path:"id" description:"ID" required:"true"`
}
```
---
### 5. 响应 DTO 完整性
**所有响应 DTO 的字段都必须有完整的 `description` 标签**
**正确示例**
```go
type AccountResponse struct {
ID uint `json:"id" description:"账号ID"`
Username string `json:"username" description:"用户名"`
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
```
---
## 🔍 推荐项SHOULD
### 6. 字段顺序规范
建议按以下顺序组织字段:
1. 主键字段ID
2. 业务核心字段(名称、编号等)
3. 关联字段(外键)
4. 状态字段
5. 审计字段Creator、Updater、CreatedAt、UpdatedAt
---
### 7. 中文注释完整性
**所有导出的结构体、方法、常量必须有中文 godoc 注释**
**正确示例**
```go
// CreateAccountRequest 创建账号请求
type CreateAccountRequest struct {
// ...
}
// AccountResponse 账号响应
type AccountResponse struct {
// ...
}
```
---
### 8. 必填字段和可选字段区分
**使用指针类型标识可选字段**
```go
type UpdateRequest struct {
Username *string `json:"username" description:"用户名(可选)"` // 可选
Status *int `json:"status" description:"状态(可选)"` // 可选
}
type CreateRequest struct {
Username string `json:"username" required:"true" description:"用户名"` // 必填
Status int `json:"status" required:"true" description:"状态"` // 必填
}
```
---
## 🚫 禁止项MUST NOT
### 9. 禁止使用英文枚举值说明
**错误**
```go
UserType int `json:"user_type" description:"用户类型 (1:SuperAdmin, 2:Platform, 3:Agent, 4:Enterprise)"`
```
**正确**
```go
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
```
---
### 10. 禁止缺失枚举值说明
**错误**
```go
Status int `json:"status" description:"状态"`
```
**正确**
```go
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
```
---
### 11. 禁止不一致的 description
**同一字段在不同 DTO 中必须使用一致的 description**
**正确示例**
```go
// CreateAccountRequest
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
// AccountListRequest
UserType *int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
// AccountResponse
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
```
---
## 📝 常见枚举字段参考
### 用户类型UserType
```go
description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"
```
### 角色类型RoleType
```go
description:"角色类型 (1:平台角色, 2:客户角色)"
```
### 权限类型PermType
```go
description:"权限类型 (1:菜单, 2:按钮)"
```
### 适用端口Platform
```go
description:"适用端口 (all:全部, web:Web后台, h5:H5端)"
```
### 状态Status
```go
description:"状态 (0:禁用, 1:启用)"
```
### 店铺层级Level
```go
description:"店铺层级 (1-7级)"
```
---
## 🔧 检查工具
### 手动检查
在提交前运行以下命令检查 DTO 文件:
```bash
# 查找没有 description 标签的 json 字段
grep -rn 'json:"[^"]*"' internal/model/*_dto.go | grep -v 'description:'
# 查找使用行内注释的字段(应该改为 description 标签)
grep -rn '`json:.*`.*//\s' internal/model/*_dto.go
# 查找可能缺少枚举值说明的 status 字段
grep -rn 'Status.*json:"status".*description:"状态"$' internal/model/*_dto.go
```
### 自动生成文档验证
```bash
# 生成 OpenAPI 文档
go run cmd/gendocs/main.go
# 检查生成的文档中是否包含完整的枚举值说明
grep -A 3 "user_type:" docs/admin-openapi.yaml
grep -A 3 "status:" docs/admin-openapi.yaml
```
---
## 📚 相关文档
- [项目开发规范](../AGENTS.md)
- [OpenAPI 文档生成说明](../README.md#openapi-文档)
- [RBAC 用户类型定义](../pkg/constants/constants.go)
---
## ✅ Code Review 检查清单(用于审查者)
在审查 Pull Request 时,请逐项检查:
- [ ] 所有新增/修改的 DTO 字段都有 `description` 标签
- [ ] 所有枚举字段的 description 包含完整的可能值和中文说明
- [ ] 所有状态字段明确说明了 0 和 1 的含义
- [ ] validate 标签与 OpenAPI 标签required、minLength、maximum 等)一致
- [ ] Query 参数添加了 `query` 标签
- [ ] Path 参数添加了 `path` 标签
- [ ] 响应 DTO 的所有字段都有完整说明
- [ ] 同一字段在不同 DTO 中使用一致的 description
- [ ] 所有枚举值使用中文说明,禁止使用英文
- [ ] 没有使用行内注释替代 description 标签
- [ ] 所有导出的结构体有中文 godoc 注释
---
## 🎯 完成标准
当所有检查项都通过时,才能合并 PR。
**文档生成验证**
```bash
go run cmd/gendocs/main.go
# 检查 docs/admin-openapi.yaml 中所有字段都有清晰的中文说明
```
---
**最后更新**: 2026-01-20
**维护者**: 开发团队

View File

@@ -1,319 +0,0 @@
# 差价佣金计算流程图
## 一、订单支付到佣金入账的完整流程
```
┌─────────────────────────────────────────────────────────────────────┐
│ 客户下单购买套餐 │
└────────────────────────────┬────────────────────────────────────────┘
┌────────▼────────┐
│ 创建订单 │
│ order.id = 123 │
│ commission_status = 1 (待计算)
│ payment_status = 1 (待支付)
└────────┬────────┘
┌────────▼────────┐
│ 用户支付 │
│ WalletPay() │
└────────┬────────┘
┌────────▼────────┐
│ 更新订单状态 │
│ payment_status = 2 (已支付)
│ paid_at = now │
└────────┬────────┘
┌────────▼────────────────────────┐
│ 激活套餐 │
│ activatePackage() │
│ (分配套餐到卡/设备) │
└────────┬────────────────────────┘
┌────────▼────────────────────────┐
│ 入队佣金计算任务 │
│ enqueueCommissionCalculation() │
│ task_type = "commission:calculate"
│ payload = {order_id: 123} │
└────────┬────────────────────────┘
│ (异步处理)
┌────────────────────▼────────────────────┐
│ Asynq Worker 处理任务 │
│ HandleCommissionCalculation() │
└────────────────────┬────────────────────┘
┌────────▼────────────────────┐
│ CalculateCommission() │
│ 开启数据库事务 │
└────────┬────────────────────┘
┌────────────────────▼────────────────────┐
│ 1. 检查订单佣金状态 │
│ if commission_status == 2 → 跳过 │
└────────────────────┬────────────────────┘
┌────────────────────▼────────────────────┐
│ 2. 计算成本价差佣金 │
│ CalculateCostDiffCommission() │
└────────────────────┬────────────────────┘
```
## 二、成本价差佣金计算详细流程
```
┌──────────────────────────────────────────────────────────────┐
│ CalculateCostDiffCommission(order) │
└────────────────────┬─────────────────────────────────────────┘
┌────────────▼────────────┐
│ 获取销售店铺信息 │
│ seller_shop_id = 10 │
└────────────┬────────────┘
┌────────────▼────────────────────────────┐
│ 计算销售端利润 │
│ profit = total_amount - seller_cost_price
│ profit = 200 - 130 = 70元 │
│ 为销售店铺创建佣金记录 │
│ commission_record { │
│ shop_id: 10, │
│ amount: 70, │
│ commission_source: "cost_diff" │
│ } │
└────────────┬────────────────────────────┘
┌────────────▼────────────────────────────┐
│ 链式向上计算 │
│ current_shop_id = seller_shop_id │
│ child_cost_price = seller_cost_price │
└────────────┬────────────────────────────┘
┌────────────▼────────────────────────────┐
│ 循环while current_shop_id != null │
└────────────┬────────────────────────────┘
┌────────────▼────────────────────────────┐
│ 1. 获取上级店铺 │
│ parent_shop_id = current_shop.parent_id
│ if parent_shop_id == null → 结束 │
└────────────┬────────────────────────────┘
┌────────────▼────────────────────────────┐
│ 2. 查询上级的套餐分配配置 │
│ allocation = ShopPackageAllocation │
│ where shop_id = parent_shop_id │
│ and package_id = order.package_id │
└────────────┬────────────────────────────┘
├─ 未找到 ──┐
│ │
│ ┌──────▼──────────────────┐
│ │ 链路断裂处理 │
│ │ 创建待审佣金记录 │
│ │ status = 99 │
│ │ amount = 0 │
│ │ remark = "套餐系列未分配" │
│ │ 结束循环 │
│ └─────────────────────────┘
└─ 找到 ──┐
┌─────────────────────▼──────────────────┐
│ 3. 计算差价 │
│ my_cost_price = allocation.cost_price
│ profit = child_cost_price - my_cost_price
│ profit = 130 - 120 = 10元 │
└─────────────────────┬──────────────────┘
┌─────────────────────▼──────────────────┐
│ 4. 如果差价 > 0创建佣金记录 │
│ commission_record { │
│ shop_id: parent_shop_id, │
│ amount: profit, │
│ commission_source: "cost_diff" │
│ } │
└─────────────────────┬──────────────────┘
┌─────────────────────▼──────────────────┐
│ 5. 更新变量,继续向上 │
│ child_cost_price = my_cost_price │
│ current_shop_id = parent_shop_id │
│ 继续循环 │
└─────────────────────┬──────────────────┘
┌─────────▴─────────┐
│ 返回佣金记录列表 │
└───────────────────┘
```
## 三、佣金入账流程
```
┌──────────────────────────────────────────────────────────────┐
│ 对每条佣金记录调用 creditCommissionInTx() │
└────────────────────┬─────────────────────────────────────────┘
┌────────────▼────────────────────────┐
│ 1. 获取店铺的分佣钱包 │
│ wallet = AgentWallet │
│ where shop_id = record.shop_id │
│ and wallet_type = 'commission' │
└────────────┬────────────────────────┘
┌────────────▼────────────────────────┐
│ 2. 增加钱包余额 │
│ wallet.balance += record.amount │
│ wallet.version += 1 │
│ (乐观锁防并发) │
└────────────┬────────────────────────┘
┌────────────▼────────────────────────┐
│ 3. 更新佣金记录 │
│ commission_record.status = 3 │
│ (CommissionStatusReleased) │
│ commission_record.released_at = now
│ commission_record.balance_after │
│ = balance_before + amount │
└────────────┬────────────────────────┘
┌────────────▼────────────────────────┐
│ 4. 创建钱包交易记录 │
│ transaction { │
│ agent_wallet_id: wallet.id, │
│ transaction_type: 'commission',│
│ amount: record.amount, │
│ balance_before: ..., │
│ balance_after: ..., │
│ reference_type: 'commission', │
│ reference_id: record.id │
│ } │
└────────────┬────────────────────────┘
┌────────────▼────────────────────────┐
│ 5. 事务提交 │
│ 佣金入账完成 │
└────────────────────────────────────┘
```
## 四、订单状态转换图
```
┌─────────────────┐
│ 订单创建 │
└────────┬────────┘
┌────────▼────────────────────┐
│ commission_status = 1 │
│ payment_status = 1 │
│ (待计算, 待支付) │
└────────┬────────────────────┘
┌────────▼────────────────────┐
│ 用户支付 │
└────────┬────────────────────┘
┌────────▼────────────────────┐
│ payment_status = 2 │
│ (已支付) │
│ commission_status = 1 │
│ (待计算) │
└────────┬────────────────────┘
┌────────▼────────────────────┐
│ 异步佣金计算 │
└────────┬────────────────────┘
┌────────▼────────────────────┐
│ commission_status = 2 │
│ (已计算) │
│ 佣金已入账到代理钱包 │
└────────────────────────────┘
```
## 五、佣金记录状态转换图
```
┌──────────────────┐
│ 佣金记录创建 │
│ status = 3 │
│ (已发放) │
└────────┬─────────┘
┌────────▼──────────────────┐
│ 入账到代理钱包 │
│ released_at = now │
│ balance_after = ... │
└────────┬──────────────────┘
┌────────▼──────────────────┐
│ 佣金可用于提现 │
│ 或继续分配给下级 │
└───────────────────────────┘
┌──────────────────┐
│ 链路断裂情况 │
│ status = 99 │
│ (待人工修正) │
└────────┬─────────┘
┌────────▼──────────────────┐
│ 平台人工审核 │
│ 修正或失效 │
└───────────────────────────┘
```
## 六、代理链佣金分配示例
```
平台 (基础成本价: 100元)
├─ 代理A (成本价: 120元)
│ │
│ ├─ 代理A1 (成本价: 130元)
│ │ │
│ │ └─ 代理A1a (成本价: 140元)
│ │ │
│ │ └─ 客户购买,售价: 200元
│ │
│ └─ 代理A2 (成本价: 135元)
└─ 代理B (成本价: 125元)
订单信息:
seller_shop_id = A1a (代理A1a)
seller_cost_price = 140元
total_amount = 200元
佣金计算结果:
┌─────────────────────────────────────────┐
│ A1a (销售店铺) │
│ 销售利润 = 200 - 140 = 60元 │
│ 佣金记录: amount=60, commission_source=cost_diff
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ A1 (A1a的上级) │
│ 差价 = 140 - 130 = 10元 │
│ 佣金记录: amount=10, commission_source=cost_diff
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ A (A1的上级) │
│ 差价 = 130 - 120 = 10元 │
│ 佣金记录: amount=10, commission_source=cost_diff
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 平台 (A的上级) │
│ 差价 = 120 - 100 = 20元 │
│ (平台收入,不作为佣金) │
└─────────────────────────────────────────┘
总计: 60 + 10 + 10 + 20 = 100元 ✓
```

View File

@@ -1,351 +0,0 @@
# 套餐与佣金业务模型
本文档定义了套餐、套餐系列、佣金的完整业务模型,作为系统改造的规范参考。
---
## 一、核心概念
### 1.1 两种佣金类型
系统只有两种佣金类型:
| 佣金类型 | 触发时机 | 触发次数 | 计算方式 |
|---------|---------|---------|---------|
| **差价佣金** | 每笔订单 | 每单都触发 | 下级成本价 - 自己成本价 |
| **一次性佣金** | 首充/累计充值达标 | 每张卡/设备只触发一次 | 上级给的 - 给下级的 |
### 1.2 实体关系
```
┌─────────────────┐
│ 套餐系列 │
│ PackageSeries │
├─────────────────┤
│ • 系列名称 │
│ • 一次性佣金规则 │ ← 可选配置
└────────┬────────┘
│ 1:N
┌─────────────────┐ ┌─────────────────┐
│ 套餐 │ │ 卡/设备 │
│ Package │ │ IoT/Device │
├─────────────────┤ ├─────────────────┤
│ • 成本价 │ │ • 绑定系列ID │
│ • 建议售价 │ │ • 累计充值金额 │ ← 按系列累计
│ • 真流量(必填) │ │ • 是否已首充 │ ← 按系列记录
│ • 虚流量(可选) │ └────────┬────────┘
│ • 虚流量开关 │ │
└────────┬────────┘ │ 分配
│ ▼
│ 分配 ┌─────────────────┐
▼ │ 店铺 │
┌─────────────────┐ │ Shop │
│ 套餐分配 │◀─────────┤ • 代理层级 │
│ PkgAllocation │ │ • 上级店铺ID │
├─────────────────┤ └─────────────────┘
│ • 店铺ID │
│ • 套餐ID │
│ • 成本价(加价后)│
│ • 一次性佣金额 │ ← 给该代理的金额
└─────────────────┘
```
---
## 二、套餐模型
### 2.1 字段定义
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `cost_price` | int64 | 是 | 成本价(平台设置的基础成本价,分) |
| `suggested_price` | int64 | 是 | 建议售价(给代理参考,分) |
| `real_data_mb` | int64 | 是 | 真实流量额度MB |
| `enable_virtual_data` | bool | 否 | 是否启用虚流量 |
| `virtual_data_mb` | int64 | 否 | 虚流量额度(启用时必填,≤ 真实流量MB |
### 2.2 流量停机判断
```
停机目标值 = enable_virtual_data ? virtual_data_mb : real_data_mb
```
### 2.3 不同用户视角
| 用户类型 | 看到的成本价 | 看到的一次性佣金 |
|---------|-------------|-----------------|
| 平台 | 基础成本价 | 完整规则 |
| 代理A | A的成本价已加价 | A能拿到的金额 |
| 代理A1 | A1的成本价再加价 | A1能拿到的金额 |
---
## 三、差价佣金
### 3.1 计算规则
```
平台设置基础成本价: 100
│ 分配给代理A设置成本价: 120
代理A成本价: 120
│ 分配给代理A1设置成本价: 130
代理A1成本价: 130
│ A1销售给客户售价: 200
结果:
• A1 收入 = 200 - 130 = 70元销售利润不是佣金
• A 佣金 = 130 - 120 = 10元差价佣金
• 平台收入 = 120元
```
### 3.2 关键区分
- **收入/利润**:末端代理的 `售价 - 自己成本价`
- **差价佣金**:上级代理的 `下级成本价 - 自己成本价`
- **平台收入**:一级代理的成本价
---
## 四、一次性佣金
### 4.1 触发条件
| 条件类型 | 说明 | 强充要求 |
|---------|------|---------|
| `first_recharge` | 首充:该卡/设备在该系列下的第一次充值 | 必须强充 |
| `accumulated_recharge` | 累计充值:累计充值金额达到阈值 | 可选强充 |
### 4.2 规则配置(套餐系列层面)
| 配置项 | 类型 | 说明 |
|--------|------|------|
| `enable` | bool | 是否启用一次性佣金 |
| `trigger_type` | string | 触发类型:`first_recharge` / `accumulated_recharge` |
| `threshold` | int64 | 触发阈值(分):首充要求金额 或 累计要求金额 |
| `commission_type` | string | 返佣类型:`fixed`(固定) / `tiered`(梯度) |
| `commission_amount` | int64 | 固定返佣金额fixed类型时 |
| `tiers` | array | 梯度配置tiered类型时 |
| `validity_type` | string | 时效类型:`permanent` / `fixed_date` / `relative` |
| `validity_value` | string | 时效值(到期日期 或 月数) |
| `enable_force_recharge` | bool | 是否启用强充 |
| `force_calc_type` | string | 强充金额计算:`fixed`(固定) / `dynamic`(动态差额) |
| `force_amount` | int64 | 强充金额fixed类型时 |
### 4.3 链式分配
一次性佣金在整条代理链上按约定分配:
```
系列规则首充100返20
分配配置:
平台给A20元
A给A18元
A1给A25元
触发首充时:
A2 获得5元
A1 获得8 - 5 = 3元
A 获得20 - 8 = 12元
─────────────────────
合计20元 ✓
```
### 4.4 首充流程
```
客户购买套餐
预检:系列是否启用一次性佣金且为首充?
否 ───────────────────▶ 正常购买流程
该卡/设备在该系列下是否已首充过?
是 ───────────────────▶ 正常购买流程(不再返佣)
计算强充金额 = max(首充要求, 套餐售价)
返回提示:"需要充值 xxx 元"
用户确认 → 创建充值订单(金额=强充金额)
用户支付
支付成功:
1. 钱进入钱包
2. 标记该卡/设备已首充
3. 自动创建套餐购买订单并完成
4. 扣款(套餐售价)
5. 触发一次性佣金,链式分配
```
### 4.5 累计充值流程
```
客户充值(直接充值到钱包)
累计充值金额 += 本次充值金额
该卡/设备是否已触发过累计充值返佣?
是 ───────────────────▶ 结束(不再返佣)
累计金额 >= 累计要求?
否 ───────────────────▶ 结束(继续累计)
触发一次性佣金,链式分配
标记该卡/设备已触发累计充值返佣
```
**累计规则**
| 操作类型 | 是否累计 |
|---------|---------|
| 直接充值到钱包 | ✅ 累计 |
| 直接购买套餐(不经过钱包) | ❌ 不累计 |
| 强充购买套餐(先充值再扣款) | ✅ 累计(充值部分) |
---
## 五、梯度佣金
梯度佣金是一次性佣金的进阶版,根据代理销量/销售额动态调整返佣金额。
### 5.1 配置项
| 配置项 | 类型 | 说明 |
|--------|------|------|
| `tier_dimension` | string | 梯度维度:`sales_count`(销量) / `sales_amount`(销售额) |
| `stat_scope` | string | 统计范围:`self`(仅自己) / `self_and_sub`(自己+下级) |
| `tiers` | array | 梯度档位列表 |
| `tiers[].threshold` | int64 | 阈值(销量或销售额) |
| `tiers[].amount` | int64 | 返佣金额(分) |
### 5.2 示例
```
梯度规则(销量维度):
┌────────────────┬────────────────────────┐
│ 销量区间 │ 首充100返佣金额 │
├────────────────┼────────────────────────┤
│ >= 0 │ 5元 │
├────────────────┼────────────────────────┤
│ >= 100 │ 10元 │
├────────────────┼────────────────────────┤
│ >= 200 │ 20元 │
└────────────────┴────────────────────────┘
代理A当前销量150单 → 落在 [100, 200) 区间 → 首充返10元
```
### 5.3 梯度升级
```
初始状态:
代理A 销量150适用10元档给A1设置5元
触发时A1得5元A得10-5=5元
升级后A销量达到210
A 适用20元档A1配置仍为5元
触发时A1得5元不变A得20-5=15元增量归上级
```
### 5.4 统计周期
- 统计周期与一次性佣金时效一致
- 只统计该套餐系列下的销量/销售额
---
## 六、约束规则
### 6.1 套餐分配
1. 下级成本价 >= 自己成本价(不能亏本卖)
2. 只能分配自己有权限的套餐给下级
3. 只能分配给直属下级(不能跨级)
### 6.2 一次性佣金分配
4. 给下级的金额 <= 自己能拿到的金额
5. 给下级的金额 >= 0可以设为0独吞全部
### 6.3 流量
6. 虚流量 <= 真实流量
### 6.4 配置修改
7. 修改配置只影响之后的新订单
8. 代理只能修改"给下级多少钱",不能修改触发规则
9. 平台修改系列规则不影响已分配的代理,需收回重新分配
### 6.5 触发限制
10. 一次性佣金每张卡/设备只触发一次
11. "首充"指该卡/设备在该系列下的第一次充值
12. 累计充值只统计"充值"操作,不统计"直接购买"
---
## 七、操作流程
### 7.1 理想的线性流程
```
1. 创建套餐系列
└─▶ 可选:配置一次性佣金规则
2. 创建套餐
└─▶ 归属到系列
└─▶ 设置成本价、建议售价
└─▶ 设置真流量(必填)、虚流量(可选)
3. 分配套餐给代理
└─▶ 设置代理成本价(加价)
└─▶ 如果系列启用一次性佣金:设置给代理的一次性佣金额度
4. 分配资产(卡/设备)给代理
└─▶ 资产绑定的套餐系列自动跟着走
5. 代理销售
└─▶ 客户购买套餐
└─▶ 差价佣金自动计算并入账给上级
└─▶ 满足一次性佣金条件时,按链式分配入账
```
---
## 八、与现有代码的差异
详见改造提案:[refactor-commission-package-model](../openspec/changes/refactor-commission-package-model/)

View File

@@ -1,283 +0,0 @@
# 差价佣金快速参考指南
## 快速查询表
### 1. 关键文件位置
| 功能 | 文件 | 行号 |
|------|------|------|
| 差价佣金计算 | `internal/service/commission_calculation/service.go` | 121-225 |
| 佣金入账 | `internal/service/commission_calculation/service.go` | 641-687 |
| 佣金计算触发 | `internal/service/order/service.go` | 2132-2150 |
| 订单支付完成 | `internal/service/order/service.go` | 1431-1623 |
| 异步任务处理 | `internal/task/commission_calculation.go` | 40-62 |
| 订单模型 | `internal/model/order.go` | 1-138 |
| 佣金记录模型 | `internal/model/commission.go` | 1-46 |
| 业务文档 | `docs/commission-package-model.md` | - |
### 2. 关键常量
```go
// 任务类型
constants.TaskTypeCommission = "commission:calculate"
// 佣金来源
model.CommissionSourceCostDiff = "cost_diff" // 成本价差
model.CommissionSourceOneTime = "one_time" // 一次性佣金
// 订单佣金状态
model.CommissionStatusPending = 1 // 待计算
model.CommissionStatusCalculated = 2 // 已计算
// 订单支付状态
model.PaymentStatusPending = 1 // 待支付
model.PaymentStatusPaid = 2 // 已支付
model.PaymentStatusCancelled = 3 // 已取消
model.PaymentStatusRefunded = 4 // 已退款
// 佣金记录状态
constants.CommissionStatusReleased = 3 // 已发放
constants.CommissionStatusInvalid = 4 // 已失效
constants.CommissionStatusPendingReview = 99 // 待人工修正
```
### 3. 关键数据库表
| 表名 | 用途 | 关键字段 |
|------|------|---------|
| `tb_order` | 订单 | `commission_status`, `payment_status`, `seller_shop_id`, `seller_cost_price` |
| `tb_commission_record` | 佣金记录 | `shop_id`, `order_id`, `amount`, `status`, `released_at` |
| `tb_agent_wallet` | 代理钱包 | `shop_id`, `balance`, `wallet_type` |
| `tb_agent_wallet_transaction` | 钱包交易 | `agent_wallet_id`, `transaction_type`, `amount` |
| `tb_shop_package_allocation` | 套餐分配 | `shop_id`, `package_id`, `cost_price` |
---
## 常见问题快速查询
### Q1: 差价佣金什么时候计算?
**A**: 订单支付成功后立即入队异步任务,由 Worker 异步计算。
**代码位置**: `internal/service/order/service.go:1621`
```go
s.enqueueCommissionCalculation(ctx, orderID)
```
### Q2: 差价佣金的计算公式是什么?
**A**: `差价佣金 = 下级成本价 - 自己成本价`
**代码位置**: `internal/service/commission_calculation/service.go:203`
```go
profit := childCostPrice - myCostPrice
```
### Q3: 如何查询某个订单的佣金记录?
**A**: 查询 `tb_commission_record` 表,按 `order_id` 过滤。
```sql
SELECT * FROM tb_commission_record
WHERE order_id = ?
ORDER BY created_at DESC;
```
### Q4: 佣金什么时候入账到代理钱包?
**A**: 佣金记录创建后立即入账,通过 `creditCommissionInTx()` 方法。
**代码位置**: `internal/service/commission_calculation/service.go:641-687`
### Q5: 链路断裂是什么意思?
**A**: 上级代理未分配该套餐导致佣金链中断。此时创建待审佣金记录status=99需平台人工处理。
**代码位置**: `internal/service/commission_calculation/service.go:173-200`
### Q6: 如何追踪一个订单的佣金计算过程?
**A**:
1. 查询订单:`SELECT * FROM tb_order WHERE id = ?`
2. 检查 `commission_status` 字段1=待计算, 2=已计算)
3. 查询佣金记录:`SELECT * FROM tb_commission_record WHERE order_id = ?`
4. 查询钱包交易:`SELECT * FROM tb_agent_wallet_transaction WHERE reference_id = ? AND reference_type = 'commission'`
### Q7: 代理钱包余额如何更新?
**A**: 通过 `AgentWalletStore.AddBalanceWithTx()` 方法,使用乐观锁防并发。
**代码位置**: `internal/service/commission_calculation/service.go:651`
```go
s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, record.Amount)
```
### Q8: 如何处理佣金计算失败?
**A**: Asynq 会自动重试,最多重试次数由队列配置决定。如果最终失败,任务会进入死信队列。
**代码位置**: `internal/task/commission_calculation.go:40-62`
### Q9: 一个订单可能产生多少条佣金记录?
**A**: 取决于代理链的深度。最多为:销售店铺 + 所有上级店铺。
**示例**: 4级代理链 → 最多4条佣金记录
### Q10: 如何验证佣金计算的正确性?
**A**:
1. 验证总金额:所有佣金记录的 `amount` 之和 = 订单总金额 - 平台成本价
2. 验证链路:每条佣金记录的 `shop_id` 应该是代理链中的一个
3. 验证状态:所有佣金记录的 `status` 应该是 3已发放或 99待审
---
## 调试技巧
### 1. 查看订单的佣金计算状态
```sql
SELECT
id, order_no, commission_status, payment_status,
seller_shop_id, seller_cost_price, total_amount
FROM tb_order
WHERE id = ?;
```
### 2. 查看订单的所有佣金记录
```sql
SELECT
id, shop_id, amount, commission_source, status, released_at
FROM tb_commission_record
WHERE order_id = ?
ORDER BY shop_id;
```
### 3. 查看代理的钱包余额变化
```sql
SELECT
id, shop_id, balance, wallet_type, version
FROM tb_agent_wallet
WHERE shop_id = ?;
```
### 4. 查看代理的钱包交易记录
```sql
SELECT
id, agent_wallet_id, transaction_type, amount,
balance_before, balance_after, reference_type, reference_id
FROM tb_agent_wallet_transaction
WHERE shop_id = ? AND transaction_type = 'commission'
ORDER BY created_at DESC;
```
### 5. 查看待审佣金记录(链路断裂)
```sql
SELECT
id, shop_id, order_id, amount, status, remark
FROM tb_commission_record
WHERE status = 99
ORDER BY created_at DESC;
```
### 6. 查看异步任务队列状态
```bash
# 查看待处理任务
redis-cli LRANGE asynq:queues:default 0 -1
# 查看已完成任务
redis-cli LRANGE asynq:queues:completed 0 -1
# 查看失败任务
redis-cli LRANGE asynq:queues:failed 0 -1
```
---
## 性能优化建议
### 1. 批量查询优化
避免在循环中逐条查询,使用 `IN` 子句批量查询:
```go
// ❌ 不好N+1 查询
for _, shopID := range shopIDs {
allocation, _ := store.GetByShopAndPackage(ctx, shopID, packageID)
}
// ✅ 好:批量查询
allocations, _ := store.GetByShopsAndPackage(ctx, shopIDs, packageID)
```
### 2. 事务优化
确保事务范围最小化,避免长事务:
```go
// ✅ 好:事务只包含必要操作
err = s.db.Transaction(func(tx *gorm.DB) error {
// 创建佣金记录
// 更新钱包余额
// 创建交易记录
return nil
})
```
### 3. 索引优化
确保以下字段有索引:
- `tb_order.id`, `tb_order.commission_status`, `tb_order.payment_status`
- `tb_commission_record.order_id`, `tb_commission_record.shop_id`, `tb_commission_record.status`
- `tb_agent_wallet.shop_id`, `tb_agent_wallet.wallet_type`
---
## 常见错误排查
### 错误1: 佣金未入账
**症状**: `commission_status = 2` 但钱包余额未增加
**排查步骤**:
1. 检查 `tb_commission_record` 中是否有记录
2. 检查记录的 `status` 是否为 3已发放
3. 检查 `tb_agent_wallet_transaction` 中是否有对应交易
4. 查看应用日志中的错误信息
### 错误2: 链路断裂
**症状**: `tb_commission_record.status = 99``remark` 包含"套餐系列未分配"
**排查步骤**:
1. 检查上级代理是否分配了该套餐
2. 检查 `tb_shop_package_allocation` 表中是否有记录
3. 平台管理员需要手动分配套餐或修正佣金记录
### 错误3: 佣金计算失败
**症状**: 异步任务失败,订单 `commission_status` 仍为 1
**排查步骤**:
1. 查看应用日志中的错误信息
2. 检查订单数据是否完整(`seller_shop_id`, `seller_cost_price` 等)
3. 检查数据库连接是否正常
4. 手动重试任务或联系技术支持
---
## 相关文档
- [套餐与佣金业务模型](commission-package-model.md) - 完整的业务规则说明
- [差价佣金搜索结果](commission-search-result.md) - 详细的代码位置和实现说明
- [差价佣金流程图](commission-flow-diagram.md) - 可视化的流程图

View File

@@ -1,237 +0,0 @@
# 差价佣金计算和分配逻辑搜索结果
## 一、差价佣金的计算方式
### 1.1 计算公式
差价佣金 = 下级成本价 - 自己成本价
### 1.2 计算流程
**文件位置**: `internal/service/commission_calculation/service.go:121-225`
```go
func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) ([]*model.CommissionRecord, error)
```
**核心逻辑**:
1. **销售端利润计算** (第138-153行)
- 销售利润 = 订单总金额 - 销售成本价
- 如果销售利润 > 0为销售店铺创建一条佣金记录
2. **链式向上计算** (第163-222行)
- 从销售店铺开始,逐级向上遍历代理链
- 对于每一级代理:
- 获取该代理的成本价(从 ShopPackageAllocation 表)
- 计算差价childCostPrice - myCostPrice
- 如果差价 > 0创建佣金记录
3. **链路断裂处理** (第173-200行)
- 如果上级代理未分配该套餐创建待审佣金记录status=99
- 记录备注:套餐系列未分配给该代理,需人工核查
### 1.3 示例
```
平台基础成本价: 100元
分配给代理A成本价: 120元
├─ A的利润: 120 - 100 = 20元
分配给代理A1成本价: 130元
├─ A1的利润: 130 - 120 = 10元
├─ A的差价佣金: 130 - 120 = 10元
A1销售给客户售价: 200元
├─ A1的销售利润: 200 - 130 = 70元
├─ A的差价佣金: 130 - 120 = 10元
├─ 平台收入: 120元
```
---
## 二、差价佣金的发放条件
### 2.1 触发条件
**文件位置**: `internal/service/order/service.go:1621``internal/task/commission_calculation.go`
差价佣金在以下情况触发:
1. **订单支付成功**
- 支付状态从 `PaymentStatusPending` (1) 变为 `PaymentStatusPaid` (2)
- 触发点:`WalletPay()` 方法完成后调用 `enqueueCommissionCalculation()`
2. **异步任务处理**
- 任务类型: `commission:calculate`
- 处理器: `CommissionCalculationHandler.HandleCommissionCalculation()`
- 队列: Asynq 异步任务队列
### 2.2 发放流程
**文件位置**: `internal/service/commission_calculation/service.go:74-119`
```go
func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error
```
**步骤**:
1. **检查订单佣金状态** (第81-84行)
- 如果 `commission_status == 2` (已计算),跳过
- 防止重复计算
2. **计算成本价差佣金** (第86-98行)
- 调用 `CalculateCostDiffCommission()` 获取佣金记录列表
- 逐条创建佣金记录到 `tb_commission_record`
- 调用 `creditCommissionInTx()` 入账到代理钱包
3. **触发一次性佣金** (第100-111行)
- 仅非代购订单触发
- 根据订单类型(单卡/设备)调用对应方法
4. **更新订单佣金状态** (第113-115行)
-`commission_status` 更新为 `2` (已计算)
### 2.3 佣金入账
**文件位置**: `internal/service/commission_calculation/service.go:641-687`
```go
func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord) error
```
**入账步骤**:
1. 获取店铺的分佣钱包 (wallet_type = 'commission')
2. 增加钱包余额
3. 更新佣金记录状态为 `CommissionStatusReleased` (3)
4. 记录入账时间 `released_at`
5. 创建钱包交易记录 (transaction_type = 'commission')
---
## 三、差价佣金与订单状态的关系
### 3.1 订单状态定义
**文件位置**: `internal/model/order.go:106-110`
```go
// 佣金状态常量
const (
CommissionStatusPending = 1 // 待计算
CommissionStatusCalculated = 2 // 已计算
)
```
### 3.2 订单支付状态
**文件位置**: `internal/model/order.go:98-104`
```go
// 支付状态常量
const (
PaymentStatusPending = 1 // 待支付
PaymentStatusPaid = 2 // 已支付
PaymentStatusCancelled = 3 // 已取消
PaymentStatusRefunded = 4 // 已退款
)
```
### 3.3 状态转换关系
```
订单创建
commission_status = 1 (待计算)
payment_status = 1 (待支付)
用户支付
payment_status = 2 (已支付)
触发佣金计算任务
异步处理CalculateCommission()
commission_status = 2 (已计算)
佣金入账到代理钱包
```
### 3.4 佣金记录状态
**文件位置**: `internal/model/commission.go:40-46``pkg/constants/iot.go:180-187`
```go
// 佣金记录状态tb_commission_record.status
const (
CommissionStatusReleased = 3 // 已发放
CommissionStatusInvalid = 4 // 已失效
CommissionStatusPendingReview = 99 // 待人工修正(链路断裂)
)
// 代理钱包交易状态tb_agent_wallet_transaction.status
const (
CommissionStatusFrozen = 1 // 已冻结
CommissionStatusUnfreezing = 2 // 解冻中
CommissionStatusReleased = 3 // 已发放
CommissionStatusInvalid = 4 // 已失效
CommissionStatusPendingReview = 99 // 待人工修正
)
```
### 3.5 关键字段
**订单表 (tb_order)**:
- `commission_status`: 佣金计算状态
- `commission_config_version`: 佣金配置版本快照
- `seller_shop_id`: 销售店铺ID用于成本价差佣金
- `seller_cost_price`: 销售成本价(用于计算利润)
- `series_id`: 系列ID用于查询分配配置
**佣金记录表 (tb_commission_record)**:
- `shop_id`: 佣金归属店铺
- `order_id`: 关联订单
- `commission_source`: 佣金来源 (cost_diff/one_time)
- `amount`: 佣金金额
- `status`: 佣金状态
- `released_at`: 入账时间
- `balance_after`: 入账后钱包余额
---
## 四、关键代码位置总结
| 功能 | 文件位置 | 方法 |
|------|---------|------|
| 差价佣金计算 | `internal/service/commission_calculation/service.go` | `CalculateCostDiffCommission()` |
| 佣金入账 | `internal/service/commission_calculation/service.go` | `creditCommissionInTx()` |
| 佣金计算触发 | `internal/service/order/service.go` | `enqueueCommissionCalculation()` |
| 异步任务处理 | `internal/task/commission_calculation.go` | `HandleCommissionCalculation()` |
| 订单支付完成 | `internal/service/order/service.go` | `WalletPay()` |
| 模型定义 | `internal/model/commission.go` | `CommissionRecord` |
| 模型定义 | `internal/model/order.go` | `Order` |
| 业务文档 | `docs/commission-package-model.md` | 完整的佣金业务模型说明 |
---
## 五、相关常量
**文件位置**: `pkg/constants/constants.go``pkg/constants/iot.go`
```go
// 任务类型
TaskTypeCommission = "commission:calculate"
// 佣金来源
CommissionSourceCostDiff = "cost_diff" // 成本价差
CommissionSourceOneTime = "one_time" // 一次性佣金
// 佣金状态
CommissionStatusReleased = 3 // 已发放
CommissionStatusInvalid = 4 // 已失效
CommissionStatusPendingReview = 99 // 待人工修正
```

View File

@@ -1,181 +0,0 @@
# 累计触发一次性佣金逻辑流程
## 概述
一次性佣金支持两种触发方式:
1. **单次充值触发**:单笔订单金额达到阈值即发放
2. **累计充值触发**:累计多次充值金额达到阈值后发放(本文档重点)
## 累计触发流程
```
┌─────────────────────────────────────────────────────────────┐
│ 用户购买套餐 │
└────────────────────┬────────────────────────────────────────┘
┌────────────▼──────────────┐
│ 支付成功触发佣金计算 │
└────────────┬──────────────┘
┌────────────▼──────────────┐
│ 读取一次性佣金配置 │
│ - EnableOneTimeCommission │
│ - Trigger = accumulated │
│ - Threshold 阈值 │
└────────────┬──────────────┘
┌────────────▼──────────────┐
│ 累计金额 += 本次订单金额 │
│ 写回 AccumulatedRecharge │
└────────────┬──────────────┘
┌────────────▼──────────────┐
│ 累计金额 >= 阈值? │
└─────┬───────────┬──────────┘
│ 否 │ 是
│ │
│ ┌────▼───────────────┐
│ │ FirstCommissionPaid│
│ │ 已标记? │
│ └────┬───────┬───────┘
│ │ 是 │ 否
│ │ │
│ ┌────▼────┐ │
│ │ 跳过 │ │
│ └─────────┘ │
│ │
│ ┌────▼────────┐
│ │ 计算佣金金额 │
│ └────┬────────┘
│ │
│ ┌────▼────────┐
│ │ 创建佣金记录 │
│ │ 佣金入账 │
│ └────┬────────┘
│ │
│ ┌────▼────────┐
│ │ 标记 │
│ │FirstCommission│
│ │Paid = true │
│ └────┬────────┘
│ │
└───────────────────▼
┌────────────▼──────────────┐
│ 完成 │
└───────────────────────────┘
```
## 关键字段
### IotCard / Device 模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `accumulated_recharge` | int64 | 累计充值金额(分),每次支付成功都会累加 |
| `first_commission_paid` | bool | 一次性佣金是否已发放,防止重复发放 |
| `series_allocation_id` | uint | 关联的系列分配ID用于获取配置 |
### ShopSeriesAllocation 模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `enable_one_time_commission` | bool | 是否启用一次性佣金 |
| `one_time_commission_trigger` | string | 触发方式:`single_recharge``accumulated_recharge` |
| `one_time_commission_threshold` | int64 | 最低阈值(分) |
| `one_time_commission_type` | string | 佣金类型:`fixed``tiered` |
| `one_time_commission_mode` | string | 返佣模式:`fixed``percent` |
| `one_time_commission_value` | int64 | 佣金值(分或千分比) |
## 核心逻辑实现
### 1. 累计金额更新
**位置**`internal/service/commission_calculation/service.go`
```go
// 累计充值触发场景:每次支付都写回累计金额
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
newAccumulated := card.AccumulatedRecharge + order.TotalAmount
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
Update("accumulated_recharge", newAccumulated).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡累计充值金额失败")
}
card.AccumulatedRecharge = newAccumulated
}
```
### 2. 阈值判断与发放
```go
// 判断是否已发放过
if card.FirstCommissionPaid {
return nil // 已发放,跳过
}
// 根据触发方式选择充值金额
var rechargeAmount int64
switch allocation.OneTimeCommissionTrigger {
case model.OneTimeCommissionTriggerSingleRecharge:
rechargeAmount = order.TotalAmount // 单次金额
case model.OneTimeCommissionTriggerAccumulatedRecharge:
rechargeAmount = card.AccumulatedRecharge // 累计金额
default:
return nil
}
// 判断是否达到阈值
if rechargeAmount < allocation.OneTimeCommissionThreshold {
return nil // 未达阈值,不发放
}
// 计算佣金、创建记录、入账
commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount)
// ...
// 标记已发放
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
Update("first_commission_paid", true).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败")
}
```
## 典型场景示例
### 场景:阈值 10000 分100元佣金 500 分5元
| 支付次数 | 订单金额 | 累计金额 | 是否发放佣金 | 说明 |
|---------|---------|---------|-------------|------|
| 第1次 | 3000 | 3000 | ❌ | 未达阈值 |
| 第2次 | 4000 | 7000 | ❌ | 未达阈值 |
| 第3次 | 4000 | 11000 | ✅ 发放500 | 达到阈值,首次发放 |
| 第4次 | 3000 | 14000 | ❌ | 已发放过,不重复 |
| 第5次 | 5000 | 19000 | ❌ | 已发放过,不重复 |
## 注意事项
1. **每次支付都写回累计金额**:即使未达阈值,也要更新 `accumulated_recharge`
2. **仅发放一次**:通过 `first_commission_paid` 标记防止重复发放
3. **事务保证一致性**:累计金额更新、佣金记录创建、标记更新都在同一事务中
4. **适用于单卡和设备**`IotCard``Device` 模型都有相同字段,逻辑完全一致
## 测试覆盖
单元测试:`tests/unit/commission_calculation_service_test.go`
- ✅ 第一次支付:累计金额更新为 3000未发放
- ✅ 第二次支付:累计金额更新为 7000未发放
- ✅ 第三次支付:累计金额更新为 11000发放佣金 500
- ✅ 第四次支付:累计金额更新为 14000不重复发放
集成测试:`tests/integration/shop_series_allocation_test.go`
- ✅ 一次性佣金配置落库(固定类型)
- ✅ 一次性佣金配置落库(梯度类型)
- ✅ 启用一次性佣金但未提供配置应失败
## 相关文档
- [API 文档](../api-documentation-guide.md):自动生成的 OpenAPI 文档包含所有参数说明
- [DTO 规范](../dto-standards.md):一次性佣金配置的请求/响应结构
- [Model 规范](../model-standards.md):数据库字段定义

View File

@@ -1,179 +0,0 @@
# 数据库迁移总结
## 迁移版本000004_create_personal_customer_relations
**执行时间**2026-01-10
**执行状态**:✅ 成功
**执行耗时**307ms
---
## 创建的表
### 1. personal_customer_phone个人客户手机号绑定表
**用途**:存储个人客户绑定的手机号信息,支持一对多关系
**字段列表**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | INTEGER | 主键ID |
| customer_id | INTEGER | 个人客户ID关联 personal_customer 表) |
| phone | VARCHAR(20) | 手机号 |
| is_primary | BOOLEAN | 是否主手机号(默认 false |
| verified_at | TIMESTAMP | 验证通过时间 |
| status | INTEGER | 状态0=禁用 1=启用,默认 1 |
| created_at | TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | 更新时间 |
| deleted_at | TIMESTAMP | 删除时间(软删除) |
**索引**
-`idx_personal_customer_phone_customer_phone` - 唯一索引 (customer_id, phone) WHERE deleted_at IS NULL
-`idx_personal_customer_phone_phone` - 普通索引 (phone)
-`idx_personal_customer_phone_deleted_at` - 普通索引 (deleted_at)
-`personal_customer_phone_pkey` - 主键索引 (id)
---
### 2. personal_customer_iccid个人客户ICCID绑定表
**用途**:记录个人客户使用过的 ICCID支持多对多关系
**字段列表**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | INTEGER | 主键ID |
| customer_id | INTEGER | 个人客户ID关联 personal_customer 表) |
| iccid | VARCHAR(20) | ICCID20位数字 |
| bind_at | TIMESTAMP | 绑定时间 |
| last_used_at | TIMESTAMP | 最后使用时间 |
| status | INTEGER | 状态0=禁用 1=启用,默认 1 |
| created_at | TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | 更新时间 |
| deleted_at | TIMESTAMP | 删除时间(软删除) |
**索引**
-`idx_personal_customer_iccid_customer_iccid` - 唯一索引 (customer_id, iccid) WHERE deleted_at IS NULL
-`idx_personal_customer_iccid_iccid` - 普通索引 (iccid)
-`idx_personal_customer_iccid_deleted_at` - 普通索引 (deleted_at)
-`personal_customer_iccid_pkey` - 主键索引 (id)
---
### 3. personal_customer_device个人客户设备号绑定表
**用途**:记录个人客户使用过的设备号/IMEI支持多对多关系可选
**字段列表**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | INTEGER | 主键ID |
| customer_id | INTEGER | 个人客户ID关联 personal_customer 表) |
| device_no | VARCHAR(50) | 设备号/IMEI |
| bind_at | TIMESTAMP | 绑定时间 |
| last_used_at | TIMESTAMP | 最后使用时间 |
| status | INTEGER | 状态0=禁用 1=启用,默认 1 |
| created_at | TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | 更新时间 |
| deleted_at | TIMESTAMP | 删除时间(软删除) |
**索引**
-`idx_personal_customer_device_customer_device` - 唯一索引 (customer_id, device_no) WHERE deleted_at IS NULL
-`idx_personal_customer_device_device_no` - 普通索引 (device_no)
-`idx_personal_customer_device_deleted_at` - 普通索引 (deleted_at)
-`personal_customer_device_pkey` - 主键索引 (id)
---
## 数据库设计原则
### 核心设计理念
1. **无外键约束**:所有表之间不建立 Foreign Key 约束,提高灵活性和性能
2. **软删除支持**:所有表都包含 `deleted_at` 字段,支持软删除
3. **唯一性保证**:通过唯一索引(带 WHERE deleted_at IS NULL 条件)保证业务唯一性
4. **查询优化**:为常用查询字段创建普通索引
### 业务模型关系
```
PersonalCustomer (1) ──< (N) PersonalCustomerPhone
PersonalCustomer (N) ──< (M) PersonalCustomerICCID
PersonalCustomer (N) ──< (M) PersonalCustomerDevice
```
- **个人客户 ←→ 手机号**:一对多(一个客户可以绑定多个手机号)
- **个人客户 ←→ ICCID**:多对多(一个客户可以使用多个 ICCID一个 ICCID 可以被多个客户使用)
- **个人客户 ←→ 设备号**:多对多(一个客户可以使用多个设备,一个设备可以被多个客户使用)
---
## Makefile 命令
为了方便后续的数据库迁移操作,已添加以下 Makefile 命令:
```bash
# 执行所有待执行的迁移(升级数据库)
make migrate-up
# 回滚最后一次迁移(降级数据库)
make migrate-down
# 查看当前迁移版本
make migrate-version
# 创建新的迁移脚本
make migrate-create
```
---
## 迁移验证
### 表验证
```bash
✅ 个人客户相关表列表:
- personal_customer_device
- personal_customer_iccid
- personal_customer_phone
```
### 索引验证
所有表的索引都已成功创建:
- ✅ 主键索引
- ✅ 唯一索引(带软删除过滤)
- ✅ 查询优化索引
- ✅ 软删除索引
---
## 下一步
1. **数据初始化**(可选)
- 如需初始化测试数据,可以创建 seed 脚本
2. **业务逻辑实现**
- 已完成Service 层、Handler 层、路由注册
- 待完成:单元测试、集成测试
3. **性能监控**
- 监控索引使用情况
- 监控查询性能
- 根据实际使用情况优化索引
---
## 注意事项
⚠️ **生产环境部署前的检查清单**
1. 确认数据库备份已完成
2. 在预发布环境测试迁移脚本
3. 确认索引创建不会影响现有查询性能
4. 准备回滚方案(已提供 .down.sql 脚本)
5. 评估迁移执行时间,选择合适的维护窗口
---
**迁移完成时间**2026-01-10
**文档版本**v1.0

View File

@@ -1,452 +0,0 @@
# 君鸿卡管系统 - 部署指南
本文档提供从零开始部署君鸿卡管系统到测试环境的完整步骤。
---
## 架构概览
```
┌─────────────────────────────────────────────────────────────────┐
│ 部署架构图 │
└───────────────────────────┬─────────────────────────────────────┘
┌───────────────────▼───────────────────┐
│ 开发者 Push 代码到 Gitea │
│ (main/dev/test 分支) │
└───────────────────┬───────────────────┘
┌───────────────────▼───────────────────┐
│ 部署服务器 (47.111.166.169) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Act Runner (docker-runner-01) │ │
│ │ 自动触发工作流 │ │
│ └───────────┬─────────────────────┘ │
│ │ │
│ ┌───────────▼─────────────────────┐ │
│ │ 构建 Docker 镜像 │ │
│ │ - API 镜像 │ │
│ │ - Worker 镜像 │ │
│ └───────────┬─────────────────────┘ │
│ │ │
│ ┌───────────▼─────────────────────┐ │
│ │ Push 到私有 Docker Registry │ │
│ │ registry.boss160.cn │ │
│ └───────────┬─────────────────────┘ │
│ │ │
│ ┌───────────▼─────────────────────┐ │
│ │ 本地部署(无 SSH │ │
│ │ - Pull 镜像 │ │
│ │ - 滚动更新容器 │ │
│ │ - 清理旧镜像(保留 3 个) │ │
│ └───────────┬─────────────────────┘ │
│ │ │
│ ┌───────────▼─────────────────────┐ │
│ │ 服务运行中 │ │
│ │ - API: 0.0.0.0:3000 │ │
│ │ - Worker: 后台任务处理 │ │
│ └──────────────────────────────────┘ │
└───────────────────────────────────────┘
```
**关键优势**Act Runner 在部署服务器本地运行,无需 SSH配置简单
---
## 前置准备
### 1. 服务器环境要求
- **操作系统**Ubuntu 20.04+ / CentOS 7+
- **Docker**20.10+
- **Docker Compose**1.29+
- **内存**:至少 2GB
- **磁盘**:至少 20GB
- **Act Runner**:已部署并注册到 Gitea✅ 你已经有了 docker-runner-01
### 2. 外部依赖服务
系统依赖以下外部服务(需提前部署):
- **PostgreSQL 14+**:数据库
- **Redis 6.0+**:缓存和队列
---
## 第一步:服务器初始化
### 1.1 确认 Docker 环境
```bash
# SSH 到服务器
ssh qycard001@47.111.166.169 -p 52022
# 验证 Docker 和 Docker Compose
docker --version
docker-compose --version
# 验证 Act Runner 正在运行
docker ps | grep runner
# 应该能看到 docker-runner-01
```
### 1.2 创建部署目录
```bash
# 创建部署目录
mkdir -p /home/qycard001/app/junhong_cmp
cd /home/qycard001/app/junhong_cmp
# 创建日志目录(配置已嵌入二进制文件,无需 configs 目录)
mkdir -p logs
```
### 1.3 配置说明
系统使用**嵌入式配置 + 环境变量覆盖**机制:
- 默认配置已编译在二进制文件中
- 通过 `docker-compose.prod.yml` 中的环境变量覆盖配置
- 环境变量前缀:`JUNHONG_`
- 格式:`JUNHONG_{配置路径}`,路径分隔符用下划线替代点号
**无需手动创建配置文件**,所有配置在 `docker-compose.prod.yml``environment` 中管理。
### 1.4 部署文件
`docker-compose.prod.yml` 由 CI/CD 自动从代码仓库复制到部署目录,无需手动操作。
如需手动部署,可从代码仓库复制:
```bash
# 方式1: 使用 Git
git clone <你的仓库地址> temp
cp temp/docker-compose.prod.yml ./docker-compose.prod.yml
rm -rf temp
# 方式2: 从本地上传
# scp -P 52022 docker-compose.prod.yml qycard001@47.111.166.169:/home/qycard001/app/junhong_cmp/
```
---
## 第二步:配置 Gitea Secrets
详细步骤请参考 [Gitea Secrets 配置说明](./gitea-secrets-setup.md)。
**只需要配置 2 个 Secrets**(非常简单!):
| Secret 名称 | 值 |
|------------|-----|
| `REGISTRY_USERNAME` | `junhong_admin` |
| `REGISTRY_PASSWORD` | `JunHong@2025!Registry` |
**配置步骤**
1. 进入 Gitea 仓库 → 设置 → Secrets
2. 添加 `REGISTRY_USERNAME`,值为 `junhong_admin`
3. 添加 `REGISTRY_PASSWORD`,值为 `JunHong@2025!Registry`
4. 完成!
---
## 第三步:首次手动部署
在自动化 CI/CD 生效前,先进行一次手动部署验证环境。
### 3.1 登录 Docker Registry
```bash
# 在服务器上执行
ssh qycard001@47.111.166.169 -p 52022
docker login registry.boss160.cn -u junhong_admin
# 输入密码JunHong@2025!Registry
```
### 3.2 手动构建镜像(可选)
如果 CI/CD 还未运行,可以手动构建:
```bash
cd <代码仓库目录>
# 构建 API 镜像
docker build -f Dockerfile.api -t registry.boss160.cn/junhong/cmp-fiber-api:latest .
# 构建 Worker 镜像
docker build -f Dockerfile.worker -t registry.boss160.cn/junhong/cmp-fiber-worker:latest .
# 推送镜像
docker push registry.boss160.cn/junhong/cmp-fiber-api:latest
docker push registry.boss160.cn/junhong/cmp-fiber-worker:latest
```
### 3.3 启动服务
```bash
cd /home/qycard001/app/junhong_cmp
# 拉取镜像
docker-compose -f docker-compose.prod.yml pull
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 查看服务状态
docker-compose -f docker-compose.prod.yml ps
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f
```
### 3.4 验证部署
```bash
# 测试 API 健康检查
curl http://localhost:3000/health
# 预期输出:
# {"code":0,"msg":"ok","data":{"status":"healthy"},"timestamp":1234567890}
# 查看容器状态
docker ps | grep junhong
```
---
## 第四步:自动化部署
配置完成后,每次 Push 代码到 `main` / `dev` / `test` 分支时,会自动触发构建。
### 工作流触发规则
| 分支 | 镜像标签 | 是否自动部署 |
|------|---------|------------|
| `main` | `latest` | ✅ 是 |
| `dev` | `dev` | ❌ 否(仅构建镜像)|
| `test` | `test` | ❌ 否(仅构建镜像)|
### 触发自动部署
```bash
# 在本地提交代码
git add .
git commit -m "测试自动部署"
git push origin main
```
### 监控部署进度
1. 在 Gitea 仓库页面点击 **Actions** 标签
2. 查看最新的工作流运行状态
3. 点击工作流查看详细日志
**预期日志输出**
```
✅ 检出代码
✅ 设置镜像标签: latest
✅ 登录 Docker Registry
✅ 构建 API 镜像
✅ 构建 Worker 镜像
✅ 推送镜像到 Registry
✅ 部署到本地
- 拉取最新镜像...
- 执行滚动更新...
- 等待服务健康检查...
- 清理旧镜像(保留最近 3 个版本)...
- 部署完成!
✅ 构建结果通知
```
---
## 运维操作
### 查看服务日志
```bash
cd /home/qycard001/app/junhong_cmp
# 查看 API 日志
docker-compose -f docker-compose.prod.yml logs -f api
# 查看 Worker 日志
docker-compose -f docker-compose.prod.yml logs -f worker
# 查看应用日志文件
tail -f logs/app.log | jq .
tail -f logs/access.log | jq .
```
### 重启服务
```bash
cd /home/qycard001/app/junhong_cmp
# 重启所有服务
docker-compose -f docker-compose.prod.yml restart
# 重启单个服务
docker-compose -f docker-compose.prod.yml restart api
docker-compose -f docker-compose.prod.yml restart worker
```
### 手动执行数据库迁移
```bash
# 进入 API 容器
docker exec -it junhong-cmp-api bash
# 查看当前迁移版本
migrate -path /app/migrations -database "$(cat /app/.env | grep DB_ | xargs)" version
# 执行迁移(通常容器启动时会自动执行)
migrate -path /app/migrations -database "$(cat /app/.env | grep DB_ | xargs)" up
```
### 回滚到旧版本
```bash
cd /home/qycard001/app/junhong_cmp
# 查看可用镜像
docker images | grep cmp-fiber
# 示例输出:
# registry.boss160.cn/junhong/cmp-fiber-api latest abc123 2 hours ago 50MB
# registry.boss160.cn/junhong/cmp-fiber-api def456 def456 1 day ago 50MB
# 修改 docker-compose.prod.yml 中的镜像标签
# 将 :latest 改为具体的 commit SHA
sed -i 's/:latest/:def456/g' docker-compose.prod.yml
# 重新部署
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
### 清理磁盘空间
```bash
# 清理未使用的镜像
docker image prune -a -f
# 清理未使用的容器
docker container prune -f
# 清理未使用的卷
docker volume prune -f
# 一键清理所有(谨慎使用)
docker system prune -a -f --volumes
```
---
## 常见问题
### Q1: 容器启动失败,显示 "unhealthy"
**排查步骤**
1. 查看容器日志:`docker-compose -f docker-compose.prod.yml logs api`
2. 检查 `docker-compose.prod.yml` 中的环境变量配置是否正确数据库连接、Redis 连接)
3. 确认外部依赖PostgreSQL、Redis是否可访问
4. 手动测试健康检查:`curl http://localhost:3000/health`
### Q2: 数据库迁移失败
**可能原因**
- 数据库连接信息错误
- 迁移文件损坏
- 数据库权限不足
**解决方法**
```bash
# 检查 .env 文件配置
cat /home/qycard001/app/junhong_cmp/.env
# 手动测试数据库连接
docker run --rm postgres:14 psql "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" -c "SELECT 1;"
# 查看迁移日志
docker logs junhong-cmp-api | grep migrate
```
### Q3: CI/CD 工作流失败
**常见原因**
1. **Secrets 未配置或错误**:检查 Gitea Secrets 是否配置了 `REGISTRY_USERNAME``REGISTRY_PASSWORD`
2. **Registry 认证失败**:验证用户名密码是否正确
3. **磁盘空间不足**:执行 `df -h` 检查磁盘空间
**排查方法**
- 在 Gitea Actions 页面查看详细日志
- 在服务器上手动测试 Registry 登录:`docker login registry.boss160.cn -u junhong_admin`
### Q4: 镜像拉取慢或超时
**解决方法**
```bash
# 检查 Registry 连接
curl -I https://registry.boss160.cn
# 检查网络
ping registry.boss160.cn
# 查看 Docker 日志
journalctl -u docker -f
```
### Q5: Act Runner 权限问题
**问题现象**:工作流报错 `permission denied``cannot connect to Docker daemon`
**解决方法**
```bash
# 确认 Act Runner 用户在 docker 组中
docker exec -it docker-runner-01 groups
# 如果缺少 docker 组,重启 Act Runner 容器
docker restart docker-runner-01
```
---
## 安全建议
1. **定期更新**
- 定期更新 Docker 和 Docker Compose
- 及时应用系统安全补丁
2. **防火墙配置**
```bash
# 仅开放必要端口
sudo ufw allow 52022/tcp # SSH
sudo ufw allow 3000/tcp # API如果需要外部访问
sudo ufw enable
```
3. **日志监控**
- 配置日志轮转,防止磁盘占满
- 定期审查访问日志和错误日志
4. **备份策略**
- 定期备份数据库
- 定期备份 `docker-compose.prod.yml`(包含所有配置)
---
## 下一步
部署完成后:
1. ✅ 配置域名和反向代理Nginx / Caddy
2. ✅ 启用 HTTPSLet's Encrypt
3. ✅ 配置监控和告警Prometheus + Grafana
4. ✅ 设置自动备份脚本
---
## 支持
如有问题,请联系运维团队或提交 Issue。

View File

@@ -1,114 +0,0 @@
# Gitea Secrets 配置说明
本文档说明如何在 Gitea 中配置 CI/CD 工作流所需的 Secrets密钥
## 重要说明
因为 Act Runner 运行在部署服务器本地,所以**不需要 SSH 连接**,配置非常简单!
---
## 配置步骤
### 1. 进入仓库设置
1. 打开你的 Gitea 仓库页面
2. 点击右上角的 **设置Settings** 按钮
3. 在左侧菜单中找到 **Secrets****密钥** 选项
### 2. 添加以下 Secrets
点击 **添加 Secret** 按钮,逐个添加以下密钥:
#### Docker Registry 认证信息
| Secret 名称 | 值 | 说明 |
|------------|-----|------|
| `REGISTRY_USERNAME` | `junhong_admin` | Docker Registry 用户名 |
| `REGISTRY_PASSWORD` | `JunHong@2025!Registry` | Docker Registry 密码 |
**就这么简单!只需要 2 个 Secrets**
---
## 配置完成检查清单
- [ ] `REGISTRY_USERNAME` 已配置
- [ ] `REGISTRY_PASSWORD` 已配置
---
## 验证配置
### 测试 Registry 登录
在部署服务器上手动测试 Registry 连接:
```bash
# 登录测试
docker login registry.boss160.cn -u junhong_admin
# 输入密码JunHong@2025!Registry
# 预期输出Login Succeeded
```
如果登录成功,说明配置正确。
---
## 常见问题
### Q1: 工作流显示 "secret not found"
**原因**Secret 名称拼写错误或未添加
**解决方法**
1. 检查 Secret 名称是否完全一致(区分大小写)
2. 确认 2 个 Secrets 都已添加
### Q2: Registry 登录失败
**原因**:用户名或密码错误
**解决方法**
```bash
# 在服务器上手动测试登录
docker login registry.boss160.cn -u junhong_admin
# 输入密码JunHong@2025!Registry
# 如果失败,检查 Registry 认证配置
cat /home/qycard001/registry/auth/htpasswd
```
### Q3: 为什么不需要 SSH 密钥?
**回答**:因为你的 Act Runner (`docker-runner-01`) 运行在部署服务器本地,可以直接执行 `docker-compose` 命令,不需要通过 SSH 连接到其他机器。
**架构示意**
```
┌──────────────────────────────────────────┐
│ 部署服务器 (47.111.166.169) │
│ │
│ ┌────────────────┐ ┌───────────────┐ │
│ │ Act Runner │──→│ Docker Daemon │ │
│ │(docker-runner) │ │ │ │
│ └────────────────┘ └───────────────┘ │
│ ↓ ↓ │
│ 执行工作流 部署容器 │
└──────────────────────────────────────────┘
```
---
## 安全注意事项
1. **绝不**在代码中硬编码密钥信息
2. **定期更换** Registry 密码
3. **监控** Gitea Actions 日志,防止密钥泄露
4. 如果 Secrets 泄露,**立即**更换密码
---
## 下一步
配置完成后,参考 [部署指南](./deployment-guide.md) 进行首次部署。

View File

@@ -0,0 +1,90 @@
# 生产环境运行说明
## 元数据
- 用途:区分生产与测试运行方式,并为人工发布、迁移和回滚提供事实入口。
- 适用范围:`xm-iot.cn` 的生产 API 与 Worker不适用于本地、测试环境或 Docker Compose。
- 事实源:生产维护者提供的主机与目录信息;实际 systemd Unit、线上 `.env.prod``migrate version` 输出优先于本文。
- Owner生产维护者。
- 最后核验2026-08-13基于维护者提供的信息未连接生产环境
- 更新触发Unit、目录、进程数、Worker 角色、发布顺序、迁移方式或回滚方式变化。
- 验证由维护者在服务器执行本文列出的只读核对命令并回填结果Agent 不连接生产环境。
## 与测试环境的边界
生产环境不是仓库中的 Docker Compose 测试部署:
| 项目 | 生产环境 | 仓库 Compose/工作流 |
| --- | --- | --- |
| 平台 | Ubuntu 24.04 x86_64 | 测试环境自动部署 |
| 发布 | 本地交叉编译后手工上传二进制 | Gitea 构建、镜像与 Docker Compose |
| 进程管理 | systemd | Docker Compose |
| 运行单元 | 1 个 API、1 个调度 Worker、3 个执行 Worker | 不作为生产拓扑依据 |
| 配置 | 每个运行目录的 `.env.prod` | Compose `environment` |
| 回滚 | 覆盖为部署前备份的二进制,必要时恢复数据库备份 | 不适用 |
不得从 `.gitea/workflows/deploy.yaml``docker-compose.prod.yml` 或其中的测试配置推断生产地址、凭据、发布步骤或运行拓扑。
## 已确认运行布局
- 主机Ubuntu 24.04.4 LTS`x86_64`
- API 服务 `junhong-cmp-api.service`:工作目录与程序分别为 `/opt/junhong_cmp/api``/opt/junhong_cmp/api/api`,通过同目录 `.env.prod` 加载环境变量;旧二进制备份在 `releases/`
- 调度 Worker 服务 `junhong-cmp-worker.service`:工作目录与程序分别为 `/opt/junhong_cmp/worker``/opt/junhong_cmp/worker/worker`,通过根目录 `.env.prod` 加载环境变量。
- 三个执行 Worker 服务 `junhong-cmp-worker-1.service``junhong-cmp-worker-2.service``junhong-cmp-worker-3.service`:共享 `/opt/junhong_cmp/worker/worker` 这一份二进制;工作目录和 `EnvironmentFile` 分别是 `worker-1/``worker-2/``worker-3/` 下的 `.env.prod`。因此发布只替换根目录的 `worker`,不复制三份二进制。
- 五个 Unit 都以 `root``Restart=always``RestartSec=3` 运行,标准输出和错误写入 journald。
- Worker 拓扑:调度实例已确认 `JUNHONG_WORKER_ROLE=leader``JUNHONG_WORKER_INSTANCE_NAME=leader`;三个执行实例均为 `JUNHONG_WORKER_ROLE=consumer`。当前三个执行实例的 `JUNHONG_WORKER_INSTANCE_NAME` 都是 `worker-1`,会混淆日志与 Outbox Relay 实例标识;七月发布前须分别修正为 `worker-1``worker-2``worker-3`
- 域名:`xm-iot.cn`API 域名:`cmp-api.xm-iot.cn`
- 企微回调由自建企微中台 `wecom.xm-iot.cn` 配置;支付和运营商回调由各官方平台配置。
## 构建与上传
维护者当前在本地构建:
```bash
GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/api ./cmd/api
GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
```
本次迭代更新上传 API 与 Worker 二进制;配置变化由维护者直接修改各运行目录的 `.env.prod`。生产迁移文件必须随本次发布上传到 API 目录下的 `migrations/`,并同步上传当前分支的 `scripts/migrate.sh`,供 `migrate` 记录和执行。生产服务器当前尚未安装 `migrate`;安装路径和版本待首次迁移前确认。
## 配置规则
- 二进制只从嵌入默认配置和 `JUNHONG_` 环境变量读取;`.env.prod` 必须由 systemd Unit 显式加载,或由 Unit 启动脚本 `source` 后启动。需要以 Unit 内容核实实际方式。
- API 与 Worker 共享数据库、Redis、日志、JWT、对象存储、Gateway、短信、支付等基础配置只记录键名不将实际凭据写入仓库文档。
- 七月新增的 API/Worker 共用配置:`JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED``JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED``JUNHONG_WECOM_BASE_URL``JUNHONG_WECOM_TIMEOUT`
- 七月新增的 Worker 配置:`JUNHONG_WORKER_ROLE``JUNHONG_WORKER_INSTANCE_NAME``JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`
- 首发要求:退款人工入口、线下充值人工确认、企微审批、运营商实名回调、微信/支付宝在线充值均按维护者决定启用;审计物理清理保持关闭。
- 企微应用凭据由后台配置写入数据库明文字段;这不是启动环境变量。本文不记录其值。
## 人工发布与迁移
已确认顺序:备份二进制和数据库 → 上传二进制及迁移文件 → 停止 systemd 服务 → 执行迁移 → 启动服务。
生产当前迁移版本已于 2026-08-13 由维护者在 API 目录验证为 `140`(非 dirty。历史迁移已归档生产从 `140` 向后执行根目录 `migrations/``141+` 迁移;七月分支最终目标为 `209`。根目录不存在 `173` 号迁移,这是正常编号空档。执行前先以显式 `DB_*` 参数运行:
```bash
DB_HOST=<生产主机> DB_PORT=<端口> DB_USER=<用户> \
DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
./scripts/migrate.sh version
DB_HOST=<生产主机> DB_PORT=<端口> DB_USER=<用户> \
DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
./scripts/migrate.sh up
```
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
### 锁的含义与发布影响
`000171` 会对 `tb_agent_wallet``000178` 会对 `tb_iot_card` 使用 PostgreSQL `ACCESS EXCLUSIVE` 锁。该锁执行期间会阻塞该表的读写及其他 DDL直到迁移事务提交或回滚若有未结束业务查询/事务,它也会等待。因此必须在 API 和全部 Worker 停止后执行,并在迁移前检查没有长事务。锁持续时间取决于表数据量、索引创建和等待中的旧事务;不能从仓库估算具体秒数。
## 待维护者确认的最小信息
1. `migrate` 的安装路径、版本和迁移文件上传命令;确认 `140` 到首个根目录迁移版本之间是否存在待补的迁移文件。
2. 数据库恢复的准确命令、恢复前提,以及发布前新建备份的执行人。
3. 已停止服务后的锁前检查命令/结果(至少确认无长事务),以及可接受维护窗口。
4. 企微中台转发到 API 的最终回调路径;支付与运营商平台配置的回调 URL 清单(可脱敏域名外路径)。
## 已确认数据库备份
每日凌晨 02:00 自动备份 `junhong_cmp_prod`:数据库运行在 Docker 容器 `postgres` 中,备份脚本执行 `pg_dump -Fc -Z 6`,写入 `/data/backups/postgresql/<库名>_<时间>.dump`,同时生成 MD5 文件并以 `pg_restore --list` 校验结构;保留 30 天。发布前仍须人工新建一次备份并确认校验通过,不能只依赖凌晨的最近备份。恢复命令待维护者实际演练或确认后补充。

View File

@@ -1,328 +0,0 @@
# 生产数据库手工上线流程
## 1. 适用场景
本文档用于君鸿卡管系统**首次生产上线**时的数据库初始化,目标是:
1. **不依赖应用启动自动建表**
2. **人工创建生产库和表结构**
3. **人工导入基础数据**
4. **人工对齐 `schema_migrations` 版本**
5. 后续继续沿用 `migrations/` 中的新增迁移
> 截至 **2026-05-06**,仓库内最新迁移版本为 **140**。
> 当前仓库缺少可直接用于“全新环境从 0 建表”的 `000114_squash_baseline`,因此首次生产上线建议走“**手工 schema 基线 + 手工基础数据导入 + 手工迁移版本对齐**”。
---
## 2. 上线原则
### 2.1 本次建议做的事
1. 从你确认过的**源库**生成一份生产首发 schema 基线 SQL
2. 在生产环境手工创建空数据库
3. 手工执行 schema 基线 SQL 建表
4. 导入基础数据
5. 手工写入 `schema_migrations=140`
6. 启动 API / Worker
### 2.2 本次不建议做的事
1. 不依赖应用自动建表
2. 不直接把当前活跃 `migrations/` 当作全新建库链路执行
3. 不把业务数据表(订单、卡、设备、钱包流水等)从测试库整库导入生产
4. 不直接复用测试环境里的密钥、回调地址、商户配置而不做复核
---
## 3. 基础数据范围
### 3.1 建议导入的非敏感基础数据
以下表建议从已确认的源库导出,再导入生产库:
1. `tb_permission`
2. `tb_role`
3. `tb_role_permission`
4. `tb_carrier`
5. `tb_commission_withdrawal_setting`
6. `tb_polling_config`
7. `tb_polling_concurrency_config`
8. `tb_polling_alert_rule`
9. `tb_data_cleanup_config`
### 3.2 建议单独处理的敏感基础数据
以下表包含支付、公众号、小程序、证书、密钥等敏感信息,建议单独导出或手工填充:
1. `tb_wechat_config`
### 3.3 不建议首发直接导入的数据
以下数据不属于“生产首发基础数据”,通常不要从测试库带入生产:
1. `tb_account`
2. `tb_account_role`
3. `tb_shop`
4. `tb_enterprise`
5. `tb_iot_card`
6. `tb_device`
7. `tb_order`
8. 各类钱包、流水、日志、审计、导入任务表
---
## 4. 关于表结构基线
### 4.1 建议默认排除的确认遗留表
以下两张表已具备明确“遗留/保留历史”的语义,生成生产首发 schema 基线时建议默认排除:
1. `tb_data_usage_record`
2. `tb_card_replacement_record_legacy`
### 4.2 建议保守保留、后续再评估的表
以下表当前在代码里的活跃引用较少或暂无数据,但仍不建议在首次生产上线时贸然删除,先按兼容方式保留更稳:
1. `tb_card_replacement_request`
2. `tb_number_card`
3. `tb_payment_merchant_setting`
4. `tb_dev_capability_config`
---
## 5. 交付文件
本目录对应的手工上线辅助文件位于:
`scripts/manual_db_release/`
按执行顺序主要包括:
1. `01_create_database.sql`
2. `02_generate_schema_baseline.sh`
3. `03_export_base_data_from_source.sh`
4. `04_import_base_data_into_target.sh`
5. `05_repair_base_data_sequences.sql`
6. `06_mark_schema_migrations.sql`
7. `07_verify_manual_release.sql`
---
## 6. 手工上线步骤
## 6.1 第一步:确定源库
源库应满足:
1. 结构版本已确认正确
2. 基础配置完整
3. 权限、角色、运营商、轮询配置经过业务验收
建议不要临时选一个“有人随手改过但未复核”的测试库。
---
## 6.2 第二步:手工创建生产数据库
先修改:
`scripts/manual_db_release/01_create_database.sql`
将其中的占位符替换为:
1. `__DB_NAME__`
2. `__DB_USER__`
3. `__DB_PASSWORD__`
然后执行:
```bash
psql "postgres://postgres:你的密码@生产PG主机:5432/postgres?sslmode=disable" \
-f scripts/manual_db_release/01_create_database.sql
```
---
## 6.3 第三步:生成 schema 基线 SQL
使用脚本:
```bash
SOURCE_DSN="postgres://源库账号:源库密码@源库主机:5432/源库名?sslmode=disable" \
OUTPUT_SQL="/你的目录/00_schema_baseline.sql" \
bash scripts/manual_db_release/02_generate_schema_baseline.sh
```
默认会排除:
1. `schema_migrations`
2. `tb_data_usage_record`
3. `tb_card_replacement_record_legacy`
生成后请人工复核一次:
1. 是否包含你确认不需要的历史表
2. 是否错误带入测试专用对象
3. 是否包含意外的 `OWNER TO`
4. 是否包含不应该进入生产的注释性对象
---
## 6.4 第四步:在生产库执行 schema 基线
```bash
psql "postgres://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@cxd.whcxd.cn:16159/junhong_cmp_prod?sslmode=disable" \
-v ON_ERROR_STOP=1 \
-f /你的目录/00_schema_baseline.sql
```
执行后先不要急着启动应用,先导入基础数据。
---
## 6.5 第五步:导出基础数据
导出非敏感基础数据:
```bash
SOURCE_DSN="postgres://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" \
bash scripts/manual_db_release/03_export_base_data_from_source.sh
```
如需连同微信配置一起导出:
```bash
SOURCE_DSN="postgres://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" \
INCLUDE_SENSITIVE=1 \
bash scripts/manual_db_release/03_export_base_data_from_source.sh
```
导出结果会放到:
`scripts/manual_db_release/generated/`
> 注意:`generated/` 下的 SQL 可能包含密钥和证书内容,不要提交到 Git。
---
## 6.6 第六步:导入基础数据
在生产库执行:
```bash
TARGET_DSN="postgres://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@cxd.whcxd.cn:16159/junhong_cmp_prod?sslmode=disable" \
bash scripts/manual_db_release/04_import_base_data_into_target.sh
```
如需导入敏感配置:
```bash
TARGET_DSN="postgres://生产库账号:生产库密码@生产库主机:5432/生产库名?sslmode=disable" \
INCLUDE_SENSITIVE=1 \
bash scripts/manual_db_release/04_import_base_data_into_target.sh
```
---
## 6.7 第七步:修正序列值
```bash
psql "postgres://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@cxd.whcxd.cn:16159/junhong_cmp_prod?sslmode=disable" \
-v ON_ERROR_STOP=1 \
-f scripts/manual_db_release/05_repair_base_data_sequences.sql
```
这一步用于避免显式插入 ID 后,自增序列仍停留在旧值。
---
## 6.8 第八步:手工对齐迁移版本
```bash
psql "postgres://生产库账号:生产库密码@生产库主机:5432/生产库名?sslmode=disable" \
-v ON_ERROR_STOP=1 \
-f scripts/manual_db_release/06_mark_schema_migrations.sql
```
执行后,生产库会被视为已处于 `140` 版本。
后续如果仓库新增 `141+` 迁移,就可以继续正常执行 `migrate up`
> 如果你上线时仓库最新迁移版本已经不是 `140`,记得同步修改 `06_mark_schema_migrations.sql`。
---
## 6.9 第九步:上线前校验
```bash
psql "postgres://生产库账号:生产库密码@生产库主机:5432/生产库名?sslmode=disable" \
-f scripts/manual_db_release/07_verify_manual_release.sql
```
重点关注:
1. `schema_migrations.version=140`
2. 权限、角色、角色权限数量不为 0
3. 运营商配置数量不为 0
4. 轮询配置数量不为 0
5. `tb_wechat_config` 是否按预期导入
---
## 6.10 第十步:启动应用
确认以下环境变量改为生产值:
1. `JUNHONG_DATABASE_HOST`
2. `JUNHONG_DATABASE_PORT`
3. `JUNHONG_DATABASE_USER`
4. `JUNHONG_DATABASE_PASSWORD`
5. `JUNHONG_DATABASE_DBNAME`
6. `JUNHONG_REDIS_*`
7. `JUNHONG_JWT_SECRET_KEY`
特别注意:
1. 当前仓库里的 `docker-compose.prod.yml` 示例仍是测试库参数,不能原样直接用于生产
2. 如果生产库里没有超级管理员账号,应用启动时会自动创建一个超级管理员账号
3. 如不希望使用默认超管账号,请提前设置 `JUNHONG_DEFAULT_ADMIN_USERNAME``JUNHONG_DEFAULT_ADMIN_PASSWORD``JUNHONG_DEFAULT_ADMIN_PHONE`
---
## 7. 推荐执行顺序汇总
```text
手工创建生产库
-> 生成 schema 基线 SQL
-> 执行 schema 基线 SQL
-> 导出基础数据
-> 导入基础数据
-> 修正序列
-> 写入 schema_migrations=140
-> 校验
-> 启动 API / Worker
```
---
## 8. 风险提醒
### 8.1 关于 `tb_wechat_config`
该表包含敏感配置,建议:
1. 单独导出
2. 单独传输
3. 单独审批
4. 导入后立刻校验回调地址、商户号、证书内容
### 8.2 关于账号初始化
本次流程**不建议**直接从测试库导入 `tb_account`
首发生产环境建议只导入权限体系和系统配置,让生产超管账号在受控条件下独立创建。
### 8.3 关于后续迁移
本次只是首次生产上线的“人工基线落地”。
后续版本发布仍应继续补齐正式的 `000114_squash_baseline`,避免长期依赖手工基线流程。

View File

@@ -1,16 +0,0 @@
# 设备列表 IMEI 搜索功能总结
## 功能说明
后台设备列表接口 `GET /api/admin/devices` 支持通过 `imei` 查询参数按设备 IMEI 模糊搜索设备。
## 影响范围
- 请求参数新增 `imei`,最大长度 20。
- 查询链路从 Handler 解析参数后,经 Service 传递到 Store 层过滤条件。
- OpenAPI 文档已同步新增 `imei` 查询参数。
## 验证方式
- 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档。
- 执行 `go build ./...` 验证编译通过。

View File

@@ -1,31 +0,0 @@
# 设备状态拆分功能总结
## 背景
设备原 `status` 同时表达在库、分销、激活和停用,导致“已分销且已激活”的设备只能展示一个状态,语义混淆。
## 调整内容
- `status` 收敛为设备归属状态:`1=在库``2=已分销`
- 设备响应新增 `activation_status``activation_status_name`
- 设备列表和批量筛选支持按 `activation_status` 查询
- 设备激活状态不落库,按查询实时计算
- 历史 `status=3/4` 迁移为归属状态:有 `shop_id` 归为已分销,无 `shop_id` 归为在库
## 激活判定
设备激活必须同时满足:
1. 存在生效中的主套餐(`tb_package_usage.status=1``master_usage_id IS NULL`
2. 设备任意绑定卡已实名(`tb_iot_card.real_name_status=1`
## 响应示例
```json
{
"status": 2,
"status_name": "已分销",
"activation_status": 1,
"activation_status_name": "已激活"
}
```

View File

@@ -1,821 +0,0 @@
# 君鸿卡管系统资产详情体系重构 - 讨论纪要
> 创建时间2026-03-12
> 最后更新2026-03-14
> 当前阶段:设计讨论(尚未进入 openspec 提案)
> 目的:保留完整上下文,供未来继续
---
## 一、背景与需求来源
### 1.1 项目背景
君鸿卡管系统junhong_cmp_fiber是一个面向代理/企业的物联网卡管理平台,核心资产有两类:
- **IoT 卡IotCard**:纯卡资源,含 ICCID、MSISDN、流量套餐
- **设备Device**:带卡的硬件设备,一个设备可绑定多张卡,设备级套餐
### 1.2 需求触发点
核心痛点:
1. **接口分散且重复** - 卡和设备的查询散落在多处H5/Admin/Personal 三端各有一套
2. **详情信息严重缺失** - 现有的详情接口返回数据太少,前端无法据此渲染完整页面
3. **网关裸数据透传** - 封装程度不够,没有业务层的聚合和处理
4. **虚拟号只存在于设备** - 卡的查询只能靠 ICCID/MSISDN不方便
### 1.3 已确认的核心决策
-**多接口组合** - 不做单一聚合大接口,前端按需调用
-**统一入口** - 一个接口告诉前端查的是"卡"还是"设备"
-**设备优先查找** - 统一入口先查设备表,再查卡表
-**卡加虚拟号** - 虚拟号概念延伸到卡,与设备的 virtual_no 对等
-**全部一步到位** - 改造不分期,一次性完成
-**resolve 返回中等版本** - 包含资产类型、ID、虚拟号、状态、实名状态、套餐概况、流量使用、所属设备如果绑定等关键信息
-**资产类型只有卡和设备两种** - 未来路由器也归属设备,无需预留更多类型
-**虚拟号客服和客户都要用** - 不是只有内部人员用
-**H5 端接口暂时不需要提供** - 后续做到时再删除旧接口
-**套餐查询看历史记录** - 通过套餐记录/订单记录页面查看历史,同时提供当前套餐接口
-**手动刷新接口复用 SyncCardStatusFromGateway** - 无需重新实现,设备时批量刷新所有绑定卡
-**权限不足返回 403** - 明确告知无权限,不假装资产不存在
-**虚拟号人工填写/批量导入** - 无格式规范,允许修改,重复时全批失败并告知原因
-**device_no 字段全量改名为 virtual_no** - 数据库+代码全部更新,不保留旧字段
-**设备停复机有保护期机制** - 保护状态一致性,时长 1 小时,存储在 Redis
-**realtime-status 只查持久化数据** - 不调用网关,刷新用 refresh 接口
-**未实名的卡不参与停复机** - 未实名卡永远是停机状态,保护期逻辑跳过
-**企业账号 resolve 接口** - 企业账号暂不支持 resolve未来单独开新接口
-**resolve 响应含卡 ICCID** - card 类型时在响应中返回 ICCID供前端调用停复机接口
-**批量停机部分失败仍设保护期** - 部分卡停机失败时也设置 Redis 保护期,已停机的卡不回滚,失败的卡记录日志
-**流量汇总逻辑统一** - 整个系统使用统一的流量汇总逻辑;设备级套餐从 PackageUsage 汇总多卡用量
-**套餐历史列表规则** - 按创建时间倒序,不分页,包含所有状态(含已失效)
-**current-package 返回主套餐** - 多套餐同时生效时只返回主套餐master_usage_id IS NULL
-**轮询系统新增第四种任务** - 保护期一致性检查封装为独立轮询任务类型,不修改现有三种任务
-**卡虚拟号导入只补空白** - 只允许为现有空白虚拟号的卡填入,不支持覆盖更新;与数据库现存数据重复则全批失败
-**设备批量刷新需限频** - Redis 限频保护,同一设备冷却期内(建议 30 秒)不允许重复触发
-**PersonalCustomerDevice 统一改名** - tb_personal_customer_device 表的 device_no 字段一并改为 virtual_no
-**realtime-status 与 resolve 分工明确** - resolve 用于初始加载含查找realtime-status 用于已知 ID 的轻量状态轮询(不含套餐流量计算)
---
## 二、现有系统审计结果
### 2.1 接口现状(三端盘点)
| 端 | 卡接口数 | 设备接口数 | 重复停复机 | 套餐接口 |
|---|---------|-----------|-----------|---------|
| Admin | 9 | 14 | 3处 | 仅流量详单 |
| H5 | 4 | 7 | 1处 | 有套餐聚合 |
| Personal | 2 | 0 | 无 | 无 |
**重复停复机的三处实现:**
1. Admin 卡端:`POST /iot-cards/:iccid/suspend|resume`(按 ICCID
2. Admin 企业卡端:`POST /enterprises/:id/cards/:card_id/suspend|resume`(按 card_id
3. H5 企业设备端:`POST /h5/devices/:device_id/cards/:card_id/suspend|resume`(按 card_id
### 2.2 DTO 缺失分析
#### 卡详情IotCardDetailResponse
```go
// 当前实现 (iot_card_dto.go:134-136)
type IotCardDetailResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *StandaloneIotCardResponse `json:"data"` // 只是空壳嵌套!
}
```
**问题**:详情响应只是列表响应的空包装,完全没有额外信息。无套餐、无所属设备、无聚合流量。
#### 设备详情DeviceResponse
```go
// 当前实现 (device_dto.go:20)
type DeviceResponse struct {
// ... 基本字段
BoundCardCount int `json:"bound_card_count"` // 只有一个数字!
}
```
**问题**:只返回绑定卡数量,看不到每张卡的实名状态、卡状态、流量使用。
#### H5 端已有参考实现
`EnterpriseDeviceDetailResp`enterprise_device_authorization_dto.go是目前唯一有"设备+绑定卡列表"聚合的 DTO可作为 admin 端改造的参考。
### 2.3 网关接口问题
**6 个网关查询接口全部是纯透传**
- `gateway.GetCardStatus`
- `gateway.GetFlowUsage`
- `gateway.GetRealNameStatus`
- `gateway.GetDeviceInfo`
- `gateway.GetSlotInfo`
- `gateway.GetDeviceFlowUsage`
**问题**:只读不写,不更新 DB 缓存,无业务封装。
### 2.4 数据模型现状
| 模型 | 虚拟号 | 缓存字段 | 套餐载体 |
|-----|-------|---------|---------|
| IotCard | ❌ 无(需新增) | CurrentMonthUsageMB, NetworkStatus, RealNameStatus, LastDataCheckAt | IotCardID |
| Device | ✅ device_no需改名为 virtual_no | 无 | DeviceID |
**关键发现**
- `PackageUsage` 模型已支持两种载体:`IotCardID`(单卡)和 `DeviceID`(设备级)
- `IotCard.IsStandalone` 字段由触发器维护,标识卡是否绑定到设备
- `DeviceStore.GetByIdentifier` 已实现多字段匹配:`WHERE device_no = ? OR imei = ? OR sn = ?`(改造后改为 virtual_no
---
## 三、设计方向(已确认)
### 3.1 统一资产入口resolve
**接口**`GET /api/admin/assets/resolve/:identifier`
**查找逻辑**
```
1. 先查 device 表virtual_no / imei / sn
2. 未命中则查 iot_card 表virtual_no / iccid / msisdn
3. 应用数据权限过滤:代理只能看自己及下级店铺的资产,平台账号看所有
4. 有权限 → 返回资产数据(中等版本)
5. 无权限 → 返回 HTTP 403
6. 未找到 → 返回 HTTP 404
```
**响应结构(已确认)**
```go
// AssetResolveResponse 资产解析响应
type AssetResolveResponse struct {
// 基础信息
AssetType string `json:"asset_type"` // "device" 或 "card"
AssetID uint `json:"asset_id"` // 对应表的主键
VirtualNo string `json:"virtual_no"` // 统一虚拟号字段(设备/卡均用此字段)
ICCID string `json:"iccid,omitempty"` // 仅 card 类型时有值,供前端调用停复机接口使用
// 状态信息
Status int `json:"status"` // 资产状态
RealNameStatus int `json:"real_name_status"` // 实名状态
// 套餐和流量信息(无套餐时返回空字符串/0
CurrentPackage string `json:"current_package"` // 当前套餐名称
PackageTotalMB float64 `json:"package_total_mb"` // 真总流量套餐标称RealDataMB
PackageVirtualMB float64 `json:"package_virtual_mb"` // 虚总流量停机阈值VirtualDataMB
PackageUsedMB float64 `json:"package_used_mb"` // 客户端展示已使用流量(经虚流量换算)
PackageRemainMB float64 `json:"package_remain_mb"` // 客户端展示剩余流量
// 保护期状态(设备类型,以及绑定该设备的卡均返回)
DeviceProtectStatus string `json:"device_protect_status"` // "none" / "stop" / "start"
// 绑定信息(仅 card 类型,且卡绑定了设备时才有值)
BoundDeviceID *uint `json:"bound_device_id,omitempty"`
BoundDeviceNo string `json:"bound_device_no,omitempty"`
BoundDeviceName string `json:"bound_device_name,omitempty"`
// 设备类型特有:绑定卡信息
BoundCardCount int `json:"bound_card_count"`
Cards []DeviceCardInfo `json:"cards,omitempty"` // 包含所有状态的卡(含未实名)
}
// DeviceCardInfo 设备下绑定卡信息
type DeviceCardInfo struct {
IotCardID uint `json:"iot_card_id"`
ICCID string `json:"iccid"`
VirtualNo string `json:"virtual_no"`
RealNameStatus int `json:"real_name_status"`
NetworkStatus int `json:"network_status"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb"`
LastSyncAt *time.Time `json:"last_sync_at"` // 最后与 Gateway 同步时间
}
```
**说明**
- 卡绑定的设备被软删除时,该卡视为独立卡,不填充绑定信息
- 设备下的 `cards` 列表包含所有绑定卡(含未实名、已停用)
### 3.2 套餐查询接口
**接口一**`GET /api/admin/assets/:asset_type/:id/packages`
- 返回所有套餐记录(含历史和当前生效套餐)
- 按 asset_type 区分查 PackageUsage.IotCardID 还是 PackageUsage.DeviceID
- 每条记录包含:套餐名称、真总流量、虚总流量、展示已使用、展示剩余、有效期、状态
- **排序**:按创建时间倒序(最新套餐在前)
- **分页**:不分页,全量返回
- **范围**:包含所有状态(含 status=4 已失效的历史套餐)
**接口二**`GET /api/admin/assets/:asset_type/:id/current-package`
- 返回当前生效的**主套餐**status=1 且 master_usage_id IS NULL的详细信息
- 当同时有主套餐 + 加油包生效时,只返回主套餐;需要查看加油包通过接口一的列表查看
- 包含完整流量明细:真总量、虚总量、展示已使用、展示剩余
### 3.3 实时状态查询接口
**接口**`GET /api/admin/assets/:asset_type/:id/realtime-status`
**与 resolve 的定位分工**
> **resolve**:初始加载使用,包含查找逻辑 + 全量聚合数据(套餐/流量/绑定信息),数据较重。
> **realtime-status**:已知资产 ID 后的轻量状态轮询,**不含套餐流量计算**,专注于网络/实名/保护期状态的快速刷新。
**说明**
- **只查询持久化数据DB/Redis不调用网关**
- 返回最近一次轮询/刷新同步到系统的状态
- "实时性"依赖轮询系统保持数据新鲜(实名 5 分钟,流量/套餐 10 分钟)
- 需要最新数据时,先调用 refresh 接口手动刷新,再查此接口
- 设备类型返回:保护期状态 + 每张绑定卡的状态(网络/实名/流量/最后同步时间)
- 卡类型返回:网络状态 + 实名状态 + 流量使用 + 最后同步时间
### 3.4 手动刷新接口
**接口**`POST /api/admin/assets/:asset_type/:id/refresh`
**说明**
- 调用网关获取最新数据,写回 DB 更新缓存字段,返回刷新后的最新状态
- 卡类型:调用已有的 `SyncCardStatusFromGateway(iccid)` 方法
- 设备类型:批量刷新所有绑定卡(遍历调用 `SyncCardStatusFromGateway`
- **设备类型需要频率限制**:通过 Redis 记录最后刷新时间,同一设备冷却期内(建议 30 秒)不允许重复触发,防止前端多次快速点击打爆网关
### 3.5 设备停复机保护期机制
**背景**
设备本身没有停机/复机概念,对设备停机 = 批量停用其下所有已实名卡。保护期机制确保操作期间所有卡的状态一致性,防止单卡被误操作破坏整体状态。
**接口**
- `POST /api/admin/assets/device/:device_id/stop`
- `POST /api/admin/assets/device/:device_id/start`
**保护期规则**
| 规则 | 说明 |
|------|------|
| 保护期时长 | **1 小时**(硬编码在代码常量中) |
| 存储方式 | Redis Key `protect:device:{device_id}:stop``protect:device:{device_id}:start`TTL=1小时 |
| 未实名的卡 | **不参与停复机操作**,未实名卡永远是停机状态,跳过不处理 |
| 重叠操作 | 设备在保护期内不允许再次发起相同或相反的停复机操作,返回 HTTP 403 |
| 批量停机部分失败 | 部分卡调网关失败时,**仍设置 Redis 保护期**;已成功停机的卡不回滚;失败的卡记录错误日志 |
**stop 保护期(设备停机后 1 小时内)**
- 对某张已实名卡手动发起复机 → **不允许**HTTP 403设备处于停机保护期
- 对某张已实名卡手动发起停机 → 允许(本已是停机,无冲突)
- 轮询系统发现某张已实名卡处于开机状态 → **强制调网关停机**,保持一致
**start 保护期(设备复机后 1 小时内)**
- 对某张已实名卡手动发起停机 → **允许**(用户可主动停单张卡)
- 对某张已实名卡手动发起复机 → 允许(本已是复机,无冲突)
- 轮询系统发现某张已实名卡处于停机状态 → **强制调网关复机**,保持一致
**保护期状态对外暴露**
- resolve 接口的 `device_protect_status` 字段返回当前保护期状态
- 卡绑定的设备有保护期时,该卡的 resolve 结果也返回 `device_protect_status`
### 3.6 接口去重(废弃清单)
**废弃接口**(直接删除,不保留向后兼容):
| 废弃接口 | 替代接口 |
|---------|---------|
| `POST /enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
| `POST /h5/devices/:device_id/cards/:card_id/suspend` | `POST /api/admin/assets/device/:device_id/stop` |
| `POST /h5/devices/:device_id/cards/:card_id/resume` | `POST /api/admin/assets/device/:device_id/start` |
| 旧 Admin 卡停复机接口(按 ICCID | `POST /api/admin/assets/card/:iccid/stop|start` |
| `GET /devices/:id` | `GET /api/admin/assets/device/:id` |
### 3.7 数据层变更
**变更一:设备表字段改名(全量重构)**
```sql
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
```
涉及改动范围Model 定义、DTO 响应、Store 查询、所有引用 `device_no` 的代码,以及 `tb_personal_customer_device` 表的 `device_no` 字段(一并改名为 `virtual_no`),确保系统中不再有 `device_no` 的身影。
**变更二:卡表新增 virtual_no 字段**
```sql
ALTER TABLE tb_iot_card ADD COLUMN virtual_no VARCHAR(50);
CREATE UNIQUE INDEX idx_iot_card_virtual_no
ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL;
```
- 允许为空(老数据无虚拟号)
- 允许手动修改
- 全局唯一(导入时检测重复,重复则全批失败并告知具体冲突数据)
**变更三:套餐表新增 virtual_ratio 字段**
```sql
ALTER TABLE tb_package ADD COLUMN virtual_ratio DECIMAL(10,6) DEFAULT 1.0;
```
- 创建套餐时计算并存储:`virtual_ratio = real_data_mb / virtual_data_mb`
- 用于客户端展示的流量换算(见第六节)
- 未启用虚流量时(`enable_virtual_data=false`virtual_ratio = 1.0
---
## 四、完整接口清单
| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) |
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) |
| 2 | GET | `/api/admin/assets/:asset_type/:id/packages` | 套餐记录(历史+当前) |
| 3 | GET | `/api/admin/assets/:asset_type/:id/current-package` | 当前生效主套餐详情 |
| 4 | GET | `/api/admin/assets/:asset_type/:id/realtime-status` | 当前持久化状态查询(轻量) |
| 5 | POST | `/api/admin/assets/:asset_type/:id/refresh` | 手动刷新(调网关写回 DB |
| 6 | POST | `/api/admin/assets/device/:device_id/stop` | 设备停机(批量停所有已实名卡) |
| 7 | POST | `/api/admin/assets/device/:device_id/start` | 设备复机(批量开所有已实名卡) |
| 8 | POST | `/api/admin/assets/card/:iccid/stop` | 卡停机 |
| 9 | POST | `/api/admin/assets/card/:iccid/start` | 卡复机 |
> `:asset_type` 取值:`device` 或 `card`
---
## 五、流程图
### 5.1 资产查找resolve流程
```mermaid
flowchart TD
A["GET /api/admin/assets/resolve/:identifier"] --> B{"查询设备表\nvirtual_no / imei / sn"}
B -->|找到| C{"应用数据权限过滤\n代理:仅自己及下级店铺\n平台:所有资产"}
B -->|未找到| D{"查询卡表\nvirtual_no / iccid / msisdn"}
D -->|找到| C
D -->|未找到| E["返回 HTTP 404\n资产不存在"]
C -->|有权限| F["聚合资产数据\n基础信息 + 状态 + 套餐流量 + 保护期 + 绑定信息"]
C -->|无权限| G["返回 HTTP 403\n无权限查看该资产"]
F --> H["返回 AssetResolveResponse"]
```
### 5.2 设备停机/复机流程
```mermaid
flowchart TD
subgraph 设备停机
A1["POST /assets/device/:id/stop"] --> B1{"设备是否存在?"}
B1 -->|否| C1["HTTP 404"]
B1 -->|是| D1{"设备是否在保护期?"}
D1 -->|是| E1["HTTP 403\n设备处于保护期不允许操作"]
D1 -->|否| F1["获取所有已实名下属卡"]
F1 --> G1["批量调网关停机"]
G1 --> H1["更新各卡 NetworkStatus=停机\n部分失败时已成功的卡不回滚"]
H1 --> I1["Redis SET protect:device:id:stop\nTTL = 1 小时(部分失败时仍设置)"]
I1 --> J1["返回成功(附带失败卡日志)"]
end
subgraph 设备复机
A2["POST /assets/device/:id/start"] --> B2{"设备是否存在?"}
B2 -->|否| C2["HTTP 404"]
B2 -->|是| D2{"设备是否在保护期?"}
D2 -->|是| E2["HTTP 403\n设备处于保护期不允许操作"]
D2 -->|否| F2["获取所有已实名下属卡"]
F2 --> G2["批量调网关复机"]
G2 --> H2["更新各卡 NetworkStatus=开机"]
H2 --> I2["Redis SET protect:device:id:start\nTTL = 1 小时"]
I2 --> J2["返回成功"]
end
```
### 5.3 手动操作单卡 + 保护期检查
```mermaid
flowchart TD
subgraph 手动停机单卡
A1["POST /assets/card/:iccid/stop"] --> B1{"卡是否存在?"}
B1 -->|否| C1["HTTP 404"]
B1 -->|是| D1{"卡是否已实名?"}
D1 -->|未实名| E1["HTTP 403\n未实名卡不允许停复机"]
D1 -->|已实名| F1{"卡是否绑定设备?"}
F1 -->|未绑定| G1["正常执行停机"]
F1 -->|已绑定| H1{"设备有 start 保护期?"}
H1 -->|是| I1["允许停机\n与 start 保护期方向一致"]
H1 -->|否| G1
end
subgraph 手动复机单卡
A2["POST /assets/card/:iccid/start"] --> B2{"卡是否存在?"}
B2 -->|否| C2["HTTP 404"]
B2 -->|是| D2{"卡是否已实名?"}
D2 -->|未实名| E2["HTTP 403\n未实名卡不允许停复机"]
D2 -->|已实名| F2{"卡是否绑定设备?"}
F2 -->|未绑定| G2["正常执行复机"]
F2 -->|已绑定| H2{"设备有 stop 保护期?"}
H2 -->|是| I2["HTTP 403\n设备处于停机保护期\n不允许手动复机"]
H2 -->|否| G2
end
```
### 5.4 轮询系统与保护期交互
```mermaid
flowchart TD
A["轮询任务触发:检查卡状态"] --> B{"卡是否已实名?"}
B -->|未实名| C["跳过,未实名卡不参与停复机逻辑"]
B -->|已实名| D{"卡是否绑定设备?"}
D -->|未绑定| E["按卡自身逻辑正常处理"]
D -->|已绑定| F{"设备是否有保护期?"}
F -->|无保护期| E
F -->|"stop 保护期"| G{"卡当前网络状态?"}
G -->|开机| H["强制调网关停机\n保持与设备保护期一致"]
G -->|停机| I["已一致,跳过"]
F -->|"start 保护期"| J{"卡当前网络状态?"}
J -->|停机| K["强制调网关复机\n保持与设备保护期一致"]
J -->|开机| L["已一致,跳过"]
```
### 5.5 手动刷新refresh流程
```mermaid
flowchart TD
A["POST /api/admin/assets/:type/:id/refresh"] --> B{"资产类型"}
B -->|card| C["调用 SyncCardStatusFromGateway(iccid)"]
C --> D["更新 iot_card 表\nNetworkStatus / RealNameStatus\nCurrentMonthUsageMB / LastSyncTime"]
D --> H["返回刷新后的最新状态"]
B -->|device| E["检查 Redis 限频(冷却期 30 秒)"]
E -->|冷却中| Z["HTTP 429 请勿频繁刷新"]
E -->|可刷新| F["查询所有绑定卡列表"]
F --> G["遍历每张卡\n调用 SyncCardStatusFromGateway"]
G --> H
```
### 5.6 实时状态查询realtime-status流程
```mermaid
flowchart TD
A["GET /api/admin/assets/:type/:id/realtime-status"] --> B{"资产类型"}
B -->|card| C["从 DB/Redis 读取持久化的卡状态"]
C --> D["返回卡状态\n网络状态 / 实名状态 / 本月已用流量\n最后同步时间"]
B -->|device| E["从 DB/Redis 读取持久化的设备数据"]
E --> F["读取所有绑定卡的持久化状态"]
F --> G["返回设备状态\n保护期状态 + 各绑定卡当前状态 + 最后同步时间"]
```
> **注意**:此接口**不调用网关**,展示的是最近一次轮询/刷新写入的持久化数据。
> 如需获取最新数据,请先调用 `POST /refresh` 接口,再查询此接口。
### 5.7 虚流量计算规则
```mermaid
flowchart TD
subgraph 创建["套餐创建时 - 存储比例"]
A1["RealDataMB = 10G 真总流量"] --> C1
A2["VirtualDataMB = 9G 虚总流量/停机阈值"] --> C1
C1["virtual_ratio = RealDataMB / VirtualDataMB\n= 10 / 9 ≈ 1.111\n存储到 tb_package.virtual_ratio"]
end
subgraph 停机["系统内部 - 停机判断"]
D1["真已使用\nCurrentMonthUsageMB"] --> E1{"真已使用 >= VirtualDataMB?"}
D2["VirtualDataMB = 9G"] --> E1
E1 -->|是| F1["触发停机"]
E1 -->|否| F2["正常运行"]
end
subgraph 展示["客户端展示 - 流量换算"]
G1["真已使用 = 9G"] --> H1
H1["展示已使用 = 真已使用 x virtual_ratio\n= 9G x 1.111 = 10G"]
G2["展示总量 = RealDataMB = 10G"]
H1 --> I1["客户看到 已用10G/共10G = 100% 已停机"]
end
```
---
## 六、虚流量计算规则详解
### 6.1 概念说明
| 字段 | 含义 | 来源 |
|------|------|------|
| 真总流量RealDataMB | 套餐标称总流量,用户购买的名义流量 | `Package.real_data_mb` |
| 虚总流量VirtualDataMB | 停机阈值,始终小于真总流量 | `Package.virtual_data_mb` |
| virtual_ratio | 换算比例 = RealDataMB / VirtualDataMB | `Package.virtual_ratio`(套餐创建时存储) |
| 真已使用 | 网关报告的实际用量 | `IotCard.current_month_usage_mb` |
| 展示已使用 | 客户看到的用量 = 真已使用 × virtual_ratio | 计算得出 |
| 展示剩余 | 客户看到的剩余 = 真总流量 展示已使用 | 计算得出 |
### 6.2 设计意图
虚总流量VirtualDataMB是系统内部的停机保护阈值。由于网关数据同步存在延迟若以真总流量作为停机阈值客户可能在用完 10G 后继续用到 10.5G 才被停机,产生超用。因此系统设置一个比真总流量略小的虚总流量(如 9G作为实际停机阈值保证不超用。
客户端展示时,系统将真实用量按比例换算回真总流量的尺度,使客户的体感与购买的套餐一致:
- 当真用量达到 9GVirtualDataMB卡被停机
- 此时展示用量 = 9G × (10G/9G) = 10G客户看到"已用 10G / 共 10G = 100%"
### 6.3 计算示例
| 场景 | 真总 | 虚总(停机阈值) | 真已使用 | 展示已使用 | 展示剩余 | 是否停机 |
|------|------|----------------|---------|-----------|---------|---------|
| 刚开始 | 10G | 9G | 0G | 0G | 10G | 否 |
| 用了一半 | 10G | 9G | 4.5G | 5G | 5G | 否 |
| 接近阈值 | 10G | 9G | 8G | ≈8.89G | ≈1.11G | 否 |
| 触发停机 | 10G | 9G | 9G | 10G | 0G | **是** |
### 6.4 未启用虚流量时
`Package.enable_virtual_data = false` 时:
- `virtual_ratio = 1.0`
- 停机阈值 = 真总流量RealDataMB
- 展示已使用 = 真已使用(无换算)
---
## 七、用户的思考与担忧(已全部解决)
### 7.1 关于接口粒度
**已确认**resolve 返回中等版本,多接口组合,前端按需调用。
### 7.2 关于网关封装程度
**已确认**
- realtime-status只查持久化数据不调用网关
- refresh调用网关并写回 DB更新缓存字段
### 7.3 关于停复机去重
**已确认**:所有停复机统一迁移到 assets 路径,旧接口直接删除。
### 7.4 关于虚拟号
**已确认**
- 卡的虚拟号给客服和客户用
- 人工填写/批量导入,无格式规范,允许修改
- 设备 device_no 全量重命名为 virtual_no
- 导入重复时全批失败,告知具体冲突数据
### 7.5 关于套餐查询
**已确认**:套餐查询分两个接口,历史套餐接口包含当前套餐,同时单独提供当前套餐接口。
### 7.6 关于停复机保护期
**已确认**:保护期 1 小时Redis 存储未实名卡不参与stop 保护期内禁止手动复机start 保护期内允许手动停机。
---
## 八、设计决策确认清单
| 序号 | 问题 | 确认结果 |
|-----|------|---------|
| 1 | resolve 返回数据范围 | 中等版本,含状态/套餐/流量/绑定信息/保护期 |
| 2 | realtime-status 和 refresh 区别 | realtime-status=查持久化数据轻量refresh=调网关写回DB |
| 3 | 实时状态封装 | 持久化数据展示,不调网关 |
| 4 | 手动刷新复用 SyncCardStatusFromGateway | 是,设备时批量刷新所有绑定卡 |
| 5 | 停复机统一 | 统一迁移到 /assets 路径,旧接口直接删除 |
| 6 | 卡虚拟号生成方式 | 人工填写/批量导入,无格式规范 |
| 7 | 废弃接口处理 | 直接删除 |
| 8 | 套餐查询接口 | 两个接口:历史套餐列表 + 当前套餐详情 |
| 9 | 权限不足的返回 | HTTP 403明确告知无权限 |
| 10 | 保护期时长 | 1 小时,硬编码常量 |
| 11 | 虚流量计算 | virtual_ratio=RealDataMB/VirtualDataMB套餐创建时存储 |
| 12 | device_no 改名 | 全量改为 virtual_no数据库+代码全部更新 |
| 13 | 设备下卡列表 | 包含所有状态的卡(含未实名、已停用) |
| 14 | 卡绑定设备被软删除时 | 视为独立卡,不填充绑定信息 |
| 15 | 未实名卡参与停复机 | 不参与,永远是停机状态,保护期跳过 |
| 16 | 数据权限规则 | 代理:仅自己及下级店铺,平台账号:所有资产 |
| 17 | 查找失败 404 还是 403 | 资产不存在=404有资产但无权限=403 |
| 18 | 设备卡列表排序 | 无要求 |
| 19 | resolve 中 current_package 无套餐时 | 返回空字符串/0 |
| 20 | 虚拟号唯一索引 | 需要,允许为空,允许手动修改 |
| 21 | 企业账号能否用 resolve | 暂不支持;企业账号未来开新接口 |
| 22 | 接口 #2(按主键查详情)的设计 | 已确认删除,与 resolve 功能重叠,无独立价值 |
| 23 | resolve 响应是否含 ICCID | 是card 类型时返回 ICCID供停复机接口使用 |
| 24 | 设备批量停机部分失败策略 | 仍设置 Redis 保护期;已成功停机的卡不回滚;失败的卡记录日志 |
| 25 | 流量数据汇总逻辑 | 统一用专门汇总逻辑,从 PackageUsage 读取;设备级套餐汇总所有绑定卡 |
| 26 | 套餐历史列表排序和范围 | 按创建时间倒序,不分页,包含所有状态(含 status=4 已失效) |
| 27 | current-package 多套餐时返回哪个 | 返回主套餐master_usage_id IS NULL |
| 28 | 轮询系统保护期检查实现方式 | 新增独立的第四种轮询任务类型,不修改现有三种任务 |
| 29 | 卡虚拟号导入规则 | 只允许为空白虚拟号的卡填入;与现存数据重复则全批失败 |
| 30 | 设备批量刷新频率限制 | 需要Redis 限频,同一设备冷却期(建议 30 秒)内不允许重复触发 |
| 31 | PersonalCustomerDevice.device_no 改名 | 是,统一改为 virtual_no与 tb_device 保持语义一致 |
| 32 | DeviceCardInfo 需要 last_sync_time | 是,添加 last_sync_at 字段 |
---
## 九、轮询系统补充说明
### 9.1 整体架构
轮询系统是君鸿卡管系统维护卡数据实时性的核心机制:
```
┌─────────────────────────────────────────────────────────────────────┐
│ Worker 服务(后台) │
├─────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Scheduler │────▶│ Asynq 队列 │────▶│ Handler │ │
│ │ (调度器) │ │ (任务队列) │ │ (处理器) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ 定时循环 (每秒) │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Redis Sorted Set 轮询队列 │ │
│ │ - polling:queue:realname (实名检查) │ │
│ │ - polling:queue:carddata (流量检查) │ │
│ │ - polling:queue:package (套餐检查) │ │
│ │ - polling:queue:protect (保护期一致性检查) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ 调用网关 API
┌──────────────────────┐
│ Gateway 网关 │
│ (第三方运营商) │
└──────────────────────┘
```
### 9.2 四种轮询任务
| 任务类型 | 触发频率 | 作用 | 更新字段 |
|---------|---------|------|---------|
| **实名检查** | 默认 5 分钟 | 调用网关查实名状态 | real_name_status |
| **流量检查** | 默认 10 分钟 | 调用网关查流量,更新套餐 | current_month_usage_mb |
| **套餐检查** | 默认 10 分钟 | 检查是否超额,触发停机 | network_status |
| **保护期检查** | 同流量检查频率 | 检查绑定设备保护期,强制同步卡的网络状态 | network_status |
> **第四种任务设计说明**:保护期一致性检查封装为独立任务类型,不嵌入现有三种任务内部。只检查"已绑定设备且设备当前有保护期"的卡,范围小,可与流量检查同频触发。
### 9.3 关键特点
1. **启动时渐进式初始化**:系统启动时把卡分批加载到 Redis 队列(每批 10 万张)
2. **按时间排序**Redis Sorted Set 的 score 是下次检查的时间戳,到期自动被调度器取出
3. **并发控制**:通过 Redis 信号量限制并发数(默认 50防止打爆网关
4. **失败重试**:任务失败后重新入队
5. **缓存优化**:优先从 Redis 读取卡信息,避免频繁查 DB
### 9.4 与手动刷新接口的关系
- **轮询是后台自动跑**:所有卡都会按配置的时间间隔被检查,保证日常数据更新
- **手动刷新是前台客服主动用**:只更新这一张卡(或设备的所有绑定卡),满足客户急用场景
- **两者是互补关系**:轮询保证数据不会太旧,手动刷新满足实时性要求高的场景
### 9.5 与设备保护期的交互
轮询系统在处理设备的绑定卡时,需要检查设备是否有保护期(见 5.4 流程图):
- 发现设备有 stop 保护期,且卡为开机状态 → 强制调网关停机
- 发现设备有 start 保护期,且卡为停机状态 → 强制调网关复机
- 未实名的卡跳过,不参与保护期逻辑
关键代码位置:
- `internal/task/polling_handler.go` - 轮询任务处理器(需新增独立的第四种任务:保护期一致性检查处理函数)
- `pkg/constants/redis.go` - 需新增 `RedisDeviceProtectKey()` 函数
### 9.6 涉及的关键代码
- `internal/polling/scheduler.go` - 轮询调度器(把卡加入队列)
- `internal/task/polling_handler.go` - 任务处理器(实际调网关更新数据)
- `internal/service/iot_card/service.go:799` - SyncCardStatusFromGateway 方法
---
## 十、下一步行动
### 10.1 当前阶段
**设计讨论** - 已完成,所有关键决策已确认,可进入 openspec 提案阶段
### 10.2 进入 openspec 提案后的任务拆分建议
**数据层(优先)**
1. 数据库迁移:设备表 `device_no``virtual_no`(同步更新 `tb_personal_customer_device.device_no``virtual_no`
2. 数据库迁移:卡表新增 `virtual_no` 字段(唯一索引,允许空)
3. 数据库迁移:套餐表新增 `virtual_ratio` 字段
4. 更新 Device Model 和所有引用 `device_no` 的代码(全量替换,含 PersonalCustomerDevice
5. 更新 Package Service创建/更新套餐时自动计算并存储 `virtual_ratio`
**接口层(依次实现)**
6. 实现资产入口 `GET /assets/resolve/:identifier`
7. 实现当前状态查询 `GET /assets/:type/:id/realtime-status`
8. 实现手动刷新 `POST /assets/:type/:id/refresh`(含设备批量刷新 + Redis 限频)
9. 实现套餐记录查询 `GET /assets/:type/:id/packages`
10. 实现当前套餐查询 `GET /assets/:type/:id/current-package`
11. 实现设备停机 `POST /assets/device/:id/stop`(含保护期逻辑 + 部分失败策略)
12. 实现设备复机 `POST /assets/device/:id/start`(含保护期逻辑)
13. 实现卡停机 `POST /assets/card/:iccid/stop`(含保护期检查)
14. 实现卡复机 `POST /assets/card/:iccid/start`(含保护期检查)
**轮询系统**
15. 新增第四种轮询任务:保护期一致性检查(独立任务类型,不修改现有三种任务内部逻辑)
**清理**
16. 删除废弃的停复机接口(见 3.6 废弃清单)
17. 丰富现有卡/设备 DTOIotCardDetailResponse、DeviceResponse
18. 更新 API 文档生成器docs.go 和 gendocs/main.go
### 10.3 涉及的关键代码文件
**Handler 层**
- `internal/handler/admin/iot_card.go`
- `internal/handler/admin/device.go`
- `internal/handler/h5/enterprise_device.go`(待删除的废弃接口)
**Service 层**
- `internal/service/iot_card/service.go`(含 SyncCardStatusFromGateway:799
- `internal/service/iot_card/stop_resume_service.go`(停复机逻辑,需扩展)
- `internal/service/device/service.go`(含 GetByIdentifier:177
- `internal/service/package/customer_view_service.go`(套餐聚合,需复用)
- `internal/service/package/service.go`(创建套餐时存储 virtual_ratio
**Store 层**
- `internal/store/postgres/device_store.go`GetByIdentifier:62改用 virtual_no
- `internal/store/postgres/iot_card_store.go`
- `internal/store/postgres/personal_customer_device_store.go`device_no → virtual_no
**Model 层**
- `internal/model/iot_card.go`(新增 virtual_no 字段)
- `internal/model/device.go`device_no → virtual_no
- `internal/model/package.go`(新增 virtual_ratio 字段)
- `internal/model/personal_customer_device.go`device_no → virtual_no
**DTO 层**
- `internal/model/dto/iot_card_dto.go`(需重构)
- `internal/model/dto/device_dto.go`(需丰富)
**常量层**
- `pkg/constants/redis.go`(新增 `RedisDeviceProtectKey()` 函数)
**轮询层**
- `internal/task/polling_handler.go`(新增保护期一致性检查独立任务处理函数)
---
## 十一、附录:关键代码片段
### 11.1 现有空壳详情 DTO
```go
// internal/model/dto/iot_card_dto.go:134-136
type IotCardDetailResponse struct {
StandaloneIotCardResponse // 只是列表响应的空包装
}
```
### 11.2 设备详情 DTO
```go
// internal/model/dto/device_dto.go:20
type DeviceResponse struct {
ID uint `json:"id"`
DeviceNo string `json:"device_no"` // 改名为 virtual_no
// ...
BoundCardCount int `json:"bound_card_count"` // 只有数字,需丰富
}
```
### 11.3 设备多字段查找 Store
```go
// internal/store/postgres/device_store.go:62
// 改造后device_no → virtual_no
func (s *Store) GetByIdentifier(db *gorm.DB, identifier string) (*model.Device, error) {
var device model.Device
err := db.Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
First(&device).Error
return &device, err
}
```
### 11.4 手动刷新方法(待暴露为接口)
```go
// internal/service/iot_card/service.go:799
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
// 已有实现,需作为接口暴露,并支持设备批量刷新
}
```
### 11.5 新增 Redis Key 常量
```go
// pkg/constants/redis.go
// RedisDeviceProtectKey 设备停复机保护期 Key
// action: "stop" 或 "start"TTL = 1 小时
func RedisDeviceProtectKey(deviceID uint, action string) string {
return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
}
// RedisDeviceRefreshCooldownKey 设备手动刷新冷却期 KeyTTL = 冷却时长(建议 30 秒)
func RedisDeviceRefreshCooldownKey(deviceID uint) string {
return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
}
```
### 11.6 virtual_ratio 计算位置
```go
// internal/service/package/service.go
// 创建/更新套餐时计算并存储 virtual_ratio
if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 {
pkg.VirtualRatio = float64(pkg.RealDataMB) / float64(pkg.VirtualDataMB)
} else {
pkg.VirtualRatio = 1.0
}
```
---
> **文档结束**
>
> 所有设计决策已确认,可进入 openspec 提案阶段。

View File

@@ -1,215 +0,0 @@
# DTO 规范完善总结
## 📊 修复概览
**修复日期**: 2026-01-20
**修复范围**: 所有 DTO 文件的 `description` 标签规范化
---
## ✅ 已修复的文件清单
### 1. 账号模块
-`internal/model/account_dto.go`
- 修复 `CreateAccountRequest.UserType` - 添加完整中文枚举说明
- 修复 `AccountListRequest.UserType` - 添加完整中文枚举说明
- 修复 `AccountListRequest.Status` - 添加 0/1 含义
- 修复 `AccountResponse.UserType` - 添加完整中文枚举说明
- 修复 `AccountResponse.Status` - 添加 0/1 含义
- 修复 `PlatformAccountListRequest.Status` - 添加 0/1 含义
### 2. 认证模块
-`internal/model/auth_dto.go`
-`UserInfo` 所有字段添加完整的 `description` 标签
- `UserType` 使用完整中文枚举说明
### 3. 角色模块
-`internal/model/role_dto.go`
- 修复 `RoleListRequest.Status` - 添加 0/1 含义
- 修复 `RoleResponse.RoleType` - 添加完整中文枚举说明
- 修复 `RoleResponse.Status` - 添加 0/1 含义
### 4. 权限模块
-`internal/model/permission_dto.go`
- 修复 `PermissionListRequest.PermType` - 添加完整中文枚举说明
- 修复 `PermissionListRequest.Platform` - 添加完整中文枚举说明
- 修复 `PermissionListRequest.Status` - 添加 0/1 含义
- 修复 `PermissionResponse.PermType` - 添加完整中文枚举说明
- 修复 `PermissionResponse.Platform` - 添加完整中文枚举说明
- 修复 `PermissionResponse.Status` - 添加 0/1 含义
- 修复 `PermissionTreeNode` - 所有字段添加完整说明
### 5. 店铺模块
-`internal/model/shop_dto.go`
- 从无 `description` 标签 → 所有字段添加完整的 `description` 标签
- 添加 validate 对应的 OpenAPI 标签required、minLength、maxLength 等)
- `Status` 字段添加 0/1 含义说明
- `Level` 字段说明店铺层级范围
### 6. 企业模块
-`internal/model/enterprise_dto.go`
- 从行内注释 → 改为 `description` 标签
- `Status` 字段添加 0/1 含义说明
- 所有字段添加清晰的中文说明
### 7. 个人客户模块
-`internal/model/personal_customer_dto.go`
- 从行内注释 → 改为 `description` 标签
- `Status` 字段添加 0/1 含义说明
- 所有字段添加清晰的中文说明
### 8. 代理商账号模块
-`internal/model/shop_account_dto.go`
- 从行内注释 → 改为 `description` 标签
- `UserType``Status` 字段添加完整枚举说明
### 9. 角色-权限关联模块
-`internal/model/role_permission_dto.go`
- 从无 `description` 标签 → 所有字段添加完整说明
- `Status` 字段添加 0/1 含义说明
### 10. 账号-角色关联模块
-`internal/model/account_role_dto.go`
- 从无 `description` 标签 → 所有字段添加完整说明
- `Status` 字段添加 0/1 含义说明
---
## 📈 修复前后对比
### 修复前 ❌
**问题 1**: 枚举值使用英文
```go
UserType int `json:"user_type" description:"用户类型 (1:SuperAdmin, 2:Platform, 3:Agent, 4:Enterprise)"`
```
**问题 2**: 缺少枚举值说明
```go
Status int `json:"status" description:"状态"`
```
**问题 3**: 使用行内注释而非 description 标签
```go
EnterpriseName string `json:"enterprise_name"` // 企业名称
```
**问题 4**: 完全没有 description 标签
```go
type ShopResponse struct {
ID uint `json:"id"`
ShopName string `json:"shop_name"`
Status int `json:"status"`
}
```
### 修复后 ✅
**所有枚举值使用完整中文说明**
```go
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
```
**状态字段明确说明 0/1 含义**
```go
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
```
**统一使用 description 标签**
```go
EnterpriseName string `json:"enterprise_name" description:"企业名称"`
```
**所有字段都有完整说明**
```go
type ShopResponse struct {
ID uint `json:"id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
}
```
---
## 📊 统计数据
### 生成的 OpenAPI 文档
- **文件路径**: `docs/admin-openapi.yaml`
- **Description 字段数量**: **375 个**
- **覆盖的 DTO 文件**: **10 个**
- **修复的字段**: **100+ 个**
### 枚举字段标准化
| 枚举类型 | 标准 Description | 使用次数 |
|---------|-----------------|---------|
| 用户类型 | `用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)` | 6 处 |
| 角色类型 | `角色类型 (1:平台角色, 2:客户角色)` | 3 处 |
| 权限类型 | `权限类型 (1:菜单, 2:按钮)` | 3 处 |
| 适用端口 | `适用端口 (all:全部, web:Web后台, h5:H5端)` | 5 处 |
| 状态 | `状态 (0:禁用, 1:启用)` | 15+ 处 |
---
## 🎯 达成的效果
### 1. 前端开发友好
- ✅ 所有 API 字段都有清晰的中文说明
- ✅ 枚举字段明确列出所有可能值
- ✅ 导入 Swagger UI/Apifox 后可直接使用
### 2. 文档自动生成
- ✅ OpenAPI 文档完全自动生成,无需手动编写
- ✅ 字段说明与代码同步,避免文档过期
### 3. 代码可维护性
- ✅ 统一的规范,新人容易上手
- ✅ Code Review 有明确的检查标准
---
## 📋 后续维护
### 新增 DTO 时必须遵循
1. ✅ 所有字段添加 `description` 标签
2. ✅ 枚举字段列出所有可能值(中文)
3. ✅ validate 标签与 OpenAPI 标签保持一致
4. ✅ 使用 `query``path` 标签标记参数类型
### Code Review 检查清单
详见:[docs/code-review-checklist.md](./code-review-checklist.md)
### 验证方法
```bash
# 生成文档
go run cmd/gendocs/main.go
# 检查特定字段的说明
grep -A 3 "user_type:" docs/admin-openapi.yaml
grep -A 3 "status:" docs/admin-openapi.yaml
# 统计 description 数量
grep -c "description:" docs/admin-openapi.yaml
```
---
## 🔗 相关文档
- [Code Review 检查清单](./code-review-checklist.md)
- [项目开发规范](../AGENTS.md)
- [README](../README.md)
---
## 👥 贡献者
- 开发团队
---
**最后更新**: 2026-01-20

View File

@@ -0,0 +1,556 @@
# 从零构建 Agent 友好项目Harness 工程实践与文档标准
> 用途:用于新项目初始化和长期治理。它定义“哪些知识需要存在、放在哪里、按什么格式写、如何验证”。
>
> 核心结论OpenSpec 只负责当前行为与行为变更;完整 Harness 还必须让 Agent 能找到事实、运行系统、观察结果、遵守边界、独立验证并持续清理熵。
## 1. 先澄清ADR 不是 OpenSpec
- OpenSpec 的标准 Artifact 是 `proposal`、增量 `specs``design``tasks`
- `design.md` 记录**一次 Change** 的实现设计、取舍、迁移和回滚。
- ADR 是业界通用但独立的“长期架构决策记录”,不属于 OpenSpec也不是新项目必需品。
- 默认不建 `adr/`。只有一个跨 Change、长期生效、存在真实竞争方案且无法从代码理解原因的决策才考虑单独记录否则留在对应 Change 的 `design.md`
## 2. Harness 的完整范围
OpenAI 的 Harness Engineering 不只是“短 AGENTS.md + docs”。一个完整闭环包含
1. **仓库可读**:事实进入仓库,并有清晰索引和单一权威位置。
2. **环境可运行**:每个工作区能独立启动、测试和复现。
3. **结果可观察**Agent 能读取日志、指标、追踪、页面和 DOM而不是靠猜。
4. **架构可约束**:依赖方向、边界数据解析和关键不变量可机械检查。
5. **任务可判定**:需求被写成可观察、可验证的行为契约。
6. **执行可闭环**Agent 能复现、修改、验证、比较前后结果并处理反馈。
7. **知识可维护**链接、Owner、新鲜度和生成来源可检查过期内容持续清除。
8. **人类掌舵**人负责目标、优先级和判断点Agent 负责读取、执行、验证和维护。
因此Context 收敛不是“少写文档”,而是让默认 Context 很小、按需 Context 有标准、运行反馈足够强。
## 3. 从最小集合开始
### 3.1 所有项目 Day 0 必需
```text
AGENTS.md # 短导航与真正的全局硬约束
README.md # 人和 Agent 都可执行的启动入口
ARCHITECTURE.md # 一页系统地图和依赖边界
openspec/ # 当前行为与行为变更
CI / scripts # 格式化、检查、测试、构建命令
```
### 3.2 有真实内容时才创建
```text
docs/integrations/ # 存在第三方契约
docs/engineering/ # 存在跨模块、长期工程约束
docs/generated/ # 能由命令稳定再生的参考资料
RELIABILITY.md # 存在队列、重试、容灾、SLO 等系统性要求
SECURITY.md # 存在统一信任边界、敏感数据或威胁模型
FRONTEND.md # 前端规模足以需要统一交互/状态/设计规则
PRODUCT_SENSE.md # 多团队或 Agent 经常误判产品取舍
QUALITY_SCORE.md # 已有明确评分维度和维护机制
```
不要预建空目录、空模板或“以后也许有用”的文档。
### 3.3 如何理解 OpenAI 文章中的示例目录
文章展示的是一套成熟仓库的实际结构,不是要求每个项目照抄。与本指南的映射如下:
| 文章中的示例 | 解决的问题 | 本指南的默认选择 |
|---|---|---|
| `ARCHITECTURE.md` | 系统地图与边界 | Day 0 创建一页版本 |
| `docs/design-docs/``DESIGN.md` | 跨范围设计知识 | 单次变更放 OpenSpec `design.md`;只有长期跨 Change 设计才另建 |
| `docs/exec-plans/{active,completed}``PLANS.md` | 长任务持续执行 | 默认用 OpenSpec `tasks.md`;非行为型长期治理才建唯一执行计划 |
| `docs/product-specs/` | 当前产品行为 | 使用 `openspec/specs/`,不再复制一套 |
| `docs/generated/db-schema.md` | Agent 可检索的生成事实 | 有稳定生成命令时放 `docs/generated/` |
| `docs/references/*-llms.txt` | 将仓库外依赖资料变成 Agent 可读输入 | 仅保存任务高频需要且无法稳定在线获取的官方资料;第三方协议归 `docs/integrations/` |
| `FRONTEND.md` | 前端统一边界 | 前端复杂度触发后创建 |
| `PRODUCT_SENSE.md` | 产品判断原则 | 重复误判触发后创建 |
| `QUALITY_SCORE.md` | 领域/架构质量评分 | 有固定评分和治理动作后创建 |
| `RELIABILITY.md``SECURITY.md` | 跨系统可靠性和安全约束 | 对应风险出现后创建 |
关键不是目录名一致而是每类知识有唯一权威位置、Agent 可发现、能被验证、不会和 OpenSpec 重复。
### 3.4 不重复的事实分工
| 知识 | 权威位置 | 不应放入 |
|---|---|---|
| 当前可观察业务行为 | `openspec/specs/` | AGENTS、工程规范 |
| 拟议行为变化 | `openspec/changes/<change>/` | 主 Specs、路线图副本 |
| 系统地图和依赖方向 | `ARCHITECTURE.md` | 每个 Change 重复描述 |
| 第三方提供的契约 | `docs/integrations/` | 业务 Spec 原文复制 |
| 长期工程约束 | `docs/engineering/` + 检查器 | 任务型 Skill |
| 实现事实 | 代码、配置、迁移 | 叙述文档副本 |
| 可再生参考资料 | `docs/generated/` | 手工维护文档 |
| 任务过程 | OpenSpec Change 或唯一执行计划 | 第二套 TODO/状态台账 |
| 完成证据 | 测试、CI、运行输出 | 长期“完成总结” |
## 4. 每类文档的统一质量契约
除生成文档外,长期文档至少要让读者回答:
```yaml
purpose: 这份文档解决什么问题
scope: 适用与不适用范围
source_of_truth: 哪些事实由本文权威维护,哪些只引用
owner: 负责判断内容是否仍成立的角色或团队
last_verified: 最后通过代码、运行结果或官方来源核验的日期
update_triggers: 哪些变更发生时必须同步检查本文
verification: 用什么命令或证据判断内容仍有效
```
这不要求所有文件都使用 YAML要求这些信息明确可找到。
### 合格标准
- 写具体规则和可判定结果,不写“注意质量”“合理处理”等口号。
- 关键声明有代码路径、命令、测试、Schema 或官方来源支撑。
- 复制外部内容时标明来源版本和核验时间。
- 一项事实只有一个权威正文,其他位置只链接。
- 每个“必须/禁止”都对应机械检查,或明确说明当前为何只能人工审查。
- 示例只用于消除歧义;示例不得成为第二份规则。
### 不合格信号
- 没有适用范围、Owner 或更新触发条件。
- 只有目录介绍,没有依赖方向、入口或验证方式。
- 把当前 Bug 写成理想行为,或把未来设计冒充当前事实。
- 从代码能直接搜索得到的列表被手工复制并长期维护。
- 同一个状态、错误码或字段定义在多份文档中各写一遍。
## 5. AGENTS.md 标准
### 必填内容
1. 项目一句话目标。
2. Agent 从代码无法自然发现、且几乎所有任务都适用的硬约束。
3. build、test、lint、spec 验证的真实命令。
4. 事实源优先级和按需文档入口。
5. 禁止直接修改的生成物、敏感路径或外部生产边界。
### 禁止内容
- 完整 DTO、Model、路由或迁移操作步骤。
- 领域状态机、接口字段表和第三方协议正文。
- 已完成任务、历史方案、长 Review 清单。
- Agent 能从代码、包管理器或 `--help` 获取的通用知识。
### 尺寸与验证
- 80120 行是目标150 行是软预算。
- 每一行应对大多数任务有价值。
- CI 检查链接有效;验证命令必须在干净环境可运行。
```md
# 项目 Agent 指南
## 项目目标
<一句话>
## 全局硬约束
- <规则;对应检查命令或规则文档链接>
## 验证
- Build: `<command>`
- Test: `<command>`
- Lint: `<command>`
- Spec: `openspec validate --all`
## 事实源
- 当前行为:`openspec/specs/`
- 系统边界:`ARCHITECTURE.md`
- 外部契约:`docs/integrations/`
- 工程约束:`docs/engineering/`
```
## 6. ARCHITECTURE.md 标准
它是系统地图,不是完整设计书。目标是让新 Agent 在几分钟内知道从哪里进入、允许依赖谁、数据如何流动。
### 必填章节
1. **系统职责与非职责**:系统解决什么,不解决什么。
2. **运行单元**API、Worker、定时任务、前端、数据库、缓存、外部系统。
3. **领域/模块地图**模块职责、Owner如有和主要入口路径。
4. **依赖方向**:允许和禁止的跨层、跨模块依赖。
5. **关键数据流**:请求、异步事件、回调、读写路径。
6. **信任与事务边界**:外部输入在哪里解析,事务在哪里开始/结束。
7. **验证方法**:结构测试、依赖检查、启动或 smoke 命令。
8. **详细资料索引**:链接 Specs、工程约束和外部契约不复制正文。
### 写法标准
```md
## 模块:订单
- 职责:创建订单并管理支付前后的状态流转
- 入口:`internal/...`
- 可依赖:共享错误、支付 Port
- 禁止依赖HTTP Handler、具体支付 SDK
- 行为契约:`openspec/specs/orders/spec.md`
- 结构验证:`<command>`
```
### 机械检查
- 用 import/dependency 测试验证禁止依赖。
- 用 smoke 命令验证列出的运行单元确实可启动。
- 用链接检查验证每个引用存在。
- 模块增删、入口迁移或依赖方向改变时触发更新。
## 7. 外部契约文档标准
外部契约记录“第三方实际要求我们怎样交互”。它不是本系统的产品需求,也不是 SDK 使用教程。
### 7.1 目录规则
```text
docs/integrations/<provider>/
├── README.md # 当前接入契约与导航
├── examples/ # 经脱敏、可复现的请求响应样例(需要时)
└── source/ # 无稳定链接的官方原文快照(需要时)
```
官方内容有稳定 URL 时只保存链接和项目所需摘要;不要复制整站文档。
### 7.2 README 必填字段
```md
# <Provider> 接入契约
## 元数据
- Owner: <角色/团队>
- 官方来源: <URL 或 source/ 文件>
- 官方版本/发布日期: <值或 unknown>
- 项目适用环境: <sandbox/production/region>
- 最后核验: YYYY-MM-DD
- 更新触发: SDK升级、官方版本变化、字段/签名/错误码变化
## 接入范围
- 使用能力:<项目实际使用的 API/事件>
- 不使用能力:<容易误用但明确不接入的能力>
## 端点与认证
| 场景 | Method | URL/Topic | 认证 | 超时 |
## 请求契约
| 字段 | 类型 | 必填 | 约束 | 来源章节 | 项目映射 |
## 响应与错误
| 外部状态/错误码 | 含义 | 可重试 | 项目处理 | 告警 |
## 签名与回调
- 签名原文构造:<精确定义>
- 验签步骤:<精确定义>
- 时间窗/重放保护:<规则>
- 回调确认语义:<响应内容和重试条件>
## 可靠性
- 幂等键:<字段与作用域>
- 超时:<连接/请求>
- 重试:<次数、退避、仅哪些错误>
- 限流:<规则>
- 对账/补偿:<触发与入口>
## 安全与数据
- 凭证名称及托管位置:<只写名称,不写密钥>
- 敏感字段:<日志脱敏规则>
- 来源校验:<证书/IP/签名等>
## 验证
- 本地/沙箱命令:`<command>`
- 固定输入:`<fixture>`
- 预期结果:`<literal outcome>`
- 生产人工验收:<只有真实环境才能完成的最小步骤>
```
### 7.3 质量门禁
- 所有项目使用的字段都能追溯到官方来源或经确认的真实样例。
- 签名、金额单位、时间格式、编码、回调确认内容必须写成精确规则。
- 每种外部错误明确:重试、失败、忽略、人工处理中的一种。
- 重试必须同时定义上限、退避和幂等保护。
- 示例必须脱敏,并可被测试或脚本读取;真实密钥不得进入仓库。
- 至少有一个沙箱/契约测试,或明确记录为何只能人工验收。
### 7.4 不合格示例
```md
调用失败时适当重试。
```
问题:没有错误范围、次数、退避、幂等和最终失败动作,无法实现或验证。
合格写法:
```md
仅对连接超时和外部错误 E_TEMP 重试;最多 3 次,间隔 1s/2s/4s
每次复用同一幂等键。三次失败后记录 integration failure 并进入人工对账队列。
```
## 8. 工程约束文档标准
工程约束描述“所有相关代码长期必须保持的性质”。DTO、Model、路由、迁移、注释、依赖边界属于此类而不是 Skill。
### 8.1 一条约束的标准格式
```md
### ENG-<AREA>-NNN<可判定标题>
- 状态active | deprecated
- 适用范围:<路径、语言、组件或操作>
- 规则MUST/MUST NOT <单一可判定约束>
- 理由:<错误成本或架构原因;不复述规则>
- 正例:`<最小示例或现有代码链接>`
- 反例:`<最小示例>`
- 机械检查:`<测试/Lint/脚本命令>``暂为人工:<原因>`
- 例外:<允许条件、批准者、记录位置;无则写“无”>
- Owner<角色/团队>
- 最后验证YYYY-MM-DD
- 更新触发:<框架升级、目录调整、事故等>
```
### 8.2 规则拆分标准
- 一个 ID 只表达一个义务,避免“正确、安全、高性能地处理”。
- 适用范围必须能映射到路径或组件。
- 正例优先链接仓库内稳定实现,不复制大段代码。
- 反例只展示最容易犯且有实际代价的错误。
- 规则能由机器判断时,文档必须链接检查器;检查器才是执行门禁。
- 例外必须有边界和到期/复审条件,不能写“特殊情况除外”。
### 8.3 分类
| 分类 | 应记录 | 首选验证 |
|---|---|---|
| 架构 | 依赖方向、分层、边界解析 | 结构测试、import Lint |
| 数据 | 迁移、主键、金额单位、事务 | Schema/Lint/集成测试 |
| API | 路由注册、错误形状、兼容性 | OpenAPI diff、契约测试 |
| 可靠性 | 幂等、重试、锁、Outbox | 故障注入、集成测试 |
| 安全 | 权限、敏感字段、信任边界 | 安全测试、静态检查 |
| 代码品味 | 禁止无价值抽象、复杂度上限 | Lint + Review |
### 8.4 合格与不合格示例
不合格:
```md
数据库迁移要谨慎,注意兼容旧数据。
```
合格:
```md
### ENG-DB-003新增非空列必须可在线回填
- 适用范围:`migrations/`
- 规则已有数据表新增非空列时MUST 先增加可空列或带兼容默认值,完成回填后再收紧约束。
- 理由:直接增加无默认值的非空列会使现有数据迁移失败。
- 机械检查:`<migration smoke command>`
- 例外:仅空表;必须在 Change design 中提供查询证据。
```
## 9. OpenSpec 生成提案的标准
用户不需要手工填写长表。AI 生成 Change但生成器和 Reviewer 必须遵守质量门禁。
### 9.1 Explore 后才能确定的内容
- 当前行为和证据路径。
- 目标、原因、范围和非目标。
- 角色、资源、前置状态和数据范围。
- 状态、金额、权限、事务、并发、幂等和外部失败边界。
- 已知事实、推断和真正需要人决策的问题。
### 9.2 Proposal
只回答为什么、做什么、不做什么、影响哪些 Capability。不得塞入实现步骤和通用工程规范。
### 9.3 Requirement / Scenario
```md
### Requirement: <一个可观察义务>
系统 SHALL <测试者无需阅读实现即可判定的结果>。
#### Scenario: <场景>
- **GIVEN** <角色、数据、前置状态>
- **WHEN** <一个动作>
- **THEN** <可观察结果>
- **AND** <副作用或必须保持的不变量>
```
门禁:
- 一个 Requirement 一个主要义务。
- Scenario 有具体前置条件、触发和结果。
- 覆盖主流程以及代价最高的权限、失败、边界或重复场景。
- 不使用“体验良好”“正确处理”“高性能”等不可判定词。
- 不写函数名、表名、ORM、缓存或队列选型。
- Brownfield 的错误现状也按 As-Is 写入Spec 描述事实不等于认可设计。
### 9.4 Design
记录本 Change 的架构通道、数据与事务、并发、外部失败、兼容、迁移、发布、回滚和真实取舍。简单且实现显然时保持简短。
### 9.5 Tasks
- 按可独立验证的纵向切片拆分,不按 Model/Service/Handler 水平拆分。
- 每项映射 Requirement/Scenario并包含最小验证命令和预期结果。
- 适用时同时验证响应、持久化、副作用和失败不变量。
- 实施发现 Artifact 与事实冲突时同步修订,不静默跳过或扩大任务。
## 10. Generated docs 标准
仅当内容能稳定再生且 Agent 经常需要查询时创建,例如数据库 Schema、OpenAPI 摘要或配置清单。
每份生成文件顶部必须包含:
```md
<!-- GENERATED FILE: DO NOT EDIT -->
- Source: <代码/Schema 路径>
- Command: `<生成命令>`
- Generator version: <版本>
- Generated at: <时间或来源 commit>
```
CI 重新生成并检查 diff。生成结果没有稳定命令时它就不是 generated doc而是会腐烂的手工副本。
## 11. 唯一执行计划标准
一般功能直接使用 OpenSpec `tasks.md`,不要再建 `docs/exec-plans/`。只有不改变产品行为、跨多天且 OpenSpec 不适配的迁移/治理任务,才使用单独执行计划。
必填内容:
- 目标和完成定义。
- 范围、非目标和受保护路径。
- 前置依赖和顺序。
- 可恢复的任务清单,每项含验证。
- 当前进度,只在这一处更新。
- 关键决策与新发现。
- 回滚条件和命令。
- 最终验收命令及预期结果。
Manager、Executor、Auditor 可以分工但只能共享这一份任务契约Harness state 和报告只是可丢弃证据。
## 12. Agent 可操作环境标准
### 12.1 独立运行
- 每个 worktree/工作区可使用不同端口和隔离的临时数据启动。
- README 提供一条启动命令、一条测试命令和一条重置命令。
- 依赖、种子数据和环境变量有可复制的本地默认值;真实凭证不入库。
### 12.2 浏览器与 UI
有 UI 时Agent 应能:
- 启动应用并通过浏览器/CDP 操作关键流程。
- 保存失败前后的截图或视频。
- 读取 DOM、Console error 和失败网络请求。
- 用稳定测试定位器,而非脆弱坐标。
### 12.3 可观测性
本地至少能按 request/correlation ID 串起:
- 结构化日志。
- 关键指标。
- 跨进程或异步链路追踪(系统存在此类链路时)。
提供最小查询命令。临时本地可观测栈应可一键启动和销毁,运行数据不作为长期事实源。
### 12.4 自主验证闭环
```text
复现问题 → 捕获基线 → 修改 → 运行最小测试 → 启动系统
→ 检查日志/指标/页面 → 对比前后 → 完整门禁 → 提交审查
```
只有产品取舍、不可逆操作、真实生产权限或相互冲突的事实需要升级给人。
## 13. Reliability、Security、Product Sense、Quality Score
这些不是所有项目的固定作业。
### RELIABILITY.md何时创建
当系统出现 SLO、重试、队列、定时任务、降级、容灾或数据修复策略时创建。最低结构关键用户旅程、SLO/错误预算、故障模式、超时重试、幂等与补偿、观测和告警、恢复/演练、Owner。
### SECURITY.md何时创建
当存在统一信任边界、敏感数据、权限模型或外部暴露面时创建。最低结构资产和角色、信任边界、认证授权、数据分类、密钥、审计、主要威胁与控制、验证命令、事件入口、Owner。
### PRODUCT_SENSE.md何时创建
当多个功能反复需要相同产品判断、Agent 经常做出局部正确但产品错误的选择时创建。最低结构:目标用户、核心任务、优先级原则、明确非目标、取舍示例、正反例。具体行为仍归 OpenSpec。
### QUALITY_SCORE.md何时创建
只有团队真的按固定维度定期评分并采取行动时创建。每项必须有定义、证据来源、当前分数、阈值、Owner、改进动作和复评日期没有维护机制就不要创建。
## 14. 文档检查与持续清熵
### 每次变更
- 检查内部链接和引用路径。
- OpenSpec 校验全部通过。
- 生成文档可无差异再生。
- 架构边界检查通过。
- Change 完成后同步/归档 Specs删除被替代的同义说明。
### 周期性 doc-gardening
Agent 定期生成候选清单,人只处理真正的判断点:
- 失效链接、孤儿文件和长期 TBD。
- `last_verified` 过期且影响仍高的文档。
- 与代码、测试或 Specs 冲突的声明。
- 重复规则、重复事实和已被检查器取代的提醒。
- 已完成的临时计划、报告和可丢弃运行证据。
清理优先级:删除 > 合并到权威位置 > 更新 > 新建索引。
### 黄金原则
把反复出现的 Review 意见和事故教训变成最靠近问题的自动化约束测试、类型、Lint、Schema、结构检查或运行时保护。不要持续加长 AGENTS.md。
## 15. Day 0 到稳定期的落地顺序
1. Agent 生成最小可运行仓库、格式化、包管理、CI 和测试骨架。
2. 建立短 `AGENTS.md`、可执行 `README.md` 和一页 `ARCHITECTURE.md`
3. 初始化 OpenSpec第一个真实功能走 Explore → Propose → Review → Apply → Verify → Archive。
4. 打通独立工作区启动、日志读取和最小 smoke 测试。
5. 出现真实第三方时按外部契约模板创建资料。
6. 出现重复工程失误时先加检查器,再补对应约束 ID。
7. 只有触发条件成立时增加 Reliability、Security 等专项文档。
8. 持续删除完成报告、重复事实和过期 Context避免周期性大扫除。
## 16. 最低验收清单
- [ ] 新 Agent 只读 AGENTS.md 就能找到行为、架构、外部契约和验证入口。
- [ ] README 的启动、测试、重置命令在干净工作区可执行。
- [ ] ARCHITECTURE.md 的依赖方向有检查或明确人工门禁。
- [ ] OpenSpec Requirement/Scenario 可观察、可判定。
- [ ] 外部契约的版本、字段、签名、重试、幂等和验证证据齐全。
- [ ] 每条工程约束有范围、规则、理由、检查、例外和 Owner。
- [ ] Agent 能自行读取错误日志;有 UI 时能读取页面和网络失败。
- [ ] 同一事实没有多个权威正文。
- [ ] 文档链接、生成检查、Spec 校验和测试进入 CI。
- [ ] 临时计划、状态和运行证据不会成为第二事实源。
## 17. 参考资料
### 第一方
- [OpenAI Harness Engineering](https://openai.com/index/harness-engineering/)
- [OpenSpec Overview](https://openspec.dev/docs/overview)
- [OpenSpec Writing Specs](https://openspec.dev/docs/writing-specs)
- [OpenSpec Getting Started](https://openspec.dev/docs/getting-started)
- [LongHorizon-Harness](https://github.com/AMAP-ML/LongHorizon-Harness)
### 补充实践
- [AugmentHow to write good AGENTS.md files](https://www.augmentcode.com/blog/how-to-write-good-agents-dot-md-files)
- [BetterClawAGENTS.md best practices](https://www.betterclaw.io/blog/agents-md-best-practices)
- [What goes in AGENTS.md?](https://ro14nd.de/what-goes-in-agents-md/)
- [Domain Expertise Is the New Agentic Coding Moat](https://www.developersdigest.tech/blog/domain-expertise-agentic-coding-moat)
- [OReillyHow to write a good spec for AI agents](https://www.oreilly.com/radar/how-to-write-a-good-spec-for-ai-agents/)

View File

@@ -0,0 +1,302 @@
# 工程约束
本文件只记录从当前代码、配置、迁移、构建或可复现运行结果证明的长期工程规则。业务行为属于 `openspec/specs/`,第三方协议属于 `docs/integrations/`。自动化测试当前为 N/A用户决策
## ENG-ARCH-001
- **状态**:生效
- **适用范围**`internal/domain/**`
- **规则**Domain MUST NOT import Fiber、GORM、Redis、Asynq 或具体第三方 SDK。
- **理由**:领域不变量必须能脱离传输和基础设施独立演化。
- **最小正例**Domain 只依赖标准库、领域类型和 Port。
- **最小反例**Domain 直接调用 GORM 或 Redis。
- **机械检查/人工原因**`! grep -RIlE 'gofiber|gorm.io|go-redis|hibiken/asynq' internal/domain --include='*.go'`,当前通过。
- **例外条件**:既有兼容行为仍须先通过独立 Change 才能调整边界。
- **Owner**:架构负责人
- **最后验证日期**2026-08-07
- **更新触发条件**Domain 依赖或分层策略变化
## ENG-ARCH-002
- **状态**:生效
- **适用范围**:新增或修改完整用例
- **规则**:复杂写 MUST 使用 Application→Domain→Port简单写 MUST 使用 Application 事务脚本;读取 MUST 使用 Query 或既有只读 Service且不得修改状态。
- **理由**:按用例选择最小边界,避免半迁移和形式化 DDD。
- **最小正例**:订单资金状态机收口 Domain列表查询直接 DTO 投影。
- **最小反例**:只为单表 CRUD 创建聚合,或 Query 内更新状态。
- **机械检查/人工原因**:暂为人工:从 Route 追踪到用例和持久化,确认一个完整用例只有一个规则归属。
- **例外条件**:未触碰的旧 `internal/service` 保持现状。
- **Owner**:架构负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增用例或触碰旧复杂写
## ENG-ERR-001
- **状态**:生效
- **适用范围**:到 HTTP 边界的错误链
- **规则**:到 Handler/HTTP 边界前 MUST 转换为 `pkg/errors` 稳定错误Handler 参数校验 MUST NOT 拼接或返回底层 `err.Error()`
- **理由**:全局 ErrorHandler 只能安全映射稳定错误,底层文本可能泄密。
- **最小正例**`errors.Wrap(code, err)` 后返回给全局 ErrorHandler。
- **最小反例**`return c.JSON(...err.Error())`
- **机械检查/人工原因**:审查变更中的 Handler 返回点;运行 `go build ./cmd/api`。全仓 `fmt.Errorf` 只作候选,不能直接判错。
- **例外条件**:内部不可见、不会越过接口边界的诊断错误允许 `fmt.Errorf`
- **Owner**API 负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:错误系统或 ErrorHandler 变化
## ENG-RESP-001
- **状态**:生效
- **适用范围**HTTP Handler
- **规则**:成功响应 MUST 使用 `pkg/response`;错误 MUST 返回给 `internal/middleware.ErrorHandler`,不得自造响应壳。
- **理由**:统一 `{code,msg,data,timestamp}` 和 HTTP 映射。
- **最小正例**`return response.Success(c, data)`
- **最小反例**Handler 直接 `c.JSON` 返回另一套结构。
- **机械检查/人工原因**:审查新增 Handler`rg "response\.(Success|Error)" internal/handler internal/routes`;生成 OpenAPI 人工核对。
- **例外条件**:第三方回调必须返回渠道要求的字面协议时可使用专用响应。
- **Owner**API 负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:响应协议或第三方回调契约变化
## ENG-DTO-001
- **状态**:生效
- **适用范围**:进入 OpenAPI 的请求/响应 DTO
- **规则**:公开字段 MUST 有中文 description枚举描述 MUST 与 `pkg/constants` 当前值一致;状态响应按现有契约提供名称字段。
- **理由**:前端和生成文档依赖字段与枚举精确一致。
- **最小正例**:从 constants 原文复制枚举值并生成 OpenAPI 核对。
- **最小反例**:凭记忆写 description 或遗漏状态名称。
- **机械检查/人工原因**`go run cmd/gendocs/main.go` 后核对 OpenAPI暂为人工比对枚举与 constants单纯 grep 不足以证明完整。
- **例外条件**:仅内部、不进入接口契约的结构。
- **Owner**API 负责人
- **最后验证日期**2026-08-07
- **更新触发条件**DTO、枚举或 OpenAPI 生成变化
## ENG-MODEL-001
- **状态**:生效
- **适用范围**:新增/修改 `internal/model` 与当前根迁移
- **规则**:关联 MUST 保存 ID 并显式查询MUST NOT 新增 GORM `foreignKey`/`hasMany`/`belongsTo` 标签或数据库外键。
- **理由**:当前隔离与渐进迁移依赖显式关联。
- **最小正例**:保存 `ShopID`,由 Store 显式查询。
- **最小反例**:新增关联切片和 `foreignKey` 标签。
- **机械检查/人工原因**:只检查变更新增行:`git diff -- internal/model migrations | grep -E "^\+.*(foreignKey|hasMany|belongsTo|REFERENCES)"`,期望空。
- **例外条件**`migrations/archive` 的历史外键不作为新增违规。
- **Owner**:数据负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:模型或关联策略变化
## ENG-DB-001
- **状态**:生效
- **适用范围**:生产 Go 数据访问
- **规则**:生产数据访问 MUST 使用 GORMMUST NOT 新增 `database/sql` 直接访问。
- **理由**统一连接、事务、Callback 数据范围和错误处理。
- **最小正例**:通过 `*gorm.DB` 或 Store 查询。
- **最小反例**:业务 Service 新建 `sql.DB`
- **机械检查/人工原因**:检查变更新增 import`git diff -U0 -- "*.go" | grep -E "^\+.*database/sql"`,期望空。
- **例外条件**:第三方库内部实现不受本规则约束。
- **Owner**:数据负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:数据访问基础设施变化
## ENG-DB-002
- **状态**:生效
- **适用范围**Agent 对项目数据库的诊断、核对与只读查询。
- **规则**MUST 使用 dbhub MCP测试/本地库使用 `mcp__dbhub__execute_sql_main`,正式库使用 `mcp__dbhub__execute_sql_pro_main`MUST NOT 通过 `psql`、连接串、环境变量或其他命令行客户端直连数据库。
- **理由**dbhub 提供受控只读访问,避免命令历史、环境凭证和目标库选择漂移。
- **最小正例**:调用 `mcp__dbhub__execute_sql_main` 查询订单与佣金记录。
- **最小反例**`source .env && psql ...`
- **机械检查/人工原因**:审查 Agent 执行记录中的数据库访问工具;仓库业务 Go 代码不受本条约束,仍遵守 ENG-DB-001。
- **例外条件**维护者明确提供的、需执行写入或迁移的人工操作按生产运行说明执行Agent 不代执行。
- **Owner**:基础设施负责人
- **最后验证日期**2026-08-13
- **更新触发条件**dbhub MCP 名称、访问范围或数据库运维边界变化
## ENG-MIG-001
- **状态**:生效
- **适用范围**`migrations/` 当前根目录
- **规则**:新迁移 MUST 使用新顺序号并提供 `.up.sql`/`.down.sql` 配对MUST NOT 修改已发布迁移。
- **理由**:保证 Schema 可重放和回滚。
- **最小正例**:新增同号 up/down 文件并在隔离库执行。
- **最小反例**:改旧迁移或只提供 up。
- **机械检查/人工原因**:按 `migrations/*.up.sql``*.down.sql` basename 配对;当前根目录 89/89。隔离库使用 `scripts/migrate.sh` 执行 up/down/up。
- **例外条件**:不可逆数据清理须在 Change 说明恢复方式archive 历史资产不要求补配。
- **Owner**:数据负责人
- **最后验证日期**2026-08-07
- **更新触发条件**Schema 或迁移工具变化
## ENG-ROUTE-001
- **状态**:生效
- **适用范围**:新增或修改 Handler/路由
- **规则**:路由 MUST 经 `internal/routes.Register` 注册 RouteSpec新增 Handler MUST 同步 `cmd/api/docs.go``cmd/gendocs/main.go` 的占位装配。
- **理由**:运行路由与生成文档共享元数据,但两套 composition root 仍需一致。
- **最小正例**:路由文件注册并在两处 docs Handlers 增加字段。
- **最小反例**:只在 Fiber app 上挂载 Handler。
- **机械检查/人工原因**`go run cmd/gendocs/main.go`;对比两处 `bootstrap.Handlers` 字段;再对照真实 `internal/bootstrap` 装配。
- **例外条件**:健康/就绪路由在总入口内定义,但仍使用 RouteSpec。
- **Owner**API 负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增 Handler 或文档生成方式变化
## ENG-COMMENT-001
- **状态**:生效
- **适用范围**Go 生产代码
- **规则**:导出符号 MUST 有中文文档注释复杂逻辑说明原因Handler 注释 MUST 包含 HTTP 方法和路径。
- **理由**:保持 godoc、评审和真实路由可核对。
- **最小正例**:导出 Handler 注释写职责及 `POST /api/...`
- **最小反例**:注释复述赋值或路径过时。
- **机械检查/人工原因**`go vet` 只做基础检查;中文、原因和路径由变更 diff 人工核对真实路由。
- **例外条件**:显而易见且少于 15 行的未导出函数可无注释。
- **Owner**:后端负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增导出 API 或路由变化
## ENG-JSON-001
- **状态**:生效
- **适用范围**:新增 JSON 编解码代码
- **规则**:新增业务 JSON MUST 优先 sonic新增 `encoding/json` import MUST 记录兼容原因。
- **理由**Fiber 已配置 sonic但现有 SDK/OpenAPI/协议有标准库兼容需求。
- **最小正例**:业务载荷使用 `sonic.Marshal`
- **最小反例**:无理由新增 `encoding/json`
- **机械检查/人工原因**:只审查 diff 中新增 import当前仓库已有多处兼容使用不能以全仓 grep 直接失败。
- **例外条件**:标准库接口、第三方 SDK 或协议兼容明确要求。
- **Owner**:后端负责人
- **最后验证日期**2026-08-07
- **更新触发条件**JSON 库或兼容协议变化
## ENG-QUEUE-001
- **状态**:生效
- **适用范围**`queueClient.EnqueueTask` 调用
- **规则**payload MUST 是 struct 或 mapMUST NOT 传预序列化 `[]byte`
- **理由**:封装会统一校验并 sonic.Marshal字节切片会二次编码。
- **最小正例**`EnqueueTask(ctx, typ, Payload{ID:id})`
- **最小反例**Marshal 后把 `[]byte` 交给 EnqueueTask。
- **机械检查/人工原因**:核对全部 EnqueueTask 第三实参及 `pkg/queue/client.go` 的 ValidatePayload运行 `go build ./cmd/worker`
- **例外条件**:直接调用 `asynq.NewTask` 时由调用方序列化。
- **Owner**:异步任务负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:队列封装或载荷协议变化
## ENG-PAGE-001
- **状态**:生效
- **适用范围**:列表 API
- **规则**:列表 MUST 分页并执行各接口当前上限;默认 20/最大 100 仅适用于已明确采用该契约的列表。
- **理由**:避免无界查询,同时不把不同接口的既有分页强行合并。
- **最小正例**Handler/Query 对缺省值与上限显式归一化。
- **最小反例**:只写 OpenAPI tag不在运行时限制。
- **机械检查/人工原因**:从 DTO→Handler→Query 逐链核对 Limit暂为人工不以 tag 单独证明行为。
- **例外条件**:固定且有界的小型枚举集合。
- **Owner**API 负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:分页协议或查询性能变化
## ENG-STATE-001
- **状态**:生效
- **适用范围**:新增或变更状态/类型字段
- **规则**:新增生命周期状态 SHOULD 使用 int类型/方式 SHOULD 使用 string启停新增语义使用 0=禁用、1=启用。既有不一致 MUST 按 As-Is 保留,未经 Change 不得统一。
- **理由**:避免继续扩大状态语义漂移,同时保护兼容行为。
- **最小正例**:新 Status int新 PaymentMethod string。
- **最小反例**:基线任务顺手改既有状态值。
- **机械检查/人工原因**:比对 constants、DTO、模型与迁移既有例外必须在 Spec 记录。
- **例外条件**:第三方原始字段在 Adapter 边界转换;既有兼容状态保留。
- **Owner**:领域负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增状态机或兼容映射
## ENG-AUTHZ-001
- **状态**:生效
- **适用范围**:资源读取与写入
- **规则**:资源操作 MUST 在业务边界校验店铺/企业/个人客户数据范围MUST NOT 只依赖路由角色中间件。无权限与不存在不得形成可枚举差异。
- **理由**:路由只做粗粒度身份,资源归属随数据变化。
- **最小正例**Application/Service 在读写前调用当前数据范围检查。
- **最小反例**:知道资源 ID 即可更新。
- **机械检查/人工原因**:从 Handler 追踪 Application/Service 的资源校验及 GORM Callback暂为人工抽查权限失败响应。
- **例外条件**:公开健康、回调等不以资源归属认证,但必须执行自身信任校验。
- **Owner**:安全负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增资源入口或数据范围变化
## ENG-CONC-001
- **状态**:生效
- **适用范围**:状态机、余额、库存与并发领取
- **规则**:状态流转 MUST 使用 expected-status 条件更新;余额 MUST 使用 version/锁定策略MUST NOT 读后无条件写关键事实。
- **理由**:防止重复处理、负余额和并发覆盖。
- **最小正例**`WHERE status=expected` 并检查 RowsAffected。
- **最小反例**:先读状态再无条件 Updates。
- **机械检查/人工原因**:核对 `application/wallet``agentrecharge` 等现有模式;变更时人工检查条件、版本和 RowsAffected。
- **例外条件**:只读投影不适用。
- **Owner**:领域负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:并发写或状态机变化
## ENG-OUTBOX-001
- **状态**:生效
- **适用范围**:需要提交后可靠副作用的用例
- **规则**:业务事实与可靠异步副作用 MUST 在同一事务写 OutboxMUST NOT 用裸 goroutine 代替可靠投递;消费者 MUST 容忍重复。
- **理由**:进程崩溃和至少一次投递会造成丢失或重复。
- **最小正例**:事务写业务事实与 Outbox消费者条件更新。
- **最小反例**:提交后启动 goroutine 调第三方。
- **机械检查/人工原因**:追踪 producer→Outbox→Relay→consumer检查事件唯一键、租约和终态条件。
- **例外条件**:允许丢失的低价值遥测可不用 Outbox但须明确说明。
- **Owner**:可靠性负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增外部副作用或事件
## ENG-TX-001
- **状态**:生效
- **适用范围**:资金、状态与成功审计
- **规则**:资金/关键状态事实和要求成功必达的审计 MUST 在同一 GORM 事务;事务内 MUST NOT 持有不可回滚的长外部 I/O。
- **理由**:保证事实与审计一致,并控制锁时间。
- **最小正例**:事务写事实和 Audit Writer提交后由 Outbox 外发。
- **最小反例**:事务中等待第三方网络后再提交。
- **机械检查/人工原因**:逐用例人工核对 Transaction 闭包、Audit Writer 和外部调用位置。
- **例外条件**:业务回滚后的 failed/denied 审计使用独立短事务。
- **Owner**:架构与审计负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:高风险写或外部调用变化
## ENG-AUDIT-001
- **状态**:机械检查阻塞
- **适用范围**:状态变更、资金、权限、关键配置、敏感读取与关键拒绝
- **规则**:用例 MUST 明确 Audit Event、Domain Ledger、Integration Log、Outbox 的使用决定或 N/A 理由;四类事实不得互相替代。
- **理由**Access Log 不能证明业务事实Integration Log 不能替代资金流水。
- **最小正例**:状态成功写 Audit外部尝试写 Integration Log。
- **最小反例**:只记录 access.log 后宣称已审计。
- **机械检查/人工原因**:当前 `cmd/audit-coverage` 写入已删除 `.scratch` 路径,不能作为通过门禁;修复前逐调用链人工核对并在仓库外记录证据。
- **例外条件**:低风险用例可登记 N/A 理由。
- **Owner**:审计负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:审计 CLI 输出路径修复或新增高风险用例
## ENG-LOG-001
- **状态**:生效
- **适用范围**:运行日志、审计、资金事实和外部交互
- **规则**Access Log、Audit Event、Domain Ledger、Integration Log 与 Outbox MUST 分责;日志 MUST 使用结构化 Zap 且不得记录密钥或完整敏感体。
- **理由**:不同保留期、查询维度与一致性要求不可混用。
- **最小正例**:外部失败写脱敏 Integration LogHTTP 请求写 Access Log。
- **最小反例**:把完整凭证写 app.log。
- **机械检查/人工原因**:人工核对 Logger 字段、Sanitizer 与事实存储;本地按 request_id/correlation_id 查询日志。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。
- **Owner**:审计与安全负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:日志字段、保留策略或外部集成变化
## ENG-SECRET-001
- **状态**:生效
- **适用范围**:配置、代码、文档、日志和示例
- **规则**密钥、Token、证书和真实凭证 MUST 由生效配置/环境注入MUST NOT 新增到仓库或日志;示例只用占位符。
- **理由**:仓库历史和日志传播范围不可控。
- **最小正例**:文档使用 `<TOKEN>`,运行环境注入 Secret。
- **最小反例**:提交可用 AppSecret。
- **机械检查/人工原因**:对 git diff 做敏感模式人工扫描;核对 `pkg/config` 绑定和日志字段。当前历史凭证属于待决策存量,不得复制。
- **例外条件**:明确不可用的假值可用于文档占位;当前无测试夹具。
- **Owner**:安全负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:配置字段、凭证或集成变化
## ENG-CONFIG-001
- **状态**:生效
- **适用范围**:运行配置和依赖版本
- **规则**:依赖版本 MUST 以 `go.mod` 为准;运行参数 MUST 经 `pkg/config` 加载/校验;业务代码 MUST NOT 散落读取环境变量。
- **理由**:保证默认值、环境覆盖和启动校验只有一个入口。
- **最小正例**:在 Config 增字段并由 loader 绑定。
- **最小反例**:业务 Service 直接 `os.Getenv`
- **机械检查/人工原因**:检查变更新增 `os.Getenv`;运行 `go build` 并核对 `pkg/config/config.go` 校验。
- **例外条件**:启动脚本读取环境变量用于组装进程配置。
- **Owner**:基础设施负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增配置或依赖升级

View File

@@ -1,224 +0,0 @@
# 企业设备授权功能实现总结
## 概述
实现了企业设备授权功能,取代原有的"设备捆绑"机制。新功能支持以设备为单位进行授权,授权后设备绑定的所有卡自动授权给企业。
## 核心变更
### 1. 数据库层
**新增表:`tb_enterprise_device_authorization`**
- 设备授权主表
- 关键字段enterprise_id, device_id, authorized_by, authorized_at, revoked_at
- 唯一约束:一个设备同时只能授权给一个企业(通过部分唯一索引实现)
**修改表:`tb_enterprise_card_authorization`**
- 新增字段:`device_auth_id`NULLABLE
- 用途标识卡是通过设备授权有值还是单卡授权NULL
- 关联:指向 `tb_enterprise_device_authorization.id`
### 2. Model 层
**新增模型**
- `model.EnterpriseDeviceAuthorization`:设备授权模型
**修改模型**
- `model.EnterpriseCardAuthorization`:添加 DeviceAuthID 字段
### 3. DTO 层
**新增 DTO`dto/enterprise_device_authorization_dto.go`**
- AllocateDevicesReq/Resp授权设备
- RecallDevicesReq/Resp撤销设备授权
- EnterpriseDeviceListReq/Resp设备列表
- EnterpriseDeviceDetailResp设备详情含绑定卡
- DeviceCardOperationReq/Resp卡操作停机/复机)
**废弃 DTO`dto/enterprise_card_authorization_dto.go`**
- DeviceBundle、DeviceBundleCard、AllocatedDevice 标记为 Deprecated
### 4. Store 层
**新增 Store**
- `postgres.EnterpriseDeviceAuthorizationStore`
- Create/BatchCreate创建授权
- GetByID/GetByDeviceID/GetByEnterpriseID查询授权
- ListByEnterprise分页查询
- RevokeByIDs撤销授权
- GetActiveAuthsByDeviceIDs批量检查授权状态
**修改 Store**
- `postgres.EnterpriseCardAuthorizationStore`
- 新增 RevokeByDeviceAuthID():级联撤销卡授权
### 5. Service 层
**新增 Service`service/enterprise_device/service.go`**
- AllocateDevices():授权设备给企业
- 验证设备状态(必须是"已分销"
- 验证设备所有权
- 事务中创建设备授权 + 自动创建绑定卡授权
- RecallDevices():撤销设备授权
- 撤销设备授权
- 级联撤销所有绑定卡授权
- ListDevices():后台管理设备列表
- ListDevicesForEnterprise()H5企业用户设备列表
- GetDeviceDetail():设备详情(含绑定卡)
- SuspendCard/ResumeCard()H5停机/复机
**Breaking Change修改 Service**
- `service/enterprise_card/service.go`
- AllocateCardsPreview():移除 DeviceBundle 逻辑,绑定设备的卡直接拒绝
- AllocateCards():移除 ConfirmDeviceBundles 参数,只能授权单卡
### 6. Handler 层
**新增 Admin Handler`handler/admin/enterprise_device.go`**
- AllocateDevices`POST /api/admin/enterprises/:id/allocate-devices`
- RecallDevices`POST /api/admin/enterprises/:id/recall-devices`
- ListDevices`GET /api/admin/enterprises/:id/devices`
**新增 H5 Handler`handler/h5/enterprise_device.go`**
- ListDevices`GET /api/h5/enterprise/devices`
- GetDeviceDetail`GET /api/h5/enterprise/devices/:device_id`
- SuspendCard`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend`
- ResumeCard`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume`
### 7. 错误码
新增错误码(`pkg/errors/codes.go`
- `CodeDeviceAlreadyAuthorized` (1083):设备已授权给此企业
- `CodeDeviceNotAuthorized` (1084):设备未授权给此企业
- `CodeDeviceAuthorizedToOther` (1085):设备已授权给其他企业
- `CodeCannotAuthorizeOthersDevice` (1086):无权操作他人设备
## 业务规则
### 授权规则
1. **设备状态检查**:只能授权状态为"已分销"status=2的设备
2. **所有权验证**:代理用户只能授权自己店铺的设备
3. **唯一性约束**:一个设备同时只能授权给一个企业
4. **自动级联**:授权设备时,所有绑定的卡自动授权
### 撤销规则
1. **级联撤销**:撤销设备授权时,自动撤销所有绑定卡授权
2. **软删除**:通过设置 revoked_at 时间戳实现,保留历史记录
### H5 操作规则
1. **设备详情**:只能查看已授权给当前企业的设备
2. **停机/复机**
- 卡必须属于已授权设备
- 卡必须通过设备授权方式授权device_auth_id 不为空)
- 只能操作当前企业的设备
## 数据权限
- **后台管理**基于用户类型自动过滤SuperAdmin/Platform 全部可见Agent 只能看到自己店铺及下级)
- **H5企业用户**:自动过滤为当前企业的数据
## API 路由
### 后台管理 API
```
POST /api/admin/enterprises/:id/allocate-devices # 授权设备
POST /api/admin/enterprises/:id/recall-devices # 撤销授权
GET /api/admin/enterprises/:id/devices # 设备列表
```
### H5 企业 API
```
GET /api/h5/enterprise/devices # 设备列表
GET /api/h5/enterprise/devices/:device_id # 设备详情
POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend # 停机
POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume # 复机
```
## 迁移说明
### 数据库迁移
已创建迁移文件:
- `migrations/000031_add_enterprise_device_authorization.up.sql`
- `migrations/000032_add_device_auth_id_to_enterprise_card_authorization.up.sql`
迁移已执行并验证成功。
### Breaking Changes
**企业卡授权 API 行为变更:**
1. `POST /api/admin/enterprises/:id/allocate-cards`
- 不再接受 `confirm_device_bundles` 参数
- 绑定设备的卡直接返回失败:`"该卡已绑定设备,请使用设备授权功能"`
2. **前端需要调整**
- 移除 DeviceBundle 相关 UI 和逻辑
- 添加设备授权入口
- 卡授权流程中处理"卡已绑定设备"错误
## 测试状态
### 已完成
- ✅ 数据库迁移验证
- ✅ 代码编译验证
- ✅ LSP 诊断通过
### 待完成(低优先级)
- ⏳ Store 层单元测试
- ⏳ Service 层单元测试
- ⏳ 修改 enterprise_card 服务测试(适配 Breaking Change
- ⏳ 集成测试
## 文件清单
### 新增文件
- `migrations/000031_add_enterprise_device_authorization.up.sql`
- `migrations/000032_add_device_auth_id_to_enterprise_card_authorization.up.sql`
- `internal/model/enterprise_device_authorization.go`
- `internal/model/dto/enterprise_device_authorization_dto.go`
- `internal/store/postgres/enterprise_device_authorization_store.go`
- `internal/service/enterprise_device/service.go`
- `internal/handler/admin/enterprise_device.go`
- `internal/handler/h5/enterprise_device.go`
- `internal/routes/enterprise_device.go`
- `internal/routes/h5_enterprise_device.go`
### 修改文件
- `internal/model/enterprise_card_authorization.go`(添加 DeviceAuthID 字段)
- `internal/model/dto/enterprise_card_authorization_dto.go`(废弃 DeviceBundle
- `internal/store/postgres/enterprise_card_authorization_store.go`(添加 RevokeByDeviceAuthID
- `internal/service/enterprise_card/service.go`(移除 DeviceBundle 逻辑)
- `internal/bootstrap/stores.go`(注册新 Store
- `internal/bootstrap/services.go`(注册新 Service
- `internal/bootstrap/handlers.go`(注册新 Handler
- `internal/bootstrap/types.go`(添加 Handler 字段)
- `internal/routes/admin.go`(注册后台路由)
- `internal/routes/h5.go`(注册 H5 路由)
- `pkg/errors/codes.go`(添加错误码)
## 后续工作
### 必要工作
1. **前端适配**
- 移除设备捆绑相关 UI
- 添加设备授权管理界面
- 处理新的错误码
2. **文档更新**
- 更新 API 文档(需要运行文档生成器)
- 更新用户使用手册
### 可选工作
1. **测试补充**:按需补充单元测试和集成测试
2. **性能优化**:如有性能问题,可优化查询逻辑
3. **功能扩展**:如需要批量操作优化,可添加批量接口
## 总结
企业设备授权功能已完整实现,包括:
- ✅ 数据库表结构变更
- ✅ 完整的四层架构实现Model/Store/Service/Handler
- ✅ 后台管理 API 和 H5 API
- ✅ 错误处理和数据权限
- ✅ 事务保证和级联操作
功能已通过编译验证,可以部署测试。前端需要配合调整以支持新的授权流程。

View File

@@ -1,250 +0,0 @@
# 枚举与状态字段规范
**背景**:系统历史上存在 int/string 枚举混用、description 与常量定义脱节、响应字段不统一等问题,导致前端映射错误、接口文档失真。本规范统一所有枚举相关写法。
---
## 1. 类型选择int vs string
### 选择规则
| 场景 | 类型 | 理由 |
|------|------|------|
| **状态类**:表示生命周期阶段(待支付→已完成→已关闭) | `int` | 便于范围查询、数值比较、DB 索引优化 |
| **布尔状态**:启用/禁用、激活/未激活 | `int` | 与全局常量统一,`0=禁用, 1=启用` |
| **类型/方式类**:支付方式、订单类型、运营商、平台 | `string` | 语义更清晰,无需记忆数字含义 |
| **标识符类**平台类型web/h5/all、角色来源 | `string` | 约定字符串值,自文档化 |
### 判断依据
```
这个字段表示"现在处于哪个阶段" → int
这个字段表示"它属于哪种类别/使用什么方式" → string
```
### ✅ 正确示例
```go
// 状态类 → int
Status int `json:"status"` // 充值状态1=待支付, 2=已支付...
PaymentStatus int `json:"payment_status"` // 订单支付状态
// 类型/方式类 → string
PaymentMethod string `json:"payment_method"` // "wechat" | "offline"
OrderType string `json:"order_type"` // "single_card" | "device"
Platform string `json:"platform"` // "web" | "h5" | "all"
```
### ❌ 错误示例
```go
// ❌ 状态用 string无法范围查询数字对比失效
Status string `json:"status"` // "pending" | "completed"
// ❌ 支付方式用 int前端必须维护不直观的数字映射
PaymentMethod int `json:"payment_method"` // 1=微信, 2=线下
```
---
## 2. Int 状态值约定
### 2.1 通用禁用/启用
**必须使用全局常量,禁止自定义**
```go
// pkg/constants/constants.go
StatusDisabled = 0 // 禁用
StatusEnabled = 1 // 启用
```
✅ 正确:
```go
Status int `gorm:"comment:状态(0-禁用 1-启用);default:1"`
```
❌ 禁止(与全局常量语义相反):
```go
// ❌ 不可以用 1=启用 2=禁用 这种方式
Status int `gorm:"comment:状态(1-启用 2-禁用)"`
```
### 2.2 生命周期状态
**1** 开始递增0 保留,避免与"未赋值/零值"混淆):
```go
// 充值状态(标准示例)
const (
RechargeStatusPending = 1 // 待支付
RechargeStatusPaid = 2 // 已支付
RechargeStatusCompleted = 3 // 已完成
RechargeStatusClosed = 4 // 已关闭
RechargeStatusRefunded = 5 // 已退款
)
```
### 2.3 特殊值
- 跳跃值(如 `99`)仅用于明确需要人工干预的异常状态,必须加注释说明原因
- 禁止在同一枚举中混用 0 起始和 1 起始
---
## 3. String 枚举值约定
- 全小写字母 + 下划线 `snake_case`(禁止驼峰、禁止大写)
- 必须在 `pkg/constants/` 定义常量,禁止硬编码字符串
- DTO 中必须加 `validate:"oneof=..."` 约束
```go
// pkg/constants/constants.go
const (
PaymentMethodWechat = "wechat" // 微信支付
PaymentMethodOffline = "offline" // 线下转账
)
// DTO 中
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" description:"支付方式 (wechat:微信支付, offline:线下转账)"`
```
❌ 禁止:
```go
// ❌ 硬编码字符串
if record.PaymentMethod == "wechat" { ... }
// ❌ 大写或驼峰
const PaymentMethodWechat = "Wechat"
const PaymentMethodWechat = "WECHAT"
```
---
## 4. Constants 是唯一真相来源
**DTO 的 description 枚举列表必须与 `pkg/constants/` 定义完全一致。**
### 操作规程
1. 先在 `pkg/constants/` 定义(或查找已有)枚举常量及注释
2. 将常量的注释**原文抄写**到 DTO description
```go
// 步骤1constants.go 中定义
const (
RechargeStatusPending = 1 // 待支付
RechargeStatusPaid = 2 // 已支付
RechargeStatusCompleted = 3 // 已完成
RechargeStatusClosed = 4 // 已关闭
RechargeStatusRefunded = 5 // 已退款
)
// 步骤2DTO description 从上面抄写,格式统一
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
```
### 禁止行为
```go
// ❌ description 中的枚举值与 constants 不一致(本次 bug 根因)
// constants: RechargeStatusCompleted=3已完成
// dto 写的: 3:已取消 ← 完全错误!
Status int `json:"status" description:"状态 (1:待支付, 2:已完成, 3:已取消)"`
```
---
## 5. description 格式标准
**统一格式**`字段含义 (值1:中文含义1, 值2:中文含义2, ...)`
规则:
- 必须用**中文圆括号外**、**英文括号内**
- 值和含义之间用**冒号** `:`,不用等号 `=`
- 多个值之间用**逗号加空格** `, `
- 含义必须是**中文**,不可用英文
```go
// ✅ 统一格式
Status int `description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
Platform string `description:"适用端口 (all:全部, web:Web后台, h5:H5端)"`
UserType int `description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
// ❌ 格式混乱
Status int `description:"状态 (0=禁用, 1=启用)"` // 用等号
Status int `description:"0=禁用 1=启用"` // 无括号无逗号
Status int `description:"状态0待生效 1生效中 2已用完"` // 格式不统一
```
---
## 6. Response DTO 的状态字段
**所有 Response DTO 中的 int 状态字段,必须同时提供对应的 `xxx_name` 文字字段。**
这是防止前端映射错误的关键措施。
```go
// ✅ Response DTO 标准写法
type AgentRechargeResponse struct {
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
// ...
}
// Service 层 toResponse 函数中赋值
func statusName(status int) string {
switch status {
case constants.RechargeStatusPending:
return "待支付"
case constants.RechargeStatusPaid:
return "已支付"
case constants.RechargeStatusCompleted:
return "已完成"
case constants.RechargeStatusClosed:
return "已关闭"
case constants.RechargeStatusRefunded:
return "已退款"
default:
return "未知"
}
}
```
**字段命名约定**
- `status``status_name`
- `payment_status``payment_status_name`
- `commission_status``commission_status_name`
**例外**Request DTO查询过滤、创建请求不需要 `_name` 字段。
---
## 7. Code Review 检查项
```
□ int/string 类型选择是否符合规则?
□ 禁/启用是否用了 0=禁用, 1=启用?(禁止 1=启用, 2=禁用)
□ string 枚举常量是否在 pkg/constants/ 定义?
□ dto description 是否从 constants 注释原文抄写?
□ description 格式是否统一(冒号、逗号、括号)?
□ Response DTO 是否同时有 status int 和 status_name string
□ 是否有遗漏的枚举值没有在 description 中列出?
```
---
## 8. 现存已知不一致(技术债,待修复)
| 文件 | 问题 | 正确值 |
|------|------|--------|
| `internal/model/shop_package_allocation.go` | `1-启用 2-禁用` | 应改为 `0-禁用 1-启用` |
| `internal/model/system.go` | `1-启用 2-禁用` | 需确认是否改 DB |
| `internal/model/dto/agent_recharge_dto.go` | description `2:已完成, 3:已取消` | 应为 `2:已支付, 3:已完成, 4:已关闭, 5:已退款` |
| `internal/model/dto/asset_wallet_dto.go` | 只有 `status_text``status int` | 需补充 `status int` |
> ⚠️ 已有数据的状态起始值(如 `1-启用 2-禁用`)改变前必须做 DB 数据迁移,改动前先评估影响。

View File

@@ -1,333 +0,0 @@
# 环境变量配置文档
## 概述
君鸿卡管系统使用嵌入式配置机制,默认配置编译在二进制文件中,通过环境变量进行覆盖。
**环境变量前缀**: `JUNHONG_`
**格式规则**: 配置路径中的 `.` 替换为 `_`,全部大写
## 必填配置
以下配置没有合理的默认值,必须通过环境变量设置:
### 数据库配置
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_DATABASE_HOST` | 数据库主机地址 | `localhost` |
| `JUNHONG_DATABASE_PORT` | 数据库端口 | `5432` |
| `JUNHONG_DATABASE_USER` | 数据库用户名 | `postgres` |
| `JUNHONG_DATABASE_PASSWORD` | 数据库密码 | `your_password` |
| `JUNHONG_DATABASE_DBNAME` | 数据库名称 | `junhong_cmp` |
### Redis 配置
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_REDIS_ADDRESS` | Redis 主机地址 | `localhost` |
### JWT 配置
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_JWT_SECRET_KEY` | JWT 签名密钥(生产环境必须修改) | `your-secret-key` |
### 微信配置
#### 微信公众号
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID` | 公众号 AppID必填 | `wxabcdef1234567890` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET` | 公众号 AppSecret必填 | `abcdef1234567890` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN` | 服务器配置Token可选 | `your_token` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY` | 消息加解密Key可选 | `` |
| `JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL` | OAuth回调URL可选 | `https://your-domain.com/callback` |
#### 微信支付
| 环境变量 | 说明 | 示例 |
|---------|------|------|
| `JUNHONG_WECHAT_PAYMENT_APP_ID` | 支付 AppID必填通常与公众号相同 | `wxabcdef1234567890` |
| `JUNHONG_WECHAT_PAYMENT_MCH_ID` | 商户号(必填) | `1234567890` |
| `JUNHONG_WECHAT_PAYMENT_API_V3_KEY` | APIv3 密钥必填32位字符串 | `your_apiv3_key_32_chars_here` |
| `JUNHONG_WECHAT_PAYMENT_API_V2_KEY` | APIv2 密钥(可选,部分接口需要) | `` |
| `JUNHONG_WECHAT_PAYMENT_CERT_PATH` | 商户证书路径(必填) | `/app/certs/apiclient_cert.pem` |
| `JUNHONG_WECHAT_PAYMENT_KEY_PATH` | 商户私钥路径(必填) | `/app/certs/apiclient_key.pem` |
| `JUNHONG_WECHAT_PAYMENT_SERIAL_NO` | 证书序列号(必填) | `1234567890ABCDEF` |
| `JUNHONG_WECHAT_PAYMENT_NOTIFY_URL` | 支付回调URL必填 | `https://api.your-domain.com/api/callback/wechat-pay` |
| `JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG` | HTTP调试日志可选 | `false` |
| `JUNHONG_WECHAT_PAYMENT_TIMEOUT` | HTTP请求超时可选 | `30s` |
**配置说明**
- 微信公众号和支付配置缺失时服务启动会失败FATAL 错误)
- 证书文件必须可读(权限 600 或 644
- APIv3 密钥必须是 32 位字符串
- 证书序列号可通过 `openssl x509 -in apiclient_cert.pem -noout -serial` 获取
- 详细配置指南参见 [微信集成使用指南](wechat-integration/使用指南.md)
## 可选配置
以下配置有合理的默认值,可按需覆盖:
### 服务器配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_SERVER_ADDRESS` | `:3000` | 服务监听地址 |
| `JUNHONG_SERVER_READ_TIMEOUT` | `30s` | 读取超时时间 |
| `JUNHONG_SERVER_WRITE_TIMEOUT` | `30s` | 写入超时时间 |
| `JUNHONG_SERVER_SHUTDOWN_TIMEOUT` | `30s` | 优雅关闭超时 |
| `JUNHONG_SERVER_PREFORK` | `false` | 是否启用预分叉模式 |
### 数据库连接池
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_DATABASE_SSLMODE` | `disable` | SSL 模式 |
| `JUNHONG_DATABASE_MAX_OPEN_CONNS` | `25` | 最大打开连接数 |
| `JUNHONG_DATABASE_MAX_IDLE_CONNS` | `10` | 最大空闲连接数 |
| `JUNHONG_DATABASE_CONN_MAX_LIFETIME` | `1h` | 连接最大生命周期 |
### Redis 配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_REDIS_PORT` | `6379` | Redis 端口 |
| `JUNHONG_REDIS_PASSWORD` | `""` | Redis 密码 |
| `JUNHONG_REDIS_DB` | `0` | Redis 数据库编号 |
| `JUNHONG_REDIS_POOL_SIZE` | `100` | 连接池大小 |
| `JUNHONG_REDIS_MIN_IDLE_CONNS` | `10` | 最小空闲连接数 |
| `JUNHONG_REDIS_DIAL_TIMEOUT` | `5s` | 连接超时 |
| `JUNHONG_REDIS_READ_TIMEOUT` | `3s` | 读取超时 |
| `JUNHONG_REDIS_WRITE_TIMEOUT` | `3s` | 写入超时 |
### 日志配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_LOGGING_LEVEL` | `info` | 日志级别 (debug/info/warn/error) |
| `JUNHONG_LOGGING_DEVELOPMENT` | `false` | 开发模式(启用彩色输出) |
| `JUNHONG_LOGGING_APP_LOG_FILENAME` | `logs/app.log` | 应用日志文件路径 |
| `JUNHONG_LOGGING_APP_LOG_MAX_SIZE` | `100` | 日志文件最大大小 (MB) |
| `JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS` | `7` | 最大备份文件数 |
| `JUNHONG_LOGGING_APP_LOG_MAX_AGE` | `30` | 日志保留天数 |
| `JUNHONG_LOGGING_APP_LOG_COMPRESS` | `true` | 是否压缩旧日志 |
| `JUNHONG_LOGGING_ACCESS_LOG_FILENAME` | `logs/access.log` | 访问日志文件路径 |
### JWT 配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_JWT_TOKEN_DURATION` | `24h` | Token 有效期 |
| `JUNHONG_JWT_ACCESS_TOKEN_TTL` | `24h` | Access Token TTL |
| `JUNHONG_JWT_REFRESH_TOKEN_TTL` | `168h` | Refresh Token TTL (7天) |
### 客户端配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_CLIENT_REQUIRE_PHONE_BINDING` | `true` | 是否强制 C 端用户绑定手机号(`true` 强制,`false` 不强制) |
### 队列配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_QUEUE_CONCURRENCY` | `10` | 并发 Worker 数量 |
| `JUNHONG_QUEUE_RETRY_MAX` | `3` | 最大重试次数 |
| `JUNHONG_QUEUE_TIMEOUT` | `30m` | 任务超时时间 |
### Worker 运行配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_WORKER_ROLE` | `all` | Worker 运行角色。`all` 为单实例兼容模式,`leader` 负责主动调度和初始化,`consumer` 只消费队列任务 |
| `JUNHONG_WORKER_INSTANCE_NAME` | `""` | Worker 实例名称,用于多实例日志区分,建议在多实例部署时配置唯一值 |
**使用说明**
- 单实例部署可不设置 `JUNHONG_WORKER_ROLE`,默认使用 `all`,行为与历史版本一致
- 多实例部署推荐固定 `1 leader + N consumer`
- `leader` / `all` 只能保留一个实例承担主动调度职责,横向扩容时请新增 `consumer`
**示例**
```bash
# 单实例兼容模式(默认可省略)
JUNHONG_WORKER_ROLE=all
# 多实例 leader
JUNHONG_WORKER_ROLE=leader
JUNHONG_WORKER_INSTANCE_NAME=worker-leader-1
# 多实例 consumer
JUNHONG_WORKER_ROLE=consumer
JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1
```
### 限流中间件
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_MIDDLEWARE_ENABLE_RATE_LIMITER` | `false` | 启用限流 |
| `JUNHONG_MIDDLEWARE_RATE_LIMITER_MAX` | `100` | 最大请求数 |
| `JUNHONG_MIDDLEWARE_RATE_LIMITER_EXPIRATION` | `1m` | 时间窗口 |
| `JUNHONG_MIDDLEWARE_RATE_LIMITER_STORAGE` | `memory` | 存储后端 (memory/redis) |
### 对象存储配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_STORAGE_PROVIDER` | `""` | 存储提供商 (s3) |
| `JUNHONG_STORAGE_TEMP_DIR` | `/tmp/junhong` | 临时文件目录 |
| `JUNHONG_STORAGE_S3_ENDPOINT` | `""` | S3 端点 |
| `JUNHONG_STORAGE_S3_REGION` | `""` | S3 区域 |
| `JUNHONG_STORAGE_S3_BUCKET` | `""` | S3 存储桶 |
| `JUNHONG_STORAGE_S3_ACCESS_KEY_ID` | `""` | S3 访问密钥 ID |
| `JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY` | `""` | S3 访问密钥 |
| `JUNHONG_STORAGE_S3_USE_SSL` | `true` | 是否使用 SSL |
| `JUNHONG_STORAGE_S3_PATH_STYLE` | `true` | 是否使用路径风格 |
| `JUNHONG_STORAGE_PRESIGN_UPLOAD_EXPIRES` | `1h` | 预签名上传 URL 有效期 |
| `JUNHONG_STORAGE_PRESIGN_DOWNLOAD_EXPIRES` | `1h` | 预签名下载 URL 有效期 |
### 短信配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_SMS_GATEWAY_URL` | `""` | 短信网关 URL |
| `JUNHONG_SMS_USERNAME` | `""` | 短信账号 |
| `JUNHONG_SMS_PASSWORD` | `""` | 短信密码 |
| `JUNHONG_SMS_SIGNATURE` | `""` | 短信签名 |
| `JUNHONG_SMS_TIMEOUT` | `10s` | 请求超时 |
### 默认管理员
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_DEFAULT_ADMIN_USERNAME` | `admin` | 默认管理员用户名 |
| `JUNHONG_DEFAULT_ADMIN_PASSWORD` | `Admin@123456` | 默认管理员密码 |
| `JUNHONG_DEFAULT_ADMIN_PHONE` | `13800000000` | 默认管理员手机号 |
### 轮询自动触发配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_POLLING_AUTO_TRIGGER_ENABLE_AUTO_TRIGGER` | `true` | 是否启用C端实名自动触发可临时关闭 |
| `JUNHONG_POLLING_AUTO_TRIGGER_AUTO_TRIGGER_SYSTEM_USER_ID` | `1` | 自动触发使用的系统用户ID暂用 SuperAdmin ID=1**生产环境建议创建专用平台账号并更新此值** |
## Docker Compose 示例
```yaml
version: '3.8'
services:
api:
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
- JUNHONG_LOGGING_LEVEL=info
volumes:
- ./logs:/app/logs
ports:
- "3000:3000"
worker:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
- JUNHONG_WORKER_ROLE=all
- JUNHONG_WORKER_INSTANCE_NAME=worker-all-1
volumes:
- ./logs:/app/logs
postgres:
image: postgres:14
environment:
- POSTGRES_USER=junhong
- POSTGRES_PASSWORD=secret123
- POSTGRES_DB=junhong_cmp
redis:
image: redis:6
```
- 单实例部署默认就是 `all`,上例显式写出只是为了便于排查日志;不设置 `JUNHONG_WORKER_ROLE` 时效果相同
- 多实例部署推荐 `1 leader + N consumer`,不要直接复制多个完整 `all` Worker
```yaml
services:
worker-leader:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
- JUNHONG_WORKER_ROLE=leader
- JUNHONG_WORKER_INSTANCE_NAME=worker-leader-1
worker-consumer-1:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
- JUNHONG_WORKER_ROLE=consumer
- JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1
worker-consumer-2:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
environment:
- JUNHONG_DATABASE_HOST=postgres
- JUNHONG_DATABASE_PORT=5432
- JUNHONG_DATABASE_USER=junhong
- JUNHONG_DATABASE_PASSWORD=secret123
- JUNHONG_DATABASE_DBNAME=junhong_cmp
- JUNHONG_REDIS_ADDRESS=redis
- JUNHONG_JWT_SECRET_KEY=your-production-secret-key
- JUNHONG_WORKER_ROLE=consumer
- JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-2
```
## 本地开发
本地开发可以创建 `.env` 文件(不要提交到 Git
```bash
# .env
JUNHONG_DATABASE_HOST=localhost
JUNHONG_DATABASE_PORT=5432
JUNHONG_DATABASE_USER=postgres
JUNHONG_DATABASE_PASSWORD=postgres
JUNHONG_DATABASE_DBNAME=junhong_cmp_dev
JUNHONG_REDIS_ADDRESS=localhost
JUNHONG_JWT_SECRET_KEY=dev-secret-key
JUNHONG_LOGGING_LEVEL=debug
JUNHONG_LOGGING_DEVELOPMENT=true
```
然后使用 `source .env` 加载环境变量后运行:
```bash
source .env
go run cmd/api/main.go
```

View File

@@ -1,219 +0,0 @@
# Excel导入功能 - 前端接入指南
## 变更说明
导入功能已从CSV格式升级为Excel格式(.xlsx),解决长数字(如20位ICCID)被Excel自动转为科学记数法导致数据损坏的问题。
## 关键变更
### 1. 文件格式
| 项目 | 旧版本(CSV) | 新版本(Excel) |
|-----|------------|--------------|
| 文件扩展名 | `.csv` | `.xlsx` |
| MIME类型 | `text/csv` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
| 文件选择器accept | `*``.csv` | `.xlsx` |
### 2. 上传示例代码
**ICCID导入**:
```javascript
// 1. 获取预签名URL
const response = await api.post('/api/admin/storage/upload-url', {
file_name: 'cards.xlsx', // 修改扩展名
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // 修改MIME类型
purpose: 'iot_import'
});
const { upload_url, file_key } = response.data;
// 2. 上传Excel文件到对象存储
await fetch(upload_url, {
method: 'PUT',
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // 修改MIME类型
},
body: file // File对象来自<input type="file" accept=".xlsx">
});
// 3. 提交导入任务
await api.post('/api/admin/iot-cards/import', {
carrier_id: 1,
batch_no: 'BATCH-2025-01',
file_key: file_key
});
```
**设备导入**: 流程相同,只需调用 `/api/admin/devices/import` 接口。
### 3. 文件选择器组件
**修改前**:
```html
<input type="file" accept="*" />
<!---->
<input type="file" accept=".csv" />
```
**修改后**:
```html
<input type="file" accept=".xlsx" />
```
### 4. 文件验证
```javascript
function validateFile(file) {
// 检查扩展名
if (!file.name.toLowerCase().endsWith('.xlsx')) {
throw new Error('仅支持上传Excel文件(.xlsx格式)');
}
// 检查MIME类型(可选,部分浏览器可能不准确)
const validTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream' // 部分浏览器可能返回此类型
];
if (!validTypes.includes(file.type)) {
console.warn('文件MIME类型不匹配,但根据扩展名判断为有效文件');
}
return true;
}
```
## Excel模板文件
### ICCID导入模板
**文件名**: `iccid_import_template.xlsx`
**格式**:
| ICCID | MSISDN | virtual_no |
|-------|--------|------------|
| 89860012345678901234 | 13800000001 | VNO-00001 |
| 89860012345678901235 | 13800000002 | VNO-00002 |
**字段说明**:
| 列名 | 是否必填 | 说明 |
|------|---------|------|
| ICCID | **是** | 卡唯一标识电信19位/其他20位 |
| MSISDN | **是** | 接入号 |
| virtual_no | **是** | 虚拟号,全局唯一;留空将导致该行导入失败 |
**要点**:
- 必须包含表头行(ICCID, MSISDN, virtual_no)
- 所有列必须设置为**文本格式**(重要!)
- Excel中设置文本格式: 选中列 → 右键 → 设置单元格格式 → 文本
- `virtual_no` 为必填列,**缺少该列将导致整批导入失败**
### 设备导入模板
**文件名**: `device_import_template.xlsx`
**格式**:
| virtual_no | sn | device_name | device_model | device_type | imei | manufacturer | max_sim_slots | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|------------|----|-------------|--------------|-------------|------|--------------|---------------|---------|---------|---------|---------|
| DEV-001 | SN-001 | GPS追踪器A | GT06N | GPS Tracker | 123456789012345 | Concox | 4 | 89860012345678901234 | 89860012345678901235 | | |
| DEV-002 | SN-002 | GPS追踪器B | GT06N | GPS Tracker | 123456789012346 | Concox | 3 | | 89860012345678901236 | 89860012345678901237 | |
**字段说明**:
| 列名 | 是否必填 | 说明 |
|------|---------|------|
| virtual_no | **是** | 设备虚拟号,全局唯一;**留空不再跳过,将记录为失败行** |
| sn | 否 | 设备 SN建议填写厂商序列号 |
| device_name | 否 | 设备名称 |
| device_model | 否 | 设备型号 |
| device_type | 否 | 设备类型 |
| imei | 否 | 设备IMEI |
| manufacturer | 否 | 制造商 |
| max_sim_slots | 否 | 最大SIM卡槽数默认4范围1-4 |
| iccid_1 ~ iccid_4 | 否 | 固定槽位的 IoT 卡 ICCID`iccid_1`=槽1`iccid_2`=槽2`iccid_3`=槽3`iccid_4`=槽4中间留空不会触发“前移补位” |
**要点**:
- 所有列都必须设置为**文本格式**
- `virtual_no` 为必填项,留空将记录为失败行(而非静默跳过)
- 设备导入按**固定列顺序**读取:`virtual_no``sn``device_name``device_model``device_type``imei``manufacturer``max_sim_slots``iccid_1``iccid_2``iccid_3``iccid_4`
- `iccid_1 ~ iccid_4` 为可选项,填写时对应的 ICCID 必须已存在于系统中
- `iccid_1 ~ iccid_4` 表示**固定槽位**而非“按非空顺序绑定”
例如只填写 `iccid_2``iccid_3`导入后仍应绑定到槽2和槽3而不是槽1和槽2
- 如果 `max_sim_slots=2`,则 `iccid_3``iccid_4` 必须留空;否则该行应导入失败
## 模板下载功能实现
```javascript
// 方案1: 后端提供静态文件下载
<a href="/api/admin/storage/templates/iccid_import_template.xlsx" download>
下载ICCID导入模板
</a>
// 方案2: 前端本地存放模板文件
<a href="/assets/templates/iccid_import_template.xlsx" download>
下载ICCID导入模板
</a>
```
建议使用方案2(前端本地存放),减轻后端负担。
## 错误处理
### 服务端错误示例
```json
{
"code": 1,
"msg": "不支持的文件格式 .csv,请上传Excel文件(.xlsx)",
"timestamp": "2025-01-31T13:00:00Z"
}
```
### 前端错误提示
```javascript
try {
await uploadAndImport(file);
} catch (error) {
if (error.response?.data?.msg) {
// 显示服务端返回的错误消息
showError(error.response.data.msg);
} else {
showError('上传失败,请重试');
}
}
```
## 迁移检查清单
- [ ] 修改文件选择器accept属性为 `.xlsx`
- [ ] 更新上传时的MIME类型为Excel格式
- [ ] 添加前端文件格式验证(扩展名检查)
- [ ] 准备Excel模板文件并放置到前端资源目录
- [ ] 添加"下载模板"按钮/链接
- [ ] 更新相关提示文案(CSV → Excel)
- [ ] 测试完整的上传流程
- [ ] 验证错误场景(上传CSV文件时的提示)
## 注意事项
1. **向后兼容**: 本次变更不向后兼容,旧的CSV文件无法使用,前端需同步更新
2. **用户通知**: 建议在界面上添加醒目提示,告知用户格式变更
3. **模板文件**: 模板文件中的ICCID列**必须**设置为文本格式,否则长数字会被Excel自动转为科学记数法
4. **文件大小**: Excel文件比CSV大3-5倍,但对1万行数据影响不大(约3-5MB)
## 常见问题
**Q: 为什么要从CSV改为Excel?**
A: Excel编辑CSV时会将超过15位的长数字(如20位ICCID)自动转为科学记数法,导致数据损坏。使用Excel格式并设置为文本格式可彻底解决此问题。
**Q: 用户已经准备好的CSV文件怎么办?**
A: 用户可以在Excel中打开CSV,将ICCID/MSISDN列设置为文本格式,然后另存为.xlsx格式即可。
**Q: 是否支持.xls(旧版Excel)?**
A: 不支持。仅支持.xlsx (Excel 2007+),建议在文档中明确说明。
## 联系方式
如有问题,请联系后端开发团队。

View File

@@ -1,41 +0,0 @@
## 订单操作者与佣金语义兼容映射清单
### 1. 历史字段现状
- `tb_order.operator_id`:历史上混用了“真实操作者账号 ID”和“代理店铺 ID”两种语义。
- `tb_order.operator_type`:历史上仅承载 `platform/agent`,且与 `operator_id` 的 ID 体系并不稳定一致。
- `tb_order.creator`:后台订单通常可回溯真实账号,但旧响应逻辑没有统一优先使用该字段补齐平台操作者展示。
- `tb_order.commission_status`:历史上仅有 `1=待计算``2=已计算`,无法表达“无佣金”和“链路异常待人工处理”。
### 2. 新增权威字段
- `operator_account_id`:真实操作者账号/主体 ID。
- `operator_account_type`:真实操作者类型,使用 `platform/agent/enterprise/personal_customer` 语义。
- `operator_account_name`:真实操作者名称快照。
- `commission_result`:佣金业务结果,使用 `0=未知 1=有佣金 2=无佣金 3=链路异常`
### 3. 写路径兼容策略
- 新订单统一写入 `operator_account_*` 权威字段。
- 保留写入 legacy `operator_id/operator_type`,但仅作为兼容读取兜底,不再作为新语义权威来源。
- `seller_shop_id` 继续作为差价佣金链路起点,不再从 `operator_id/operator_type` 推导佣金归属。
### 4. 读路径兼容策略
- 响应层优先读取 `operator_account_*`
- 若新字段为空:
- 优先尝试 `creator` 回填后台账号名称。
- 对客户端个人客户订单,回填个人客户昵称。
- 对历史代理订单,若 `operator_id/operator_type` 仍承载店铺语义,则回退展示店铺名称,但不回写新字段。
- `commission_result` 为空时,按历史订单状态与佣金记录兜底推断,不直接篡改旧列值。
### 5. 高风险兼容点
- `internal/store/postgres/order_store.go` 中旧逻辑把 `operator_id IN subordinateShopIDs` 作为代理权限过滤条件;当 `operator_id` 不再表示店铺 ID 后,该条件必须迁移到 `seller_shop_id`,历史订单仅保留兜底分支。
- `internal/service/order/service.go` 当前 `operator_name` 仅在 `operator_type=agent` 时按店铺名称回填,会导致平台旧订单长期空白。
- `internal/service/commission_calculation/service.go` 当前仅把订单写成“已计算”,无法区分“有佣金 / 无佣金 / 链路异常”。
### 6. 本次实现范围内的迁移原则
- 只做增量字段迁移,不删除旧列。
- 不在本次 migration 中批量强制回填历史订单,只在运行时兼容读取并为后续人工校验提供依据。

View File

@@ -1,78 +0,0 @@
## 订单操作者与产业链佣金语义修复功能总结
### 1. 变更目标
本次变更围绕两个历史语义问题展开:
1. 订单 `operator_id/operator_type` 混用了“真实操作者账号”和“代理店铺 ID”导致平台订单无法稳定显示真实操作者。
2. 订单 `commission_status` 只区分“待计算/已计算”,无法表达“有佣金 / 无佣金 / 链路异常待人工处理”的真实业务结果。
### 2. 已完成的核心改动
#### 2.1 订单操作者改为账号语义
- 新增权威字段:
- `operator_account_id`
- `operator_account_type`
- `operator_account_name`
- 后台订单创建、H5 代理订单创建、客户端个人客户订单、自动购包订单都会写入真实操作者快照。
-`operator_id/operator_type` 保留为历史兼容字段,不再作为新语义权威来源。
#### 2.2 订单响应分离“操作者账号”和“业务店铺”
- `operator_id/operator_type/operator_name` 现在按真实操作者账号语义返回。
- 新增 `seller_shop_id/seller_shop_name`,用于表达业务归属店铺展示。
- 历史订单优先使用新字段;若缺失,则按 `creator`、个人客户昵称、旧店铺型 `operator_id` 顺序兜底,避免平台旧订单出现空白操作者名称。
#### 2.3 订单佣金状态拆成“流程状态 + 业务结果”
- `commission_status` 现在表示流程状态:
- `1=待计算`
- `2=已完成`
- `3=待人工处理`
- 新增 `commission_result` 表示业务结果:
- `0=未知`
- `1=有佣金`
- `2=无佣金`
- `3=链路异常`
#### 2.4 差价佣金任务结果显式落单
- 佣金 worker 结束后会显式写回订单:
- `已完成 + 有佣金`
- `已完成 + 无佣金`
- `待人工处理 + 链路异常`
- 0 佣金订单不再长期停留在“待计算”。
#### 2.5 入队与幂等语义修复
- 钱包支付创建订单后,所有仍处于 `待计算` 的适用订单都会入队佣金任务,不再只覆盖特定购买角色。
- 重复支付回调、重复钱包支付不会重复入队。
- worker 对 `已完成/待人工处理` 订单直接跳过,避免重复补偿处理。
### 3. 兼容策略
- migration 仅新增字段,不删除旧列。
- 历史订单读取时:
- 优先使用 `operator_account_*`
- 其次使用 `creator` 回填账号名
- 对旧代理订单保留店铺名兜底展示
- 历史订单的 `commission_result` 若未回填,则在响应层按佣金记录只读推断。
### 4. 受影响文件
- `internal/model/order.go`
- `internal/model/dto/order_dto.go`
- `internal/service/order/service.go`
- `internal/service/client_order/service.go`
- `internal/service/commission_calculation/service.go`
- `internal/store/postgres/order_store.go`
- `internal/task/auto_purchase.go`
- `pkg/constants/iot.go`
- `migrations/000140_add_order_operator_snapshot_and_commission_result.*.sql`
### 5. 风险提示
- 历史订单若 `creator` 无法映射到账号,仍会退回旧店铺型操作者展示,仅保证“可读”而不强行伪造账号 ID。
- `commission_result=0` 仅作为历史兼容态存在;新计算链路应尽量落为明确业务结果。
- 本次不重构一次性佣金规则本身,只保证其异常待审记录能纳入订单最终流程结果判断。

View File

@@ -1,39 +0,0 @@
## 手工验收清单
### 一、平台订单操作者展示
- [ ] 创建或查询一条历史平台订单,确认响应中的 `operator_name` 不再为空。
- [ ] 确认该订单的 `operator_type=platform`,且操作者名称优先来自 `creator` 对应后台账号。
- [ ] 确认 `seller_shop_name``operator_name` 语义分离,不再混用店铺名代替平台账号名。
### 二、多级代理链差价佣金
- [ ] 准备一条 `seller_shop_id` 存在完整父级链的订单。
- [ ] 执行佣金任务后,确认各级代理按父级链逐级生成差价佣金记录。
- [ ] 确认订单 `commission_status=2``commission_result=1`
- [ ] 确认相关代理钱包流水与佣金记录金额一致。
### 三、无佣金完成态
- [ ] 准备一条顶级代理订单或无正差价订单。
- [ ] 执行佣金任务后,确认不产生正金额佣金记录。
- [ ] 确认订单不会继续停留在待计算,而是落为 `commission_status=2``commission_result=2`
### 四、链路异常待人工处理
- [ ] 准备一条上级链断裂、系列配置缺失或套餐分配缺失的订单。
- [ ] 执行佣金任务后,确认生成 `status=99` 的待人工修正佣金记录。
- [ ] 确认备注中包含链路断点信息。
- [ ] 确认订单落为 `commission_status=3``commission_result=3`
### 五、入队与幂等
- [ ] 钱包支付创建订单后,确认差价佣金任务会自动入队。
- [ ] 重复触发支付回调或重复调用钱包支付时,确认不会重复入队。
- [ ] 对已经完成或待人工处理的订单重复执行佣金任务,确认 worker 会直接跳过。
### 六、历史兼容读取
- [ ] 抽查至少一条历史平台订单,确认 `creator` 能兜底回填操作者名称。
- [ ] 抽查至少一条历史代理订单,确认新字段为空时仍可读出兼容操作者展示。
- [ ] 确认历史订单不会把旧 `operator_id` 误当成新的账号 ID 再写回数据库。

View File

@@ -1,525 +0,0 @@
# 代理钱包订单创建功能总结
## 概述
fix-agent-wallet-order-creation 提案修复了代理在后台使用钱包支付创建订单的问题,实现了代理钱包一步购买(扣款 + 激活)、代理代购、订单角色追踪等核心功能。
## <20><>景问题
### 问题描述
代理在后台使用钱包支付wallet创建订单时系统只创建待支付订单`payment_status = 1`),不扣款也不激活套餐,导致订单无法完成。后台没有支付接口,代理无法对待支付订单进行支付。
### 业务场景
- **代理自购**:代理为自己的卡/设备购买套餐,从自己钱包扣自己的成本价
- **代理代购**:代理为下级代理的卡/设备购买套餐,从自己钱包扣自己的成本价,但订单金额显示下级成本价
- **平台代购**(现有逻辑):平台使用 offline 支付为代理创建订单,不扣款,立即激活,产生佣金
## 核心功能
### 1. 订单角色追踪
**新增字段**`tb_order` 表):
- `operator_id` (INT, 可空):操作者 ID谁下的单
- `operator_type` (VARCHAR, 可空):操作者类型(`platform` / `agent`
- `actual_paid_amount` (BIGINT, 可空):实际支付金额(分)
- `purchase_role` (VARCHAR):订单角色枚举
**订单角色枚举**`internal/model/order.go`
```go
const (
PurchaseRoleSelfPurchase = "self_purchase" // 自己购买
PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买
PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购
PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买
)
```
**索引**
- `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询
- `idx_orders_purchase_role` (purchase_role):支持按角色筛选
---
### 2. 后台钱包一步支付
**行为变更**
- **原逻辑**:后台 wallet 订单 → 创建待支付订单(`payment_status = 1`)→ 无法支付
- **新逻辑**:后台 wallet 订单 → 立即扣款 + 激活套餐 → 订单已支付(`payment_status = 2`
**区别于 H5 端**
- H5 端 wallet 订单仍使用两步流程:创建待支付订单 → 调用 WalletPay 接口支付
- 后台 wallet 订单一步完成,无需后续支付接口
**权限调整**
- 允许代理、平台、超管使用 wallet 支付方式
- offline 支付方式仍限制为平台和超管
---
### 3. 价格计算逻辑
**区分"订单金额"和"实际支付"**
| 场景 | 订单金额total_amount | 实际支付actual_paid_amount | 说明 |
|------|------------------------|------------------------------|------|
| 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 |
| 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) |
| 平台代购 | 买家成本价 | NULL | 平台不扣款 |
**示例**
```
一级代理 A 成本价80 元
二级代理 B 成本价100 元
A 为 B 的卡购买套餐:
- total_amount = 10000100 元B 看到的订单金额)
- actual_paid_amount = 800080 元A 实际扣款)
- A 赚取差价20 元
```
**成本价查询**
通过 `ShopPackageAllocation` 表查询店铺对套餐的成本价。
---
### 4. 钱包流水记录扩展
**新增字段**`tb_agent_wallet_transaction` 表):
- `transaction_subtype` (VARCHAR):交易子类型(细分 order_payment 场景)
- `related_shop_id` (INT, 可空):关联店铺 ID代购时记录下级店铺
**交易子类型枚举**`pkg/constants/wallet.go`
```go
const (
WalletTransactionSubtypeSelfPurchase = "self_purchase"
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate"
)
```
**流水示例**
- **自购**`transaction_subtype = "self_purchase"``remark = "购买套餐"`
- **代购**`transaction_subtype = "purchase_for_subordinate"``related_shop_id = 下级店铺 ID``remark = "为下级代理【XX】购买套餐"`
---
### 5. 订单查询增强
**OR 查询逻辑**`OrderStore.List()`
```sql
WHERE (buyer_type = 'agent' AND buyer_id = ?) OR operator_id = ?
```
代理可以看到两类订单:
1. 作为买家的订单(`buyer_id = 自己`):别人为自己代购、自己购买
2. 作为操作者的订单(`operator_id = 自己`):自己为下级代购
**新增查询参数**
- `purchase_role`可选筛选订单角色类型self_purchase / purchased_by_parent / purchased_by_platform / purchase_for_subordinate
---
### 6. 佣金逻辑调整
**规则**
- **代理代购**:操作者已赚取成本价差(自己成本价 vs 下级成本价),不产生佣金
- **平台代购**:平台不扣款,按买家成本价计算差价佣金,激励上级代理
**实现**
```go
// 只有平台代购operator_id == nil才入队佣金计算
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
```
---
### 7. 幂等性和并发控制
**乐观锁**(钱包扣款):
```go
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", walletID, amount, version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", amount),
"version": gorm.Expr("version + 1"),
})
```
**幂等性检查**(订单创建):
- 使用 Redis 业务键:`order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
- TTL3 分钟
- 分布式锁防止并发:`order:create:lock:{carrier_type}:{carrier_id}`
---
## API 变更
### 后台订单创建 API❗ Breaking Change
**端点**`POST /api/admin/orders`
**请求参数变更**
| 字段 | 变更前 | 变更后 | 说明 |
|------|--------|--------|------|
| `payment_method` | 可选,任意值 | **必填**,仅允许 `wallet``offline` | 不传或传其他值均返回 1001 错误 |
**行为变更**
- `wallet` 支付:订单直接完成(`payment_status = 2`),无需后续支付接口
- `offline` 支付:逻辑保持不变
- 传入 `wechat`/`alipay` → 返回 `{"code": 1001, "msg": "请求参数解析失败"}`
**响应新增字段**
```json
{
"operator_id": 123,
"operator_type": "agent",
"operator_name": "一级代理 A",
"actual_paid_amount": 8000,
"purchase_role": "purchase_for_subordinate",
"is_purchased_by_parent": false,
"purchase_remark": "为下级代理【二级代理 B】购买"
}
```
### H5 端订单创建 API无变更
**端点**`POST /api/h5/orders`
行为完全不变,仍支持 `wallet`/`wechat`/`alipay`,仍创建待支付订单。
### 订单列表 API
**端点**`GET /api/admin/orders`
**新增查询参数**
- `purchase_role` (可选):订单角色筛选
- `self_purchase`:自己购买
- `purchased_by_parent`:上级代理购买
- `purchased_by_platform`:平台代购
- `purchase_for_subordinate`:给下级购买
**查询逻辑变更**
- 代理可以看到 `buyer_id = 自己``operator_id = 自己` 的所有订单
---
## 数据库变更
### 订单表tb_order
**新增字段**
```sql
ALTER TABLE tb_order ADD COLUMN operator_id INT;
ALTER TABLE tb_order ADD COLUMN operator_type VARCHAR(20);
ALTER TABLE tb_order ADD COLUMN actual_paid_amount BIGINT;
ALTER TABLE tb_order ADD COLUMN purchase_role VARCHAR(50);
COMMENT ON COLUMN tb_order.operator_id IS '操作者ID谁下的单';
COMMENT ON COLUMN tb_order.operator_type IS '操作者类型platform/agent';
COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)';
COMMENT ON COLUMN tb_order.purchase_role IS '订单角色self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate';
```
**新增索引**
```sql
CREATE INDEX CONCURRENTLY idx_orders_operator_id ON tb_order(operator_id);
CREATE INDEX CONCURRENTLY idx_orders_purchase_role ON tb_order(purchase_role);
```
---
### 钱包流水表tb_agent_wallet_transaction
**新增字段**(如果不存在):
```sql
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50);
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT;
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)';
COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID代购时记录下级店铺';
```
---
## 代码结构
### Service 层新增方法
**`internal/service/order/service.go`**
1. **`getCostPrice(ctx, shopID, packageID) (int64, error)`**
- 查询店铺对套餐的成本价(通过 ShopPackageAllocation
2. **`createWalletTransaction(ctx, tx, walletID, orderID, amount, purchaseRole, relatedShopID) error`**
- 创建钱包流水,根据 purchaseRole 填充 subtype 和 remark
3. **`createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID) (*dto.OrderResponse, error)`**
- 钱包支付订单创建方法,事务内完成:订单创建 + 扣款 + 流水 + 激活套餐
**`Create()` 方法重构**
```go
// 场景判断
if req.PaymentMethod == "offline":
// 平台代购场景(保持现有逻辑)
return s.createOrderWithActivation(...)
else if req.PaymentMethod == "wallet":
// 获取资源所属店铺 ID
if 资源属于操作者:
// 代理自购场景
buyer = operator
purchase_role = "self_purchase"
total_amount = actual_paid_amount = 操作者成本价
else:
// 代理代购场景
buyer = 资源所属者
operator = 操作者
purchase_role = "purchase_for_subordinate"
total_amount = 买家成本价
actual_paid_amount = 操作者成本价
return s.createOrderWithWalletPayment(...)
```
---
### Store 层变更
**`internal/store/postgres/order_store.go`**
**`List()` 方法**
```go
// 代理用户:查询作为买家或操作者的订单
if shopID, ok := filters["shop_id"].(uint); ok {
query = query.Where(
"(buyer_type = ? AND buyer_id = ?) OR operator_id = ?",
model.BuyerTypeAgent, shopID, shopID,
)
}
// 支持 purchase_role 精确匹配筛选
if purchaseRole, ok := filters["purchase_role"].(string); ok {
query = query.Where("purchase_role = ?", purchaseRole)
}
```
---
### Handler 层变更
**`internal/handler/admin/order.go`**
**`Create()` 方法**
- 修改 wallet 支付方式的权限检查,允许代理、平台、超管使用
- offline 支付方式仍限制为平台和超管
**`List()` 方法**
- 从查询参数解析 `purchase_role`
- 传递给 Service 层的 `List()` 方法
---
## 使用指南
### 代理自购场景
**请求**
```http
POST /api/admin/orders
Authorization: Bearer {agent_token}
Content-Type: application/json
{
"order_type": 1,
"iot_card_id": 101,
"package_ids": [201],
"payment_method": "wallet"
}
```
**响应**
```json
{
"code": 0,
"data": {
"id": 1001,
"order_no": "ORD202602281234567890",
"payment_status": 2,
"operator_id": 10,
"buyer_id": 10,
"operator_type": "agent",
"purchase_role": "self_purchase",
"total_amount": 8000,
"actual_paid_amount": 8000
},
"msg": "订单创建成功"
}
```
---
### 代理代购场景
**请求**
```http
POST /api/admin/orders
Authorization: Bearer {parent_agent_token}
Content-Type: application/json
{
"order_type": 1,
"iot_card_id": 201,
"package_ids": [301],
"payment_method": "wallet"
}
```
**响应**
```json
{
"code": 0,
"data": {
"id": 1002,
"order_no": "ORD202602281234567891",
"payment_status": 2,
"operator_id": 10,
"buyer_id": 20,
"operator_type": "agent",
"operator_name": "一级代理 A",
"purchase_role": "purchase_for_subordinate",
"total_amount": 10000,
"actual_paid_amount": 8000,
"purchase_remark": "为下级代理【二级代理 B】购买"
},
"msg": "订单创建成功"
}
```
---
### 订单列表查询
**请求**
```http
GET /api/admin/orders?purchase_role=purchase_for_subordinate&page=1&page_size=20
Authorization: Bearer {agent_token}
```
**响应**
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1002,
"purchase_role": "purchase_for_subordinate",
"operator_id": 10,
"buyer_id": 20,
"total_amount": 10000,
"actual_paid_amount": 8000
}
],
"total": 1
},
"msg": "success"
}
```
---
## 迁移和部署
### 数据库迁移
**迁移脚本**
- `migrations/000067_add_operator_fields_to_orders.up.sql`
- `migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql`
**回滚脚本**
- `migrations/000067_add_operator_fields_to_orders.down.sql`
- `migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql`
**数据回填**(可选):
- `migrations/backfill_order_purchase_role.sql`:回填历史平台代购订单
---
### 部署步骤
1. **测试环境验证**
- 执行迁移脚本
- 验证索引创建成功
- 手工测试三种代购场景
2. **灰度发布**
- 代码部署到灰度环境
- 观察日志和监控指标
- 验证订单创建、查询、钱包扣款功能
3. **生产环境部署**
- 低峰期执行数据库迁移
- 部署代码
- 监控错误日志和业务指标
- 验证核心功能
---
### 监控指标
**关键指标**
- 订单创建成功率(按 payment_method 分组)
- 钱包扣款成功率
- 错误日志:余额不足、并发冲突、套餐激活失败
- 订单创建耗时P95、P99
**告警规则**
- 钱包扣款失败率 > 5%
- 订单创建失败率 > 10%
- 并发冲突次数 > 100/分钟
---
## 兼容性说明
### 向后兼容
- **现有订单字段为空值**:不影响已有订单查询
- **平台代购offline逻辑不变**:保持现有行为
- **H5 钱包支付不受影响**H5 端仍使用两步流程
- **数据权限保持一致**:订单角色追踪不影响现有数据权限逻辑
### 破坏性变更
**无**。所有新增字段均为 nullable新增逻辑不影响现有流程。
---
## 测试覆盖
### 集成测试场景
1. **代理自购**:代理为自己的卡购买套餐,验证扣款、激活、流水
2. **代理代购**:一级代理为二级代理购买,验证价格差异、佣金不产生
3. **平台代购**:平台 offline 代购,验证不扣款、佣金产生
4. **订单查询**:验证 OR 查询逻辑、purchase_role 筛选
5. **边界场景**:余额不足、并发扣款、幂等性
### 验证结果
- ✅ 编译通过:`go build ./...`
- ✅ OpenAPI 文档更新:新增字段已包含
- ✅ 迁移脚本执行成功
---
## 相关文档
- [提案文档](../../openspec/changes/fix-agent-wallet-order-creation/proposal.md)
- [设计文档](../../openspec/changes/fix-agent-wallet-order-creation/design.md)
- [任务清单](../../openspec/changes/fix-agent-wallet-order-creation/tasks.md)
- [Specs 规范](../../openspec/changes/fix-agent-wallet-order-creation/specs/)
- [项目规范](../../CLAUDE.md)

View File

@@ -1,538 +0,0 @@
# 代理钱包订单创建功能部署指南
## 部署前检查清单
### 代码检查
- [x] 编译通过:`go build ./...`
- [x] OpenAPI 文档更新:`go run cmd/gendocs/main.go`
- [ ] 测试环境验证通过
- [ ] Code Review 通过
### 数据库准备
- [ ] 测试环境迁移脚本执行成功
- [ ] 生产环境数据库备份完成
- [ ] 回滚脚本准备完毕
---
## 数据库迁移
### 迁移脚本清单
**脚本位置**`migrations/`
| 序号 | 文件名 | 说明 | 执行时间 |
|------|--------|------|----------|
| 000067 | `add_operator_fields_to_orders.up.sql` | 订单表新增字段和索引 | < 5 秒 |
| 000068 | `add_transaction_subtype_to_wallet_transaction.up.sql` | 钱包流水表新增字段 | < 1 秒 |
**回滚脚本**
| 序号 | 文件名 | 说明 |
|------|--------|------|
| 000067 | `add_operator_fields_to_orders.down.sql` | 删除订单表字段和索引 |
| 000068 | `add_transaction_subtype_to_wallet_transaction.down.sql` | 删除钱包流水表字段 |
---
### 迁移执行步骤
#### 步骤 1备份数据库
```bash
# 生产环境数据库备份
pg_dump -h <host> -U <user> -d junhong_cmp -F c -b -v -f "backup_$(date +%Y%m%d_%H%M%S).dump"
```
**验证备份**
```bash
pg_restore --list backup_*.dump | head -20
```
---
#### 步骤 2执行迁移测试环境
**使用 migrate 工具**
```bash
# 切换到项目目录
cd /path/to/junhong_cmp_fiber
# 执行迁移
migrate -path migrations -database "postgresql://<user>:<password>@<host>:<port>/junhong_cmp?sslmode=disable" up
# 验证迁移版本
migrate -path migrations -database "postgresql://<user>:<password>@<host>:<port>/junhong_cmp?sslmode=disable" version
```
**手动执行(可选)**
```bash
# 连接数据库
psql -h <host> -U <user> -d junhong_cmp
# 执行迁移脚本
\i migrations/000067_add_operator_fields_to_orders.up.sql
\i migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql
```
---
#### 步骤 3验证迁移结果
**检查字段**
```sql
-- 验证订单表字段
\d tb_order
-- 预期输出包含:
-- operator_id | integer | | |
-- operator_type | character varying(20) | | |
-- actual_paid_amount | bigint | | |
-- purchase_role | character varying(50) | | |
```
**检查索引**
```sql
-- 验证索引
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'tb_order'
AND indexname IN ('idx_orders_operator_id', 'idx_orders_purchase_role');
-- 预期输出:
-- idx_orders_operator_id | CREATE INDEX idx_orders_operator_id ON public.tb_order USING btree (operator_id)
-- idx_orders_purchase_role | CREATE INDEX idx_orders_purchase_role ON public.tb_order USING btree (purchase_role)
```
**检查钱包流水表**
```sql
-- 验证钱包流水表字段
\d tb_agent_wallet_transaction
-- 预期输出包含:
-- transaction_subtype | character varying(50) | | |
-- related_shop_id | integer | | |
```
---
#### 步骤 4数据回填可选
**回填历史订单**
```bash
psql -h <host> -U <user> -d junhong_cmp -f migrations/backfill_order_purchase_role.sql
```
**验证回填结果**
```sql
SELECT purchase_role, operator_type, COUNT(*) as count
FROM tb_order
WHERE purchase_role IS NOT NULL
GROUP BY purchase_role, operator_type;
-- 预期输出示例:
-- purchased_by_platform | platform | 1234
```
---
#### 步骤 5执行迁移生产环境
**时间窗口**:选择低峰期(凌晨 2:00 - 4:00
**执行命令**(与测试环境相同):
```bash
migrate -path migrations -database "postgresql://<prod_host>:<prod_port>/<db>?sslmode=require" up
```
**监控指标**
- 迁移执行时间
- 索引创建时间CONCURRENTLY不锁表
- 数据库连接数
- 慢查询日志
---
### 回滚步骤
**场景**:迁移失败或发现严重 Bug
#### 步骤 1停止应用
```bash
# 停止应用服务
systemctl stop junhong-cmp-api
```
#### 步骤 2执行回滚
```bash
# 回滚到上一版本
migrate -path migrations -database "postgresql://<host>:<port>/<db>?sslmode=disable" down 2
```
**或手动执行回滚脚本**
```bash
psql -h <host> -U <user> -d junhong_cmp <<EOF
\i migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql
\i migrations/000067_add_operator_fields_to_orders.down.sql
EOF
```
#### 步骤 3验证回滚
```sql
-- 验证字段已删除
\d tb_order
\d tb_agent_wallet_transaction
-- 验证索引已删除
SELECT indexname FROM pg_indexes WHERE tablename = 'tb_order';
```
#### 步骤 4恢复应用旧版本代码
```bash
# 回滚代码到上一版本
git checkout <previous_commit>
# 重新编译
go build -o api cmd/api/main.go
# 启动应用
systemctl start junhong-cmp-api
```
---
## 代码部署
### 灰度发布计划
**阶段 1灰度服务器10% 流量)**
**时间**:低峰期(周一至周五 02:00 - 04:00
**步骤**
1. 部署代码到灰度服务器
2. 切换 10% 流量到灰度服务器
3. 观察 2 小时,监控关键指标
4. 手工测试代理自购、代理代购场景
**验证项**
- [ ] 应用启动成功
- [ ] 健康检查通过:`curl http://localhost:8080/health`
- [ ] 订单创建成功率 > 95%
- [ ] 钱包扣款成功率 > 99%
- [ ] 无严重错误日志
---
**阶段 2全量发布100% 流量)**
**时间**:灰度验证通过后 24 小时
**步骤**
1. 部署代码到所有服务器
2. 逐步切换流量20% → 50% → 100%
3. 持续监控 24 小时
**验证项**
- [ ] 所有服务器应用启动成功
- [ ] 订单创建成功率 > 95%
- [ ] 钱包扣款成功率 > 99%
- [ ] 错误日志无异常峰值
- [ ] 用户反馈无异常
---
### 发布命令
**构建**
```bash
# 构建二进制文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o api cmd/api/main.go
# 验证版本
./api --version
```
**部署**
```bash
# 停止服务
systemctl stop junhong-cmp-api
# 备份旧版本
cp /opt/junhong-cmp/api /opt/junhong-cmp/api.backup
# 替换新版本
cp api /opt/junhong-cmp/api
# 启动服务
systemctl start junhong-cmp-api
# 检查状态
systemctl status junhong-cmp-api
```
**验证**
```bash
# 健康检查
curl http://localhost:8080/health
# 查看日志
journalctl -u junhong-cmp-api -f
```
---
## 监控指标
### 关键业务指标
**订单创建**
- 订单创建成功率(总体)
- 订单创建成功率(按 payment_method 分组)
- 订单创建耗时P50、P95、P99
- 订单创建 QPS
**钱包扣款**
- 钱包扣款成功率
- 钱包扣款失败原因分布(余额不足、并发冲突、其他)
- 钱包余额不足次数
**订单查询**
- 订单列表查询耗时P95
- OR 查询性能(慢查询日志)
---
### 错误日志监控
**关键错误**
```bash
# 余额不足
grep "余额不足" /var/log/junhong-cmp/app.log | wc -l
# 并发冲突
grep "并发冲突" /var/log/junhong-cmp/app.log | wc -l
# 套餐激活失败
grep "套餐激活失败" /var/log/junhong-cmp/app.log | wc -l
# 成本价查询失败
grep "店铺没有该套餐的分配配置" /var/log/junhong-cmp/app.log | wc -l
```
---
### 数据库性能监控
**慢查询**
```sql
-- 查看慢查询
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
WHERE query LIKE '%tb_order%'
AND mean_time > 100
ORDER BY mean_time DESC
LIMIT 10;
```
**索引使用率**
```sql
-- 检查新索引是否被使用
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexname IN ('idx_orders_operator_id', 'idx_orders_purchase_role');
```
**OR 查询性能**
```sql
-- EXPLAIN 分析
EXPLAIN ANALYZE
SELECT * FROM tb_order
WHERE (buyer_type = 'agent' AND buyer_id = 10) OR operator_id = 10
LIMIT 20;
```
---
### 告警规则
**业务告警**
| 指标 | 阈值 | 级别 |
|------|------|------|
| 订单创建成功率 | < 95% | P1 |
| 钱包扣款成功率 | < 99% | P1 |
| 订单创建耗时 P99 | > 1000ms | P2 |
| 并发冲突次数 | > 100/分钟 | P2 |
| 余额不足次数 | > 500/小时 | P3 |
**系统告警**
| 指标 | 阈值 | 级别 |
|------|------|------|
| 应用进程退出 | - | P0 |
| 数据库连接数 | > 80% | P1 |
| 慢查询(订单相关) | > 1000ms | P2 |
---
## 验证测试
### 功能验证清单
**代理自购**
- [ ] 创建订单成功
- [ ] 钱包余额正确扣减
- [ ] 订单状态为已支付
- [ ] 套餐已激活
- [ ] 钱包流水记录正确transaction_subtype = "self_purchase"
- [ ] 订单响应字段完整operator_id、purchase_role 等)
**代理代购**
- [ ] 创建订单成功
- [ ] 钱包余额按操作者成本价扣减
- [ ] 订单金额显示买家成本价
- [ ] actual_paid_amount 为操作者成本价
- [ ] 套餐已激活
- [ ] 钱包流水记录正确transaction_subtype = "purchase_for_subordinate"、related_shop_id、remark 包含店铺名称)
- [ ] 未产生佣金记录
**平台代购**
- [ ] 创建订单成功
- [ ] 钱包余额未扣减
- [ ] 订单状态为已支付
- [ ] 套餐已激活
- [ ] 产生佣金记录
- [ ] purchase_role = "purchased_by_platform"
**订单查询**
- [ ] 代理可查询作为买家或操作者的订单
- [ ] purchase_role 筛选生效
- [ ] 订单列表响应包含新字段
**边界场景**
- [ ] 余额不足时返回明确错误
- [ ] 并发扣款时乐观锁生效
- [ ] 幂等性检查防止重复创建
- [ ] H5 端 wallet 订单不受影响(仍为待支付)
---
### 性能验证
**压力测试**(可选):
```bash
# 订单创建并发测试
ab -n 1000 -c 50 -H "Authorization: Bearer <token>" \
-p order_request.json \
-T "application/json" \
http://localhost:8080/api/admin/orders
# 订单列表查询性能测试
ab -n 5000 -c 100 -H "Authorization: Bearer <token>" \
http://localhost:8080/api/admin/orders?page=1&page_size=20
```
**预期结果**
- 订单创建 QPS > 50
- 订单创建 P95 < 200ms
- 订单列表查询 P95 < 100ms
---
## 回滚预案
### 回滚触发条件
满足以下任一条件时立即回滚:
- 订单创建成功率 < 90%(持续 5 分钟)
- 钱包扣款成功率 < 95%(持续 5 分钟)
- 发现严重 Bug重复扣款、金额计算错误、数据丢失
- 用户投诉量激增
---
### 快速回滚步骤
**步骤 1立即回滚代码**< 5 分钟)
```bash
# 停止服务
systemctl stop junhong-cmp-api
# 恢复旧版本
cp /opt/junhong-cmp/api.backup /opt/junhong-cmp/api
# 启动服务
systemctl start junhong-cmp-api
```
**步骤 2回滚数据库**(可选,< 10 分钟)
仅当数据异常时执行:
```bash
# 执行回滚脚本
migrate -path migrations -database "..." down 2
```
**步骤 3验证回滚成功**
- [ ] 应用启动成功
- [ ] 健康检查通过
- [ ] 订单创建成功率恢复
- [ ] 用户反馈恢复正常
---
## 上线后观察
### 观察期7 天)
**每日检查**
- [ ] 订单创建成功率
- [ ] 钱包扣款成功率
- [ ] 错误日志无异常
- [ ] 用户反馈无异常
- [ ] 数据库慢查询无新增
**周报总结**
- 订单创建总量、成功率
- 钱包扣款总量、成功率
- 代理自购 vs 代理代购占比
- 错误类型分布
- 性能指标趋势
---
## 联系人
**技术负责人**[姓名]
**运维负责人**[姓名]
**产品负责人**[姓名]
**紧急联系方式**
- 技术值班电话:[电话]
- 运维值班电话:[电话]
---
## 附录
### 相关文档
- [功能总结](./功能总结.md)
- [提案文档](../../openspec/changes/fix-agent-wallet-order-creation/proposal.md)
- [设计文档](../../openspec/changes/fix-agent-wallet-order-creation/design.md)
- [任务清单](../../openspec/changes/fix-agent-wallet-order-creation/tasks.md)
### 迁移脚本内容
详见 `migrations/` 目录:
- `000067_add_operator_fields_to_orders.up.sql`
- `000067_add_operator_fields_to_orders.down.sql`
- `000068_add_transaction_subtype_to_wallet_transaction.up.sql`
- `000068_add_transaction_subtype_to_wallet_transaction.down.sql`
- `backfill_order_purchase_role.sql`

View File

@@ -1,242 +0,0 @@
# 订单激活幂等性修复功能总结
## 问题背景
### 业务风险
**核心规则**:同一个订单只能激活一次套餐使用记录
**潜在问题场景**
- 用户重复点击支付按钮
- 网络超时导致客户端重试
- 支付回调重复通知
- 并发请求同时到达服务器
**风险后果**
- 重复生成 `PackageUsage` 记录
- 用户重复获得权益
- 资金/权益漏洞
## 解决方案
### 核心机制:状态机作为幂等门闸
使用订单支付状态的条件更新作为幂等控制:
```sql
UPDATE tb_order
SET payment_status = 2, payment_method = 'wallet', paid_at = NOW()
WHERE id = ? AND payment_status = 1
```
**关键点**
- 只有 `payment_status = 1`(待支付)的订单才能更新为 `2`(已支付)
- 使用 `RowsAffected` 判断是否更新成功
- 并发场景下只有一个请求能成功更新
### 幂等处理逻辑
**当条件更新失败(`RowsAffected == 0`)时**
1. 重新查询订单当前状态
2. 根据状态返回相应结果:
- 已支付:返回成功(幂等成功)
- 已取消:返回错误 `CodeInvalidStatus`
- 已退款:返回错误 `CodeInvalidStatus`
- 其他状态:返回错误 `CodeInvalidStatus`
### 防御性约束
**数据库层面**
-`tb_package_usage` 添加唯一索引
- 约束:`(order_id, package_id)` 唯一
- 条件:`WHERE deleted_at IS NULL`
**代码层面**
- `activatePackage` 中检查是否已存在 `PackageUsage` 记录
- 如果存在,记录警告日志并跳过创建
### 事务一致性
**确保所有数据访问使用同一事务 `tx`**
- 订单明细查询:`tx.Where("order_id = ?", order.ID).Find(&items)`
- 套餐信息查询:`tx.First(&pkg, item.PackageID)`
- 套餐使用记录创建:`tx.Create(usage)`
## 技术实现
### 修改的文件
1. **`internal/service/order/service.go`**
- `WalletPay` 方法:条件更新 + 幂等处理
- `HandlePaymentCallback` 方法:条件更新 + 幂等处理
- `activatePackage` 方法:事务化 + 防御性检查
2. **`migrations/000033_add_unique_index_package_usage_order_package.up.sql`**
- 创建唯一索引
3. **`internal/service/order/service_test.go`**
- 新增幂等性和异常状态测试
### 关键代码片段
#### 钱包支付幂等处理
```go
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
})
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, orderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
// 扣减钱包余额并激活套餐
// ...
})
```
#### 套餐激活防御性检查
```go
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
var items []*model.OrderItem
if err := tx.Where("order_id = ?", order.ID).Find(&items).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
}
for _, item := range items {
var existingUsage model.PackageUsage
err := tx.Where("order_id = ? AND package_id = ?", order.ID, item.PackageID).
First(&existingUsage).Error
if err == nil {
s.logger.Warn("套餐使用记录已存在,跳过创建",
zap.Uint("order_id", order.ID),
zap.Uint("package_id", item.PackageID))
continue
}
// 创建新记录
// ...
}
}
```
## 测试验证
### 测试用例
1. **钱包支付幂等测试**
- 首次支付成功
- 第二次支付返回成功(幂等)
- 第三次支付返回成功(幂等)
- 验证只生成一条 `PackageUsage` 记录
2. **支付回调幂等测试**
- 首次回调成功
- 重复回调返回成功(幂等)
- 验证只生成一条 `PackageUsage` 记录
3. **已取消订单支付测试**
- 创建订单并取消
- 尝试支付
- 验证返回错误 `CodeInvalidStatus`
4. **已支付订单支付测试**
- 创建订单并支付
- 尝试再次支付
- 验证返回成功(幂等成功)
### 测试结果
```
PASS
coverage: 71.7% of statements
ok github.com/break/junhong_cmp_fiber/internal/service/order 23.555s
```
所有测试通过,核心业务逻辑覆盖率良好。
## 使用注意事项
### 迁移步骤
1. **应用代码变更**
```bash
git pull
go build
```
2. **执行数据库迁移**(已完成)
```bash
migrate -path migrations -database "postgresql://..." up
# 输出: 33/u add_unique_index_package_usage_order_package (323.480333ms)
```
3. **验证索引创建**(已验证)
```
索引名称: idx_package_usage_order_package
索引定义: CREATE UNIQUE INDEX idx_package_usage_order_package
ON tb_package_usage (order_id, package_id)
WHERE deleted_at IS NULL
```
### 监控建议
1. **关键指标**
- 订单支付成功率
- 幂等请求占比
- `PackageUsage` 创建失败率
2. **日志监控**
- 搜索 "套餐使用记录已存在" 警告日志
- 监控 "订单已取消/已退款" 错误
3. **数据一致性检查**
```sql
-- 检查是否有订单对应多条 PackageUsage
SELECT order_id, COUNT(*)
FROM tb_package_usage
WHERE deleted_at IS NULL
GROUP BY order_id, package_id
HAVING COUNT(*) > 1;
```
## 性能影响
### 预期影响
- **写操作**:略有增加(增加了条件更新和状态查询)
- **读操作**:无影响
- **数据库**:唯一索引对插入性能有轻微影响
### 优化建议
- 订单支付状态字段已建立索引
- 唯一索引创建时使用 `WHERE deleted_at IS NULL` 减少索引大小
- 事务内的查询都使用主键或索引字段
## 相关文档
- [变更提案](../../openspec/changes/fix-order-activation-idempotency/proposal.md)
- [设计文档](../../openspec/changes/fix-order-activation-idempotency/design.md)
- [任务清单](../../openspec/changes/fix-order-activation-idempotency/tasks.md)

View File

@@ -1,753 +0,0 @@
# Gateway API 参考文档
## 概述
本文档提供 Gateway 客户端所有 API 接口的完整参考,包括请求参数、响应格式和使用示例。
**API 分类**
- 流量卡管理7 个接口)
- 设备管理7 个接口)
**基础信息**
- 协议HTTPS
- 请求方法POST
- 内容类型application/json
- 编码方式UTF-8
- 加密方式AES-128-ECB + Base64
- 签名方式MD5
---
## 流量卡管理 API
### 1. 查询流量卡状态
查询流量卡的当前状态信息。
**方法**: `QueryCardStatus`
**请求参数**:
```go
type CardStatusReq struct {
CardNo string `json:"cardNo" validate:"required"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡号ICCID |
**响应参数**:
```go
type CardStatusResp struct {
ICCID string `json:"iccid"`
CardStatus string `json:"cardStatus"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| ICCID | string | 流量卡 ICCID |
| CardStatus | string | 卡状态(准备、正常、停机) |
| Extend | string | 扩展字段(广电国网特殊参数) |
**使用示例**:
```go
resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: "898608070422D0010269",
})
if err != nil {
return err
}
fmt.Printf("卡状态: %s\n", resp.CardStatus)
```
---
### 2. 查询流量使用情况
查询流量卡的流量使用详情。
**方法**: `QueryFlow`
**请求参数**:
```go
type FlowQueryReq struct {
CardNo string `json:"cardNo" validate:"required"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡号ICCID |
**响应参数**:
```go
type FlowUsageResp struct {
UsedFlow int64 `json:"usedFlow"`
Unit string `json:"unit"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| UsedFlow | int64 | 已用流量 |
| Unit | string | 流量单位MB |
| Extend | string | 扩展字段 |
**使用示例**:
```go
resp, err := client.QueryFlow(ctx, &gateway.FlowQueryReq{
CardNo: "898608070422D0010269",
})
if err != nil {
return err
}
fmt.Printf("已用流量: %d %s\n", resp.UsedFlow, resp.Unit)
```
---
### 3. 查询实名认证状态
查询流量卡的实名认证状态。
**方法**: `QueryRealnameStatus`
**请求参数**:
```go
type CardStatusReq struct {
CardNo string `json:"cardNo" validate:"required"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡号ICCID |
**响应参数**:
```go
type RealnameStatusResp struct {
Status string `json:"status"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| Status | string | 实名认证状态 |
| Extend | string | 扩展字段 |
**使用示例**:
```go
resp, err := client.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
CardNo: "898608070422D0010269",
})
if err != nil {
return err
}
fmt.Printf("实名状态: %s\n", resp.Status)
```
---
### 4. 流量卡停机
对流量卡执行停机操作。
**方法**: `StopCard`
**请求参数**:
```go
type CardOperationReq struct {
CardNo string `json:"cardNo" validate:"required"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡号ICCID |
| Extend | string | ❌ | 扩展字段(广电国网特殊参数) |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.StopCard(ctx, &gateway.CardOperationReq{
CardNo: "898608070422D0010269",
})
if err != nil {
return err
}
fmt.Println("停机成功")
```
---
### 5. 流量卡复机
对流量卡执行复机操作。
**方法**: `StartCard`
**请求参数**:
```go
type CardOperationReq struct {
CardNo string `json:"cardNo" validate:"required"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡号ICCID |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.StartCard(ctx, &gateway.CardOperationReq{
CardNo: "898608070422D0010269",
})
if err != nil {
return err
}
fmt.Println("复机成功")
```
---
### 6. 获取实名认证跳转链接
获取流量卡实名认证的跳转链接。
**方法**: `GetRealnameLink`
**请求参数**:
```go
type CardStatusReq struct {
CardNo string `json:"cardNo" validate:"required"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡号ICCID |
**响应参数**:
```go
type RealnameLinkResp struct {
Link string `json:"link"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| Link | string | 实名认证跳转链接HTTPS URL |
| Extend | string | 扩展字段 |
**使用示例**:
```go
resp, err := client.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: "898608070422D0010269",
})
if err != nil {
return err
}
fmt.Printf("实名链接: %s\n", resp.Link)
```
---
### 7. 批量查询(预留)
批量查询流量卡信息(暂未实现)。
**方法**: `BatchQuery`
**请求参数**:
```go
type BatchQueryReq struct {
CardNos []string `json:"cardNos" validate:"required,min=1,max=100"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNos | []string | ✅ | 流量卡号列表最多100个 |
**响应参数**:
```go
type BatchQueryResp struct {
Results []CardStatusResp `json:"results"`
}
```
**状态**: ⚠️ 暂未实现,调用将返回错误
---
## 设备管理 API
### 1. 获取设备信息
通过卡号或设备 ID 查询设备的在线状态、信号强度、WiFi 信息等。
**方法**: `GetDeviceInfo`
**请求参数**:
```go
type DeviceInfoReq struct {
CardNo string `json:"cardNo,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | 二选一 | 流量卡号ICCID |
| DeviceID | string | 二选一 | 设备 ID/IMEI |
**响应参数**:
```go
type DeviceInfoResp struct {
IMEI string `json:"imei"`
OnlineStatus int `json:"onlineStatus"`
SignalLevel int `json:"signalLevel"`
WiFiSSID string `json:"wifiSsid,omitempty"`
WiFiEnabled int `json:"wifiEnabled"`
UploadSpeed int `json:"uploadSpeed"`
DownloadSpeed int `json:"downloadSpeed"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| IMEI | string | 设备 IMEI |
| OnlineStatus | int | 在线状态0:离线, 1:在线) |
| SignalLevel | int | 信号强度0-31 |
| WiFiSSID | string | WiFi 名称 |
| WiFiEnabled | int | WiFi 启用状态0:禁用, 1:启用) |
| UploadSpeed | int | 上行速率KB/s |
| DownloadSpeed | int | 下行速率KB/s |
| Extend | string | 扩展字段 |
**使用示例**:
```go
resp, err := client.GetDeviceInfo(ctx, &gateway.DeviceInfoReq{
DeviceID: "123456789012345",
})
if err != nil {
return err
}
fmt.Printf("设备状态: %s, 信号: %d\n",
map[int]string{0:"离线", 1:"在线"}[resp.OnlineStatus],
resp.SignalLevel,
)
```
---
### 2. 获取设备卡槽信息
查询设备的所有卡槽及其中的卡信息。
**方法**: `GetSlotInfo`
**请求参数**:
```go
type DeviceInfoReq struct {
CardNo string `json:"cardNo,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | 二选一 | 流量卡号ICCID |
| DeviceID | string | 二选一 | 设备 ID/IMEI |
**响应参数**:
```go
type SlotInfoResp struct {
IMEI string `json:"imei"`
Slots []SlotInfo `json:"slots"`
Extend string `json:"extend,omitempty"`
}
type SlotInfo struct {
SlotNo int `json:"slotNo"`
ICCID string `json:"iccid"`
CardStatus string `json:"cardStatus"`
IsActive int `json:"isActive"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| IMEI | string | 设备 IMEI |
| Slots | []SlotInfo | 卡槽信息列表 |
| SlotNo | int | 卡槽编号 |
| ICCID | string | 卡槽中的 ICCID |
| CardStatus | string | 卡状态(准备、正常、停机) |
| IsActive | int | 是否为当前使用的卡槽0:否, 1:是) |
**使用示例**:
```go
resp, err := client.GetSlotInfo(ctx, &gateway.DeviceInfoReq{
DeviceID: "123456789012345",
})
if err != nil {
return err
}
for _, slot := range resp.Slots {
fmt.Printf("卡槽%d: %s (%s)%s\n",
slot.SlotNo,
slot.ICCID,
slot.CardStatus,
map[int]string{0:"", 1:" [当前使用]"}[slot.IsActive],
)
}
```
---
### 3. 设置设备限速
设置设备的上行和下行速率限制。
**方法**: `SetSpeedLimit`
**请求参数**:
```go
type SpeedLimitReq struct {
DeviceID string `json:"deviceId" validate:"required"`
UploadSpeed int `json:"uploadSpeed" validate:"required,min=1"`
DownloadSpeed int `json:"downloadSpeed" validate:"required,min=1"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| DeviceID | string | ✅ | 设备 ID/IMEI |
| UploadSpeed | int | ✅ | 上行速率KB/s最小1 |
| DownloadSpeed | int | ✅ | 下行速率KB/s最小1 |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{
DeviceID: "123456789012345",
UploadSpeed: 100,
DownloadSpeed: 500,
})
if err != nil {
return err
}
fmt.Println("限速设置成功")
```
---
### 4. 设置设备 WiFi
设置设备的 WiFi 信息。
**方法**: `SetWiFi`
**请求参数**:
```go
type WiFiReq struct {
CardNo string `json:"cardNo" validate:"required"`
Params WiFiParams `json:"params" validate:"required"`
Extend string `json:"extend,omitempty"`
}
type WiFiParams struct {
SSIDName string `json:"ssidName" validate:"required,min=1,max=32"`
SSIDPassword string `json:"ssidPassword,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 设备 IMEI |
| Params | object | ✅ | WiFi 配置参数 |
| SSIDName | string | ✅ | WiFi 名称1-32字符 |
| SSIDPassword | string | ❌ | WiFi 密码 |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.SetWiFi(ctx, &gateway.WiFiReq{
CardNo: "123456789012345",
Params: gateway.WiFiParams{
SSIDName: "MyWiFi",
SSIDPassword: "password123",
},
})
if err != nil {
return err
}
fmt.Println("WiFi设置成功")
```
---
### 5. 设备切换卡
切换设备当前使用的卡到指定的目标卡。
**方法**: `SwitchCard`
**请求参数**:
```go
type SwitchCardReq struct {
DeviceID string `json:"deviceId" validate:"required"`
TargetICCID string `json:"targetIccid" validate:"required"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| DeviceID | string | ✅ | 设备 ID/IMEI |
| TargetICCID | string | ✅ | 目标卡 ICCID |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.SwitchCard(ctx, &gateway.SwitchCardReq{
DeviceID: "123456789012345",
TargetICCID: "898608070422D0010270",
})
if err != nil {
return err
}
fmt.Println("切换卡成功")
```
---
### 6. 设备恢复出厂设置
将设备恢复到出厂设置状态。
**方法**: `ResetDevice`
**请求参数**:
```go
type DeviceOperationReq struct {
DeviceID string `json:"deviceId" validate:"required"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| DeviceID | string | ✅ | 设备 ID/IMEI |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: "123456789012345",
})
if err != nil {
return err
}
fmt.Println("恢复出厂设置成功")
```
---
### 7. 设备重启
远程重启设备。
**方法**: `RebootDevice`
**请求参数**:
```go
type DeviceOperationReq struct {
DeviceID string `json:"deviceId" validate:"required"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| DeviceID | string | ✅ | 设备 ID/IMEI |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: "123456789012345",
})
if err != nil {
return err
}
fmt.Println("重启设备成功")
```
---
## 通用响应结构
所有 API 的底层响应都遵循统一的 Gateway 响应格式:
```go
type GatewayResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
TraceID string `json:"trace_id"`
}
```
| 参数 | 类型 | 说明 |
|------|------|------|
| Code | int | 业务状态码200 = 成功) |
| Msg | string | 业务提示信息 |
| Data | json.RawMessage | 业务数据(原始 JSON |
| TraceID | string | 链路追踪 ID |
**成功响应示例**:
```json
{
"code": 200,
"msg": "成功",
"data": {
"iccid": "898608070422D0010269",
"cardStatus": "正常"
},
"trace_id": "abc123xyz"
}
```
**失败响应示例**:
```json
{
"code": 404,
"msg": "卡号不存在",
"data": null,
"trace_id": "abc123xyz"
}
```
---
## 错误码说明
### Gateway 业务错误码
| 错误码 | 说明 | 解决方案 |
|-------|------|---------|
| 200 | 成功 | - |
| 400 | 请求参数错误 | 检查请求参数格式和内容 |
| 401 | 认证失败 | 检查 AppID 和 AppSecret |
| 404 | 资源不存在 | 检查卡号或设备 ID 是否正确 |
| 500 | 服务器内部错误 | 联系 Gateway 服务提供方 |
### 客户端错误码
客户端封装的统一错误码(`pkg/errors/codes.go`
| 错误码 | 常量 | 说明 |
|-------|------|------|
| 1110 | CodeGatewayError | Gateway 连接失败 |
| 1111 | CodeGatewayTimeout | Gateway 请求超时 |
| 1112 | CodeGatewayBusinessError | Gateway 业务错误 |
| 1113 | CodeGatewayInvalidResp | Gateway 响应解析失败 |
| 1114 | CodeGatewaySignError | Gateway 签名验证失败 |
---
## 请求流程
### 完整请求流程
```
1. 构造业务请求参数
2. 序列化为 JSON
3. AES-128-ECB 加密Base64 编码)
4. 生成 MD5 签名(参数排序 + appSecret
5. 构造最终请求体
{
"appId": "xxx",
"data": "encrypted_base64_string",
"sign": "md5_signature",
"timestamp": 1706620800000
}
6. POST 请求到 Gateway
7. 解析响应 JSON
8. 检查业务状态码
9. 解密并解析业务数据
10. 返回结果或错误
```
### 签名算法
```
1. 将请求参数按 key 字母序排序
2. 拼接为 key1=value1&key2=value2 格式
3. 末尾追加 &appSecret=xxx
4. 计算 MD5 哈希
5. 转为大写字符串
```
**示例**:
```
参数: {cardNo: "123", appId: "abc"}
排序: appId=abc&cardNo=123
追加: appId=abc&cardNo=123&appSecret=secret
MD5: D41D8CD98F00B204E9800998ECF8427E
```
---
## 相关文档
- [Gateway 客户端使用指南](./gateway-client-usage.md) - 详细的使用指南和最佳实践
- [错误处理指南](./003-error-handling/使用指南.md) - 统一错误处理规范

View File

@@ -1,547 +0,0 @@
# Gateway 客户端使用指南
## 概述
Gateway 客户端是对第三方 Gateway API 的 Go 封装,提供流量卡和设备管理的统一接口。客户端内置了 AES-128-ECB 加密、MD5 签名验证、HTTP 连接池管理等功能。
**核心特性**
- ✅ 自动加密/签名处理
- ✅ 统一错误处理
- ✅ HTTP Keep-Alive 连接池
- ✅ 可配置超时时间
- ✅ 完整的测试覆盖88.8%
## 配置说明
### 环境变量配置
Gateway 客户端通过环境变量配置,支持以下参数:
| 环境变量 | 说明 | 默认值 | 必填 |
|---------|------|--------|------|
| `JUNHONG_GATEWAY_BASE_URL` | Gateway API 基础 URL | - | ✅ |
| `JUNHONG_GATEWAY_APP_ID` | 应用 ID | - | ✅ |
| `JUNHONG_GATEWAY_APP_SECRET` | 应用密钥 | - | ✅ |
| `JUNHONG_GATEWAY_TIMEOUT` | 请求超时时间(秒) | 30 | ❌ |
### 配置示例
**开发环境** (`.env.local`):
```bash
export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
export JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
export JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
export JUNHONG_GATEWAY_TIMEOUT=30
```
**生产环境** (`docker-compose.yml`):
```yaml
services:
api:
environment:
- JUNHONG_GATEWAY_BASE_URL=https://gateway.prod.example.com
- JUNHONG_GATEWAY_APP_ID=${GATEWAY_APP_ID}
- JUNHONG_GATEWAY_APP_SECRET=${GATEWAY_APP_SECRET}
- JUNHONG_GATEWAY_TIMEOUT=60
```
## 使用示例
### 1. 基础用法
#### 获取客户端实例
Gateway 客户端在 Bootstrap 阶段自动初始化,通过依赖注入获取:
```go
package iot_card
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
type Service struct {
gatewayClient *gateway.Client
logger *zap.Logger
}
func New(gatewayClient *gateway.Client, logger *zap.Logger) *Service {
return &Service{
gatewayClient: gatewayClient,
logger: logger,
}
}
```
#### 查询流量卡状态
```go
func (s *Service) SyncCardStatus(ctx context.Context, iccid string) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
}
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
}
s.logger.Info("查询卡状态成功",
zap.String("iccid", resp.ICCID),
zap.String("status", resp.CardStatus),
)
return nil
}
```
### 2. 流量卡管理
#### 查询流量使用情况
```go
func (s *Service) GetFlowUsage(ctx context.Context, iccid string) (*gateway.FlowUsageResp, error) {
resp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{
CardNo: iccid,
})
if err != nil {
return nil, errors.Wrap(errors.CodeGatewayError, err, "查询流量失败")
}
return resp, nil
}
```
#### 流量卡停机
```go
func (s *Service) StopCard(ctx context.Context, iccid string) error {
err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
CardNo: iccid,
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "停机失败")
}
s.logger.Info("停机成功", zap.String("iccid", iccid))
return nil
}
```
#### 流量卡复机
```go
func (s *Service) StartCard(ctx context.Context, iccid string) error {
err := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{
CardNo: iccid,
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "复机失败")
}
s.logger.Info("复机成功", zap.String("iccid", iccid))
return nil
}
```
#### 查询实名认证状态
```go
func (s *Service) CheckRealnameStatus(ctx context.Context, iccid string) (string, error) {
resp, err := s.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
return "", errors.Wrap(errors.CodeGatewayError, err, "查询实名状态失败")
}
return resp.Status, nil
}
```
#### 获取实名认证链接
```go
func (s *Service) GetRealnameLink(ctx context.Context, iccid string) (string, error) {
resp, err := s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
return "", errors.Wrap(errors.CodeGatewayError, err, "获取实名链接失败")
}
return resp.Link, nil
}
```
### 3. 设备管理
#### 查询设备信息
```go
func (s *Service) GetDeviceInfo(ctx context.Context, imei string) (*gateway.DeviceInfoResp, error) {
resp, err := s.gatewayClient.GetDeviceInfo(ctx, &gateway.DeviceInfoReq{
DeviceID: imei,
})
if err != nil {
return nil, errors.Wrap(errors.CodeGatewayError, err, "查询设备信息失败")
}
return resp, nil
}
```
#### 查询设备卡槽信息
```go
func (s *Service) GetDeviceSlots(ctx context.Context, imei string) ([]gateway.SlotInfo, error) {
resp, err := s.gatewayClient.GetSlotInfo(ctx, &gateway.DeviceInfoReq{
DeviceID: imei,
})
if err != nil {
return nil, errors.Wrap(errors.CodeGatewayError, err, "查询卡槽信息失败")
}
return resp.Slots, nil
}
```
#### 设置设备限速
```go
func (s *Service) SetDeviceSpeed(ctx context.Context, imei string, uploadKBps, downloadKBps int) error {
err := s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{
DeviceID: imei,
UploadSpeed: uploadKBps,
DownloadSpeed: downloadKBps,
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "设置限速失败")
}
s.logger.Info("设置限速成功",
zap.String("imei", imei),
zap.Int("upload", uploadKBps),
zap.Int("download", downloadKBps),
)
return nil
}
```
#### 设置设备 WiFi
```go
func (s *Service) ConfigureWiFi(ctx context.Context, imei, ssid, password string) error {
err := s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
CardNo: imei,
Params: gateway.WiFiParams{
SSIDName: ssid,
SSIDPassword: password,
},
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "设置WiFi失败")
}
return nil
}
```
#### 设备切换卡
```go
func (s *Service) SwitchDeviceCard(ctx context.Context, imei, targetICCID string) error {
err := s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
DeviceID: imei,
TargetICCID: targetICCID,
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "切换卡失败")
}
s.logger.Info("切换卡成功",
zap.String("imei", imei),
zap.String("targetICCID", targetICCID),
)
return nil
}
```
#### 设备重启
```go
func (s *Service) RebootDevice(ctx context.Context, imei string) error {
err := s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "重启设备失败")
}
s.logger.Info("重启设备成功", zap.String("imei", imei))
return nil
}
```
#### 设备恢复出厂设置
```go
func (s *Service) ResetDevice(ctx context.Context, imei string) error {
err := s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
})
if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
}
s.logger.Info("恢复出厂设置成功", zap.String("imei", imei))
return nil
}
```
## 错误处理
### 统一错误码
Gateway 客户端使用统一的错误码系统(`pkg/errors/codes.go`
| 错误码 | 说明 |
|-------|------|
| `1110` | Gateway 连接失败 |
| `1111` | Gateway 请求超时 |
| `1112` | Gateway 业务错误 |
| `1113` | Gateway 响应解析失败 |
| `1114` | Gateway 签名验证失败 |
### 错误处理最佳实践
```go
func (s *Service) ProcessCard(ctx context.Context, iccid string) error {
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Error("查询卡状态失败",
zap.String("iccid", iccid),
zap.Error(err),
)
return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
}
return nil
}
```
### 错误分类处理
```go
func (s *Service) HandleGatewayError(err error) {
switch {
case strings.Contains(err.Error(), "超时"):
s.logger.Warn("Gateway 请求超时,请稍后重试")
case strings.Contains(err.Error(), "业务错误"):
s.logger.Error("Gateway 业务处理失败", zap.Error(err))
case strings.Contains(err.Error(), "连接失败"):
s.logger.Error("Gateway 连接失败,请检查网络", zap.Error(err))
default:
s.logger.Error("Gateway 未知错误", zap.Error(err))
}
}
```
## 高级用法
### 自定义超时时间
```go
client := gateway.NewClient(baseURL, appID, appSecret).
WithTimeout(60 * time.Second)
resp, err := client.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
```
### 使用扩展字段(广电国网)
部分 API 支持 `extend` 扩展字段用于特殊参数:
```go
err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
CardNo: iccid,
Extend: "special_param=value",
})
```
### Context 传递
所有 API 方法都支持 `context.Context`,可以传递超时、取消信号等:
```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
```
## 最佳实践
### 1. 空值检查
```go
func (s *Service) SafeCall(ctx context.Context) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
}
}
```
### 2. 日志记录
```go
func (s *Service) CallWithLogging(ctx context.Context, iccid string) error {
s.logger.Info("开始查询卡状态", zap.String("iccid", iccid))
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Error("查询失败", zap.String("iccid", iccid), zap.Error(err))
return err
}
s.logger.Info("查询成功",
zap.String("iccid", resp.ICCID),
zap.String("status", resp.CardStatus),
)
return nil
}
```
### 3. 重试机制
```go
func (s *Service) QueryWithRetry(ctx context.Context, iccid string) (*gateway.CardStatusResp, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err == nil {
return resp, nil
}
lastErr = err
s.logger.Warn("重试查询",
zap.Int("attempt", i+1),
zap.String("iccid", iccid),
zap.Error(err),
)
time.Sleep(time.Second * time.Duration(i+1))
}
return nil, errors.Wrap(errors.CodeGatewayError, lastErr, "重试失败")
}
```
### 4. 批量处理
```go
func (s *Service) BatchSyncStatus(ctx context.Context, iccids []string) error {
for _, iccid := range iccids {
if err := s.SyncCardStatus(ctx, iccid); err != nil {
s.logger.Error("同步失败", zap.String("iccid", iccid), zap.Error(err))
continue
}
}
return nil
}
```
## 测试
### 单元测试
Gateway 客户端提供完整的单元测试覆盖88.8%
```bash
go test -v ./internal/gateway
go test -cover ./internal/gateway
```
### 集成测试
使用 `-short` 标志跳过集成测试:
```bash
go test -v ./internal/gateway -short
```
运行集成测试(需要真实 Gateway 环境):
```bash
source .env.local && go test -v ./internal/gateway
```
## 故障排查
### 常见问题
#### 1. Gateway 客户端未配置
**错误**: `Gateway 客户端未配置`
**原因**: 环境变量未设置或 Bootstrap 初始化失败
**解决方案**:
```bash
export JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
export JUNHONG_GATEWAY_APP_ID=your_app_id
export JUNHONG_GATEWAY_APP_SECRET=your_app_secret
```
#### 2. 请求超时
**错误**: `Gateway 请求超时`
**原因**: 网络延迟或 Gateway 服务响应慢
**解决方案**:
```go
client := client.WithTimeout(60 * time.Second)
```
#### 3. 业务错误
**错误**: `业务错误: code=500, msg=xxx`
**原因**: Gateway 服务端业务逻辑错误
**解决方案**: 检查请求参数是否正确,查看日志中的 `TraceID` 联系 Gateway 服务提供方
#### 4. 签名验证失败
**错误**: `签名验证失败`
**原因**: `AppSecret` 配置错误
**解决方案**: 检查 `JUNHONG_GATEWAY_APP_SECRET` 环境变量是否正确
## 相关文档
- [Gateway API 参考](./gateway-api-reference.md) - 完整的 API 接口文档
- [错误处理指南](./003-error-handling/使用指南.md) - 统一错误处理规范
- [测试连接管理规范](./testing/test-connection-guide.md) - 测试规范和最佳实践

View File

@@ -0,0 +1,30 @@
# 支付宝接入契约
## 元数据
- Owner支付适配维护人
- 实现:`pkg/alipay/``github.com/smartwalle/alipay/v3 v3.2.29`
- 核验日期2026-08-07
- 环境:支付宝沙箱或生产商户;本文不保存凭证
## 当前实际使用范围
系统使用手机网站支付 `TradeWapPay` 生成签名支付 URL并处理异步通知未发现退款、转账或当面付调用。`payment.PaymentNo` 写入 `out_trade_no`,金额从分转为元字符串,产品码固定为 `QUICK_WAP_WAY`,有效期存在且晚于当前时间时写入 `time_expire`
## 配置、认证与关键字段
配置来自支付配置记录AppID、应用私钥、支付宝公钥、通知地址和返回地址。SDK 使用 RSA2 完成请求签名与通知验签。请求关键字段为 `notify_url``return_url``subject``out_trade_no``total_amount``product_code``time_expire`
通知只有 `trade_status=TRADE_SUCCESS``TRADE_FINISHED` 才进入成功处理;处理前还需匹配商户支付号、配置和金额。业务支付号承担幂等标识,重复通知由现有支付状态条件保护。
## 失败、超时与重试
配置不完整或 SDK 生成 URL 失败映射为项目支付配置错误;通知签名、金额或状态不符按回调失败处理。创建支付 URL 不自动重试避免重复业务意图通知是否重发由支付宝控制本系统必须保持回调幂等。SDK 超时未在本适配器单独覆盖。
## 安全与验证
私钥、公钥原文、完整通知报文和用户标识不得写入本文或普通日志。可复现静态证据:`pkg/alipay/wap.go``pkg/alipay/notify.go``internal/handler/callback/payment.go`。真实验收需在隔离商户完成下单、签名通知、错误金额和重复通知四个场景;本次 Context 重建不调用真实渠道。
官方参考:<https://opendocs.alipay.com/open/203/107091>
渠道版本、认证、字段、通知状态或重试语义变化时更新本文。

View File

@@ -0,0 +1,28 @@
# 富友接入契约
## 元数据
- Owner支付适配维护人
- 实现:`pkg/fuiou/`
- 核验日期2026-08-07
- 来源:当前代码和商户接口约定;仓库未保存可公开的版本号
## 当前实际使用范围
系统仅使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力。
## 配置、认证与传输
运行配置包含 API 地址、机构号、商户号、终端号、RSA 私钥、公钥及通知地址。请求先生成 XML再转换为 GBK并对请求参数做双重 URL 编码;请求和响应使用 RSA 签名/验签。除 `reserved` 外的请求字段即使为空也参与 XML 与签名。
关键请求字段包括 `mchnt_order_no``order_amt`(分)、`txn_begin_ts``notify_url``trade_type``sub_openid``sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
## 幂等、失败与重试
`mchnt_order_no` 是渠道业务幂等键;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
## 安全与验证
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go``pkg/fuiou/wxprecreate.go``pkg/fuiou/types.go``internal/handler/callback/payment.go`。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符和重复通知;本次不调用真实渠道。
端点、编码、签名字段、成功码或通知语义变化时更新本文。

View File

@@ -0,0 +1,30 @@
# Gateway 接入契约
## 元数据
- OwnerIoT Gateway 适配维护人
- 实现:`internal/gateway/`
- 核验日期2026-08-07
- 证据:`internal/gateway/client.go``crypto.go``card_status.go``flow_card.go``device.go`
## 当前实际使用范围
Gateway 是运营商流量卡、实名、停复机、限速和设备信息的统一封装入口。具体路径、请求字段和响应字段以同目录详细协议与 `internal/gateway/*.go` 的实际调用交集为准;文档中出现但代码未调用的接口不视为系统能力。
## 配置、认证与报文
配置键为 `gateway.base_url``gateway.app_id``gateway.app_secret``gateway.timeout`。业务参数先包装为 `{"params": ...}`,使用 AppSecret 做 AES-128-ECB 加密;外层请求含 `appId``data``sign``timestamp`,签名使用 MD5。HTTP 方法统一为 POST内容类型为 `application/json;charset=utf-8`。HTTP 200 且 Gateway `code=200` 才算成功,`data` 再按具体能力解码。
## 超时、重试与幂等
客户端默认超时 60 秒;生效配置可覆盖。默认最多重试 2 次,即最多 3 次尝试,退避为 100ms、200ms更多重试时封顶 300ms。仅客户端超时、连接和 DNS 等网络级错误重试;用户 Context 取消、HTTP 非 200、响应解析失败和 Gateway 业务码失败不重试。每次尝试重新生成时间戳与签名。
查询天然只读;停复机、限速等写操作的幂等和状态条件由调用它的业务 Service 承担Gateway 客户端本身不提供幂等键。
## 安全、错误与验证
AppSecret、加密前业务数据、完整身份标识不得写入本文或普通日志当前客户端存在请求结构日志运行环境必须依赖日志脱敏策略。Gateway 错误映射为项目 `CodeGatewayError``CodeGatewayTimeout``CodeGatewayInvalidResp`
可复现静态证据:`internal/gateway/client.go``internal/gateway/crypto.go` 及同目录能力文件。真实写操作可能改变卡或设备状态,本次只允许静态核对和隔离账号只读验证,不调用生产写接口。
协议、路径、认证、成功码、超时或重试分类变化时更新本文。

View File

@@ -0,0 +1,32 @@
# S3 兼容对象存储接入契约
## 元数据
- Owner基础设施维护人
- 实现:`pkg/storage/`
- SDK`github.com/aws/aws-sdk-go v1.55.5`
- 核验日期2026-08-07
## 当前实际使用范围
系统支持对象上传、下载、Head 元数据、删除、存在性判断,以及上传/下载预签名 URLProvider 当前只接受 `s3`。未发现版本管理、生命周期或跨区域复制调用。
## 配置、认证与字段
配置键为 `storage.provider``storage.s3.endpoint``region``bucket``access_key_id``secret_access_key``use_ssl``path_style`,以及 `storage.presign.upload_expires``download_expires``storage.temp_dir`。默认上传 URL 有效期 15 分钟,下载 URL 有效期 24 小时,运行配置可覆盖。
关键输入为对象 Key、Content-Type、Metadata 和可重复读取的数据流。预签名结果返回 URL、文件 Key 与过期时间;调用方必须保存 Key不应把临时 URL 当作永久标识。
## 幂等、失败与重试
相同 Key 的上传按 S3 覆盖语义执行,唯一性由业务层保证;删除遵循服务端删除语义,存在性通过 Head 判断。适配器未额外实现重试策略,实际网络重试遵循 AWS SDK 默认行为;写操作重试前必须确保输入可重复读取。
## 安全与验证
AccessKey、SecretKey、私有对象 URL 和敏感 Metadata 不进入文档或普通日志。预签名 URL 仅在所需最短期限内分发Bucket 权限、CORS 与生命周期由部署侧人工验收。
可复现静态证据:`pkg/storage/s3.go``pkg/storage/service.go``pkg/storage/types.go``pkg/config/config.go`。真实验收需使用隔离 Bucket 完成上传、Head、下载、删除及过期 URL 验证;本次不访问生产 Bucket。
官方参考:<https://docs.aws.amazon.com/AmazonS3/latest/API/Welcome.html>
SDK、端点兼容性、认证、对象语义或预签名期限变化时更新本文。

View File

@@ -0,0 +1,28 @@
# 短信网关接入契约
## 元数据
- Owner通知适配维护人
- 实现:`pkg/sms/`
- 协议版本:渠道 SMS HTTP 1.6;仓库仅保留当前实现所需字段摘要
- 核验日期2026-08-07
## 当前实际使用范围
系统使用 `POST <gateway_url>/sms/api/sendMessageMass` 批量发送短信;未发现状态回执查询或上行短信处理。
## 配置、认证与字段
配置键为 `sms.gateway_url``sms.username``sms.password``sms.signature``sms.timeout`。默认配置超时为 10 秒,运行配置可覆盖。客户端把短信签名拼在正文前,提交 `userName``content``phoneList`、毫秒时间戳和 `sign`。签名算法为小写 `MD5(username + timestamp + MD5(password))`
响应关键字段为 `code``message``msgId``smsCount`;只有代码中定义的 `CodeSuccess` 才视为提交成功,其他状态转换为 `SMSError` 并保留渠道码。
## 幂等、重试与安全
客户端不自动重试,也不生成业务幂等键;调用方需以通知业务标识防重,并在网络结果不确定时先判断是否允许重发。密码、签名原文、完整手机号列表和完整正文不得记录;当前实现只输出手机号列表和最多 50 个字符的内容预览,部署侧仍需日志脱敏。
## 验证
可复现静态证据:`pkg/sms/client.go``pkg/sms/types.go``pkg/config/config.go`。真实验收需使用测试号码核对单条/批量、错误签名、超时和重复发送;本次 Context 重建不发送真实短信。
协议版本、端点、认证、字段、成功码或重试语义变化时更新本文。

View File

@@ -0,0 +1,34 @@
# 微信生态接入契约
## 元数据
- Owner支付与微信生态维护人
- 实现:`pkg/wechat/`
- SDK`github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38`
- 核验日期2026-08-07
## 当前实际使用范围
系统使用公众号 OAuth、小程序 `code2session`、微信支付 JSAPI/H5 下单、查单、关单和支付通知。支付同时存在 PowerWeChat/v3 适配与仅配置 APIv2 Key 时使用的 v2 XML 适配;不得仅凭 SDK 文档推断其他微信能力。
## 端点、配置与关键字段
- 小程序:`GET https://api.weixin.qq.com/sns/jscode2session`,参数为 AppID、AppSecret、临时 code 和固定 `authorization_code`;响应必须含 OpenID、SessionKey可选 UnionID超时 10 秒。
- 支付 v2`POST https://api.mch.weixin.qq.com/pay/unifiedorder``/pay/orderquery`XML + MD5 签名关键字段为商户订单号、金额、OpenID、交易类型、通知地址和客户端 IP。
- 支付 v3/SDK使用 AppID、商户号、APIv3 Key、证书序列号、私钥及通知地址支持 JSAPI/H5、Query、Close 和 Notify。
配置来自支付配置记录不在本文列出具体值。v2 下单同时要求 `return_code=SUCCESS``result_code=SUCCESS`;查单以商户订单号关联本地支付。
## 回调、幂等、失败与重试
v2 按 API Key 验签v3 按平台证书验签并解密通知。商户订单号是渠道幂等标识;通知还需核对金额、配置和本地状态,并以状态条件更新避免重复入账。小程序 `errcode!=0`、关键字段缺失、HTTP/解析失败均映射统一微信错误。
适配器没有对创建支付做盲目自动重试;查单可由业务流程补偿,关单和回调按原商户订单号及状态保持幂等。
## 安全与验证
AppSecret、API Key、私钥、证书内容、SessionKey、完整 OpenID 和通知原文不得进入文档或普通日志。可复现静态证据:`pkg/wechat/miniapp.go``pkg/wechat/payment.go``pkg/wechat/payment_v2.go``internal/handler/callback/payment.go`。真实验收需隔离商户验证 JSAPI/H5、查关单、签名失败、金额不符与重复通知本次不调用真实渠道。
官方参考:<https://pay.weixin.qq.com/doc/v3/merchant/4012065342>
SDK、API 版本、认证、通知或重试语义变化时更新本文。

View File

@@ -0,0 +1,41 @@
# 企业微信接入契约
## 元数据
- Owner企业微信审批维护人
- 实现:`internal/infrastructure/wecom/`
- 官方来源:企业微信开发者中心对应接口页;仓库仅保留当前调用交集
- 核验日期2026-08-07
## 当前实际使用范围
系统使用 access_token、部门与成员简表、审批模板详情、审批提交、审批详情/列表、临时素材上传,以及加密回调后的权威状态同步。未在代码调用链出现的企业微信接口不视为系统能力。
## 端点与方法
- `GET /cgi-bin/gettoken`
- `GET /cgi-bin/department/list`
- `GET /cgi-bin/user/simplelist`
- `POST /cgi-bin/oa/gettemplatedetail`
- `POST /cgi-bin/oa/applyevent`
- `POST /cgi-bin/oa/getapprovaldetail`
- `POST /cgi-bin/oa/getapprovalinfo`
- `POST /cgi-bin/media/upload`
基础地址来自 `wecom.base_url`HTTP 超时来自 `wecom.timeout`,未配置时为 10 秒。CorpID 与应用 Secret 用于换取 access_token审批控件 ID、类型和选项 Key 必须来自当前模板详情,不能由本系统猜测。
## 回调、终态与补偿
回调入口校验企业微信签名并进行 AES 解密,再按审批单号获取权威详情。回调并非唯一事实来源:审批列表/详情查询用于轮询补偿。提交 Consumer 区分 `SafeToRetry`:附件准备、取 token、构造请求或调用前的安全失败可释放实例等待重试已无法确认是否提交成功的结果不得盲目重复创建审批。
业务以本地审批实例和企业微信审批单号去重,终态只推进一次;`errcode/errmsg` 保存到集成日志并映射项目错误,不能直接暴露凭证或底层报文。
## 安全与验证
Secret、access_token、回调 AES Key、成员敏感字段、审批正文与附件 URL 必须脱敏。可信 IP、应用可见范围、模板、回调 URL 和素材权限属于部署侧人工配置。
可复现静态证据:`internal/infrastructure/wecom/token_provider.go``directory_client.go``template_client.go``approval_submission_client.go``approval_detail_client.go``approval_info_client.go``approval_attachment_uploader.go``approval_submission_consumer.go`。真实验收需测试企业覆盖提交、加密回调、漏回调补偿、重复事件和未知提交结果;本次不访问真实企业。
官方参考:<https://developer.work.weixin.qq.com/document/path/91902>
端点、模板字段、认证、回调加密或补偿语义变化时更新本文。

View File

@@ -1,940 +0,0 @@
# IoT SIM 管理系统 - 分佣系统说明
## 概述
IoT SIM 管理系统实现了一套灵活的多级代理分佣体系,支持三种分佣模式(一次性分佣、长期分佣、组合分佣),支持阶梯奖励机制,支持自动解冻和手动审批,支持 OR 条件解冻逻辑。
---
## 分佣架构
### 多级代理树形结构
```
┌─────────────────────────────────────────────────────────┐
│ 平台(Platform) │
│ Level 0 │
└─────────────────────────────────────────────────────────┘
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 一级代理 A │ │ 一级代理 B │ │ 一级代理 C │
│ Level 1 │ │ Level 1 │ │ Level 1 │
│ Path: /A/ │ │ Path: /B/ │ │ Path: /C/ │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
├────┬────┐ ├────┬────┐
▼ ▼ ▼ ▼ ▼ ▼
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│A-1 │ │A-2 │ │A-3 │ │B-1 │ │B-2 │ │B-3 │
│L2 │ │L2 │ │L2 │ │L2 │ │L2 │ │L2 │
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘
```
**代理层级关系表**: `agent_hierarchies`
---
## 三种分佣模式
### 1. 一次性分佣 (One-time Commission)
**特点**: 订单完成后立即发放佣金
**适用场景**:
- 首次激活奖励
- 推广奖励
- 快速返佣
**示例**:
```
用户购买套餐 → 订单完成 → 立即发放佣金给上级代理
```
**配置示例**:
```sql
INSERT INTO commission_rules (
rule_name,
rule_type,
package_series_id,
commission_type,
commission_value,
status
) VALUES (
'一次性分佣-套餐激活奖励',
'one_time',
1, -- 套餐系列 ID
'fixed', -- 固定金额
10.00, -- 10元
1 -- 启用
);
```
**业务流程**:
```
订单创建 → 订单支付 → 订单完成
└─→ 创建分佣记录 (status=1 待发放)
└─→ 自动发放 (status=2 已发放)
```
---
### 2. 长期分佣 (Long-term Commission)
**特点**: 订单完成后冻结佣金,满足解冻条件后发放
**适用场景**:
- 续费奖励
- 留存奖励
- 长期激励
**解冻条件**:
- **时间条件**: 冻结 N 天后自动解冻
- **流量条件**: IoT 卡累计使用 M MB 流量后解冻
- **OR 逻辑**: 时间到期 **OR** 流量达标,满足任一条件即可解冻
**示例**:
```
用户购买套餐 → 订单完成 → 冻结佣金 (30天或1GB流量)
时间到期 OR 流量达标
自动解冻发放
```
**配置示例**:
```sql
INSERT INTO commission_rules (
rule_name,
rule_type,
package_series_id,
commission_type,
commission_value,
freeze_days,
freeze_data_mb,
unfreeze_mode,
status
) VALUES (
'长期分佣-续费奖励',
'long_term',
1,
'percentage', -- 百分比
0.10, -- 10%
30, -- 冻结30天
1024, -- 或使用1GB流量
'auto', -- 自动解冻
1
);
```
**业务流程**:
```
订单创建 → 订单支付 → 订单完成
└─→ 创建分佣记录 (status=3 已冻结)
├─→ 时间检查: 30天后 → 自动解冻 (status=2 已发放)
└─→ 流量检查: 使用1GB流量后 → 自动解冻 (status=2 已发放)
```
**解冻条件数据结构**:
```json
{
"time_based": {
"days": 30,
"deadline": "2025-02-10T00:00:00Z"
},
"data_based": {
"data_mb": 1024,
"iot_card_id": 12345
}
}
```
---
### 3. 组合分佣 (Combined Commission)
**特点**: 同时包含一次性分佣和长期分佣,订单完成后部分立即发放,部分冻结
**适用场景**:
- 首充奖励(立即发放) + 留存奖励(冻结发放)
- 灵活激励机制
**示例**:
```
用户购买套餐 → 订单完成 → 立即发放 5元 + 冻结 10元 (30天后发放)
```
**配置示例**:
```sql
-- 1. 创建组合分佣规则
INSERT INTO commission_rules (
rule_name,
rule_type,
package_series_id,
status
) VALUES (
'组合分佣-首充+留存',
'combined',
1,
1
);
-- 2. 配置一次性条件
INSERT INTO commission_combined_conditions (
rule_id,
condition_type,
commission_type,
commission_value
) VALUES (
1, -- 上面创建的规则 ID
'one_time',
'fixed',
5.00 -- 立即发放 5元
);
-- 3. 配置长期条件
INSERT INTO commission_combined_conditions (
rule_id,
condition_type,
commission_type,
commission_value,
freeze_days,
freeze_data_mb
) VALUES (
1,
'long_term',
'fixed',
10.00, -- 冻结 10元
30, -- 30天
1024 -- 或1GB流量
);
```
**业务流程**:
```
订单创建 → 订单支付 → 订单完成
├─→ 创建一次性分佣记录 (status=1 待发放) → 立即发放 (status=2)
└─→ 创建长期分佣记录 (status=3 已冻结) → 满足条件后解冻
```
---
## 阶梯奖励机制
### 阶梯奖励说明
阶梯奖励允许根据订单数量设置不同的分佣标准,订单数量越多,分佣越高。
**示例配置**:
```
1-10 单: 10元/单
11-50 单: 15元/单
51+ 单: 20元/单
```
### 配置示例
```sql
-- 1. 创建支持阶梯的分佣规则
INSERT INTO commission_rules (
rule_name,
rule_type,
package_series_id,
enable_ladder,
status
) VALUES (
'阶梯分佣-月度订单量',
'one_time',
1,
true, -- 启用阶梯
1
);
-- 2. 配置阶梯奖励
INSERT INTO commission_ladder (rule_id, min_quantity, max_quantity, commission_type, commission_value) VALUES
(1, 1, 10, 'fixed', 10.00),
(1, 11, 50, 'fixed', 15.00),
(1, 51, NULL, 'fixed', 20.00); -- NULL 表示无上限
```
### 阶梯计算逻辑
```go
// 伪代码
func CalculateLadderCommission(agentID uint, ruleID uint, currentMonth string) float64 {
// 1. 查询阶梯配置
ladders := db.FindCommissionLadders(ruleID)
// 2. 统计当月订单数量
orderCount := db.CountOrders(agentID, currentMonth)
// 3. 匹配阶梯
for _, ladder := range ladders {
if orderCount >= ladder.MinQuantity &&
(ladder.MaxQuantity == nil || orderCount <= ladder.MaxQuantity) {
if ladder.CommissionType == "fixed" {
return ladder.CommissionValue
} else if ladder.CommissionType == "percentage" {
orderAmount := db.GetOrderAmount(orderID)
return orderAmount * ladder.CommissionValue
}
}
}
return 0
}
```
---
## 分佣计算方式
### 1. 固定金额 (Fixed)
**说明**: 每笔订单固定分佣 N 元
**示例**:
```sql
commission_type = 'fixed'
commission_value = 10.00
```
**计算公式**:
```
分佣金额 = commission_value = 10.00 元
```
---
### 2. 百分比 (Percentage)
**说明**: 按订单金额的 N% 分佣
**示例**:
```sql
commission_type = 'percentage'
commission_value = 0.10 -- 10%
```
**计算公式**:
```
分佣金额 = 订单金额 × commission_value
= 100.00 × 0.10
= 10.00 元
```
---
## 分佣记录表
### 表结构: `commission_records`
分佣记录表记录每笔分佣的详细信息:
```sql
CREATE TABLE commission_records (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
agent_id BIGINT NOT NULL,
rule_id BIGINT NOT NULL,
commission_type VARCHAR(50) NOT NULL,
commission_amount DECIMAL(10,2) NOT NULL,
status INT NOT NULL DEFAULT 1,
freeze_days INT DEFAULT 0,
freeze_data_mb BIGINT DEFAULT 0,
unfreeze_conditions JSONB,
unfrozen_at TIMESTAMPTZ,
distributed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 状态说明
| status | 状态 | 说明 |
|--------|------|------|
| 1 | 待发放 | 一次性分佣,等待发放 |
| 2 | 已发放 | 已发放到代理账户 |
| 3 | 已冻结 | 长期分佣,冻结中 |
| 4 | 已取消 | 订单取消或退款,分佣取消 |
---
## OR 条件解冻逻辑
### 解冻条件设计
长期分佣支持 **OR 条件解冻**,即时间到期 **OR** 流量达标,满足任一条件即可自动解冻。
**解冻条件数据结构**:
```json
{
"time_based": {
"days": 30,
"deadline": "2025-02-10T00:00:00Z"
},
"data_based": {
"data_mb": 1024,
"iot_card_id": 12345
}
}
```
### 解冻检查逻辑
```go
// 伪代码
func CheckUnfreezeConditions(record *CommissionRecord) bool {
var conditions struct {
TimeBased struct {
Days int `json:"days"`
Deadline time.Time `json:"deadline"`
} `json:"time_based"`
DataBased struct {
DataMB int64 `json:"data_mb"`
IotCardID uint `json:"iot_card_id"`
} `json:"data_based"`
}
json.Unmarshal(record.UnfreezeConditions, &conditions)
// 检查时间条件
if time.Now().After(conditions.TimeBased.Deadline) {
return true // 时间到期,可以解冻
}
// 检查流量条件
if conditions.DataBased.IotCardID > 0 {
card := db.FindIotCardByID(conditions.DataBased.IotCardID)
if card.DataUsageMB >= conditions.DataBased.DataMB {
return true // 流量达标,可以解冻
}
}
return false // 条件均未满足
}
```
### 自动解冻定时任务
```go
// 伪代码
func UnfreezeCommissionTask() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
// 查询所有冻结中的分佣记录
records := db.FindCommissionRecords("status = 3")
for _, record := range records {
if CheckUnfreezeConditions(&record) {
// 解冻
record.Status = 2 // 已发放
record.UnfrozenAt = time.Now()
record.DistributedAt = time.Now()
db.Save(&record)
// 发放到代理账户
DistributeCommission(record.AgentID, record.CommissionAmount)
logger.Info("分佣解冻成功",
zap.Uint("record_id", record.ID),
zap.Uint("agent_id", record.AgentID),
zap.Float64("amount", record.CommissionAmount),
)
}
}
}
}
```
---
## 分佣审批流程
### 自动审批 vs 手动审批
分佣规则的 `unfreeze_mode` 字段控制解冻模式:
- **auto**: 自动解冻,满足条件后自动发放
- **manual**: 手动审批,需要人工审核通过后才能发放
### 手动审批流程
```
订单完成 → 创建分佣记录 (status=3 已冻结)
满足解冻条件
创建审批记录 (approval_status=1 待审批)
审批人审核
├─→ 通过 (approval_status=2) → 发放佣金 (status=2 已发放)
└─→ 拒绝 (approval_status=3) → 取消分佣 (status=4 已取消)
```
### 审批表: `commission_approvals`
```sql
CREATE TABLE commission_approvals (
id BIGSERIAL PRIMARY KEY,
commission_record_id BIGINT UNIQUE NOT NULL,
agent_id BIGINT NOT NULL,
approval_status INT NOT NULL DEFAULT 1,
approver_id BIGINT,
approval_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 审批状态
| approval_status | 状态 | 说明 |
|----------------|------|------|
| 1 | 待审批 | 等待审批人审核 |
| 2 | 已通过 | 审批通过,发放佣金 |
| 3 | 已拒绝 | 审批拒绝,取消分佣 |
---
## 分佣模板
### 模板设计
分佣模板用于快速创建分佣规则,避免重复配置。
**表结构**: `commission_templates`
```sql
CREATE TABLE commission_templates (
id BIGSERIAL PRIMARY KEY,
template_name VARCHAR(255) NOT NULL,
template_data JSONB NOT NULL,
description TEXT,
status INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 模板数据格式
```json
{
"rule_type": "combined",
"package_series_id": 1,
"conditions": [
{
"condition_type": "one_time",
"commission_type": "fixed",
"commission_value": 5.00
},
{
"condition_type": "long_term",
"commission_type": "fixed",
"commission_value": 10.00,
"freeze_days": 30,
"freeze_data_mb": 1024
}
]
}
```
### 使用模板创建规则
```go
// 伪代码
func CreateRuleFromTemplate(templateID uint, seriesID uint) error {
template := db.FindTemplateByID(templateID)
var data struct {
RuleType string `json:"rule_type"`
PackageSeriesID uint `json:"package_series_id"`
Conditions []struct {
ConditionType string `json:"condition_type"`
CommissionType string `json:"commission_type"`
CommissionValue float64 `json:"commission_value"`
FreezeDays int `json:"freeze_days"`
FreezeDataMB int64 `json:"freeze_data_mb"`
} `json:"conditions"`
}
json.Unmarshal(template.TemplateData, &data)
// 创建分佣规则
rule := CommissionRule{
RuleName: template.TemplateName,
RuleType: data.RuleType,
PackageSeriesID: seriesID,
Status: 1,
}
db.Create(&rule)
// 创建组合条件
for _, cond := range data.Conditions {
condition := CommissionCombinedCondition{
RuleID: rule.ID,
ConditionType: cond.ConditionType,
CommissionType: cond.CommissionType,
CommissionValue: cond.CommissionValue,
FreezeDays: cond.FreezeDays,
FreezeDataMB: cond.FreezeDataMB,
}
db.Create(&condition)
}
return nil
}
```
---
## 运营商结算
### 结算表: `carrier_settlements`
记录与运营商的月度结算情况:
```sql
CREATE TABLE carrier_settlements (
id BIGSERIAL PRIMARY KEY,
carrier_id BIGINT NOT NULL,
settlement_month VARCHAR(7) NOT NULL,
total_orders INT DEFAULT 0,
total_amount DECIMAL(10,2) DEFAULT 0,
settlement_status INT NOT NULL DEFAULT 1,
settled_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 结算状态
| settlement_status | 状态 | 说明 |
|------------------|------|------|
| 1 | 待结算 | 月度未结束 |
| 2 | 已结算 | 已统计金额 |
| 3 | 已支付 | 已支付给运营商 |
### 月度结算流程
```
每月1号 → 统计上月订单数据
创建结算记录 (settlement_status=1 待结算)
财务审核
确认结算 (settlement_status=2 已结算)
支付运营商 (settlement_status=3 已支付)
```
### 结算计算逻辑
```go
// 伪代码
func GenerateCarrierSettlement(carrierID uint, month string) error {
// 1. 统计上月订单
orders := db.FindOrders("carrier_id = ? AND DATE_FORMAT(completed_at, '%Y-%m') = ?", carrierID, month)
totalOrders := len(orders)
totalAmount := 0.0
for _, order := range orders {
totalAmount += order.Amount
}
// 2. 创建结算记录
settlement := CarrierSettlement{
CarrierID: carrierID,
SettlementMonth: month,
TotalOrders: totalOrders,
TotalAmount: totalAmount,
SettlementStatus: 1, // 待结算
}
db.Create(&settlement)
return nil
}
```
---
## 提现管理
### 提现申请表: `commission_withdrawal_requests`
```sql
CREATE TABLE commission_withdrawal_requests (
id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL,
withdrawal_amount DECIMAL(10,2) NOT NULL,
withdrawal_method VARCHAR(20) NOT NULL,
account_info JSONB NOT NULL,
status INT NOT NULL DEFAULT 1,
reviewer_id BIGINT,
review_reason TEXT,
reviewed_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 提现状态
| status | 状态 | 说明 |
|--------|------|------|
| 1 | 待审核 | 等待审核 |
| 2 | 已通过 | 审核通过,等待打款 |
| 3 | 已拒绝 | 审核拒绝 |
| 4 | 已打款 | 已打款到账户 |
| 5 | 已取消 | 用户取消 |
### 提现流程
```
代理提交提现申请 (status=1 待审核)
财务审核
├─→ 通过 (status=2 已通过) → 打款 (status=4 已打款)
└─→ 拒绝 (status=3 已拒绝)
```
### 提现设置表: `commission_withdrawal_settings`
```sql
CREATE TABLE commission_withdrawal_settings (
id BIGSERIAL PRIMARY KEY,
agent_id BIGINT UNIQUE NOT NULL,
min_withdrawal_amount DECIMAL(10,2) DEFAULT 0,
max_withdrawal_amount DECIMAL(10,2) DEFAULT 0,
withdrawal_fee_rate DECIMAL(5,4) DEFAULT 0,
auto_approval_enabled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 提现规则检查
```go
// 伪代码
func ValidateWithdrawalRequest(agentID uint, amount float64) error {
setting := db.FindWithdrawalSetting(agentID)
// 检查最小金额
if amount < setting.MinWithdrawalAmount {
return fmt.Errorf("提现金额不能低于 %.2f 元", setting.MinWithdrawalAmount)
}
// 检查最大金额
if setting.MaxWithdrawalAmount > 0 && amount > setting.MaxWithdrawalAmount {
return fmt.Errorf("提现金额不能高于 %.2f 元", setting.MaxWithdrawalAmount)
}
// 检查账户余额
balance := db.GetAgentBalance(agentID)
fee := amount * setting.WithdrawalFeeRate
totalAmount := amount + fee
if balance < totalAmount {
return fmt.Errorf("余额不足,需要 %.2f 元(含手续费 %.2f 元)", totalAmount, fee)
}
return nil
}
```
---
## 分佣业务流程示例
### 示例 1: 一次性分佣
```
1. 用户购买套餐(100元)
2. 订单完成
3. 触发分佣计算
- 规则: 一次性分佣,固定金额 10元
- 创建分佣记录: agent_id=123, commission_amount=10.00, status=1 待发放
4. 自动发放
- 更新分佣记录: status=2 已发放, distributed_at=NOW()
- 更新代理账户余额: balance += 10.00
5. 完成
```
---
### 示例 2: 长期分佣(OR 条件解冻)
```
1. 用户购买套餐(100元)
2. 订单完成
3. 触发分佣计算
- 规则: 长期分佣,10%,冻结30天 OR 使用1GB流量
- 创建分佣记录: agent_id=123, commission_amount=10.00, status=3 已冻结
- 解冻条件: {"time_based": {"days": 30}, "data_based": {"data_mb": 1024}}
4. 定时任务检查解冻条件
- 时间检查: 30天后 → 满足条件 → 解冻
- 流量检查: 使用1GB流量后 → 满足条件 → 解冻
5. 自动解冻
- 更新分佣记录: status=2 已发放, unfrozen_at=NOW(), distributed_at=NOW()
- 更新代理账户余额: balance += 10.00
6. 完成
```
---
### 示例 3: 组合分佣
```
1. 用户购买套餐(100元)
2. 订单完成
3. 触发分佣计算
- 规则: 组合分佣
- 一次性条件: 固定金额 5元
- 长期条件: 固定金额 10元,冻结30天
4. 创建两条分佣记录
- 记录1: agent_id=123, commission_amount=5.00, status=1 待发放
- 记录2: agent_id=123, commission_amount=10.00, status=3 已冻结
5. 立即发放一次性分佣
- 记录1: status=2 已发放
- 代理账户余额: balance += 5.00
6. 30天后自动解冻长期分佣
- 记录2: status=2 已发放
- 代理账户余额: balance += 10.00
7. 完成
```
---
## 监控和统计
### 分佣统计指标
1. **代理分佣总额**
- 待发放金额
- 已发放金额
- 已冻结金额
2. **分佣发放效率**
- 平均发放时长
- 平均解冻时长
3. **提现统计**
- 提现申请数量
- 提现成功率
- 提现金额统计
### SQL 查询示例
```sql
-- 1. 代理分佣总额统计
SELECT
agent_id,
SUM(CASE WHEN status = 1 THEN commission_amount ELSE 0 END) AS pending_amount,
SUM(CASE WHEN status = 2 THEN commission_amount ELSE 0 END) AS distributed_amount,
SUM(CASE WHEN status = 3 THEN commission_amount ELSE 0 END) AS frozen_amount
FROM commission_records
WHERE agent_id = 123
GROUP BY agent_id;
-- 2. 月度分佣统计
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS total_records,
SUM(commission_amount) AS total_amount
FROM commission_records
WHERE agent_id = 123
AND status = 2
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month DESC;
-- 3. 提现统计
SELECT
status,
COUNT(*) AS request_count,
SUM(withdrawal_amount) AS total_amount
FROM commission_withdrawal_requests
WHERE agent_id = 123
GROUP BY status;
```
---
## 最佳实践
### 1. 合理设置冻结条件
- **过短**: 可能导致代理流失
- **过长**: 影响代理积极性
- **建议**: 根据业务特点和用户留存数据设置合理的冻结期
### 2. 使用 OR 条件解冻
- **优势**: 提高解冻灵活性,代理满足任一条件即可获得佣金
- **示例**: 30天 OR 1GB流量,满足其一即可解冻
### 3. 启用阶梯奖励
- **优势**: 激励代理提高订单量
- **示例**: 月订单量越多,单笔佣金越高
### 4. 定期审查分佣规则
- 定期分析分佣数据,优化分佣规则
- 根据代理反馈调整冻结条件和佣金比例
---
## 总结
IoT SIM 管理系统的分佣系统具有以下特点:
1. **三种分佣模式**: 一次性分佣、长期分佣、组合分佣
2. **阶梯奖励机制**: 支持根据订单数量设置不同的分佣标准
3. **OR 条件解冻**: 时间到期 OR 流量达标,满足任一条件即可解冻
4. **自动 + 手动审批**: 支持自动解冻和手动审批两种模式
5. **分佣模板**: 快速创建分佣规则,避免重复配置
6. **运营商结算**: 记录与运营商的月度结算情况
7. **提现管理**: 完善的提现申请和审批流程
8. **多级代理**: 支持无限层级的代理树形结构
通过灵活配置和使用分佣系统,可以激励代理积极性,提高销售业绩,实现平台与代理的双赢。
---
**文档版本**: v1.0
**最后更新**: 2026-01-12
**维护人员**: Claude Sonnet 4.5

View File

@@ -1,491 +0,0 @@
# IoT SIM 管理系统 - 数据模型层实施总结
## 项目信息
- **项目名称**: IoT SIM 管理系统 - 数据模型层
- **实施日期**: 2026-01-12
- **实施人员**: Claude Sonnet 4.5
- **OpenSpec 变更 ID**: iot-sim-management
---
## 实施范围
本次实施严格按照 OpenSpec 规范,仅完成数据模型层的实现,包括:
### ✅ 已完成
1. **数据库迁移脚本**
- UP 迁移脚本 (000005_create_iot_sim_management_tables.up.sql)
- DOWN 迁移脚本 (000005_create_iot_sim_management_tables.down.sql)
- 26 张数据库表
- 完整的索引定义
- 中文注释
- 三大运营商初始数据
2. **GORM 模型定义**
- 11 个模型文件
- 26 个模型结构体
- 遵循项目规范的字段定义
- 无外键约束,无 ORM 关联
3. **业务常量定义**
- 100+ 业务常量
- 5 大分类
- 统一常量管理
4. **代码质量保证**
- `go fmt` 格式化
- `goimports` 导入整理
- `golangci-lint` 质量检查(0 issues)
- `go build` 编译通过
5. **数据库迁移测试**
- UP 迁移成功 (616ms)
- DOWN 迁移成功 (602ms)
- 版本切换正确
6. **完整文档**
- 数据模型总结.md
- 表结构详细说明.md
- 轮询机制说明.md
- 分佣系统说明.md
- 实施总结.md (本文档)
### ❌ 不在本阶段范围
根据 OpenSpec 规范,以下工作不在本阶段实施范围:
- API Handler 层
- Service 业务逻辑层
- Store 数据访问层
- 单元测试
- 集成测试
- API 文档生成
---
## 数据库表清单
### 核心业务表 (4张)
| 表名 | 说明 | 记录数 |
|------|------|--------|
| carriers | 运营商 | 3 (预置) |
| iot_cards | IoT 卡 | 0 |
| devices | 设备 | 0 |
| number_cards | 号卡 | 0 |
### 套餐与流量管理表 (7张)
| 表名 | 说明 | 记录数 |
|------|------|--------|
| package_series | 套餐系列 | 0 |
| packages | 套餐 | 0 |
| agent_package_allocations | 代理套餐分配 | 0 |
| device_sim_bindings | 设备-IoT卡绑定 | 0 |
| package_usages | 套餐使用情况 | 0 |
| polling_configs | 轮询配置 | 0 |
| data_usage_records | 流量使用记录 | 0 |
### 订单管理表 (1张)
| 表名 | 说明 | 记录数 |
|------|------|--------|
| orders | 订单 | 0 |
### 分佣系统表 (8张)
| 表名 | 说明 | 记录数 |
|------|------|--------|
| agent_hierarchies | 代理层级关系 | 0 |
| commission_rules | 分佣规则 | 0 |
| commission_ladder | 分佣阶梯 | 0 |
| commission_combined_conditions | 组合分佣条件 | 0 |
| commission_records | 分佣记录 | 0 |
| commission_approvals | 分佣审批 | 0 |
| commission_templates | 分佣模板 | 0 |
| carrier_settlements | 运营商结算 | 0 |
### 财务管理表 (3张)
| 表名 | 说明 | 记录数 |
|------|------|--------|
| commission_withdrawal_requests | 提现申请 | 0 |
| commission_withdrawal_settings | 提现设置 | 0 |
| payment_merchant_settings | 收款商户设置 | 0 |
### 系统管理表 (2张)
| 表名 | 说明 | 记录数 |
|------|------|--------|
| dev_capability_configs | 开发能力配置 | 0 |
| card_replacement_requests | 换卡申请 | 0 |
**总计**: 26 张表
---
## 文件清单
### 数据库迁移脚本
```
migrations/
├── 000005_create_iot_sim_management_tables.up.sql (1102 行)
└── 000005_create_iot_sim_management_tables.down.sql (27 行)
```
### GORM 模型文件
```
internal/iot/model/
├── carrier.go (17 行)
├── iot_card.go (40 行)
├── device.go (27 行)
├── number_card.go (26 行)
├── package.go (108 行)
├── order.go (36 行)
├── polling.go (29 行)
├── data_usage.go (20 行)
├── commission.go (175 行)
├── financial.go (70 行)
└── system.go (46 行)
```
**总计**: 11 个文件, 594 行代码
### 常量定义文件
```
pkg/constants/
└── iot.go (164 行)
```
### 文档文件
```
docs/iot-sim-management/
├── 数据模型总结.md
├── 表结构详细说明.md
├── 轮询机制说明.md
├── 分佣系统说明.md
└── 实施总结.md (本文档)
```
---
## 核心技术特性
### 1. 无外键约束设计
- 数据库表之间没有外键约束
- 关联关系通过存储关联 ID 字段手动维护
- 提高灵活性和性能
- 便于分布式扩展
### 2. GORM 模型规范
- 所有字段显式指定 `column:` 标签
- 禁止使用 ORM 关联关系
- 所有字段添加中文注释
- 字符串字段明确长度
- 数值字段明确精度
### 3. 多所有者模式
- `owner_type` + `owner_id` 实现多态所有权
- 支持 platform/agent/user/device 四种所有者类型
- 灵活管理资源所有权
### 4. 三层轮询机制
- 实名检查进程
- 卡流量检查进程
- 套餐流量检查进程
- 支持梯度轮询策略
- 独立的轮询配置
### 5. 三种分佣模式
- 一次性分佣 (立即发放)
- 长期分佣 (冻结后发放)
- 组合分佣 (部分立即,部分冻结)
- OR 条件解冻 (时间 OR 流量)
- 阶梯奖励机制
### 6. 真流量/虚流量共存
- 真流量额度 (`real_data_mb`)
- 虚流量额度 (`virtual_data_mb`)
- 总流量额度 (`data_amount_mb`)
- 停机判断基于虚流量
### 7. 行业卡 vs 普通卡
- 行业卡无需实名认证
- 普通卡必须实名才能激活
- 通过 `card_category` 字段区分
---
## 代码质量指标
### 编译和格式化
-`go fmt` 格式化通过
-`goimports` 导入整理通过
-`go build` 编译通过
-`go mod tidy` 依赖管理通过
### 静态分析
-`golangci-lint run` 质量检查通过
- ✅ 0 issues
- ✅ 无语法错误
- ✅ 无命名冲突
### 数据库迁移测试
- ✅ UP 迁移成功 (耗时 616ms)
- ✅ DOWN 迁移成功 (耗时 602ms)
- ✅ 版本切换正确 (4 → 5 → 4 → 5)
- ✅ 表结构验证通过
---
## 解决的问题
### 1. 常量命名冲突
**问题**: 发现订单状态常量在 `constants.go``iot.go` 中重复定义
**解决方案**:
- 将 IoT 模块的订单状态常量重命名为 `IotOrderStatus*`
- 避免与现有常量冲突
- 保持常量命名的一致性
### 2. 依赖管理
**问题**: `github.com/lib/pq` 应该作为直接依赖
**解决方案**:
- 运行 `go mod tidy` 添加直接依赖
- 确保所有依赖正确管理
---
## 关键决策记录
### 1. 字段命名规范
**决策**: 所有 GORM 模型字段必须显式指定 `column:` 标签
**理由**:
- 明确 Go 字段名和数据库字段名的映射关系
- 避免 GORM 自动转换可能带来的歧义
- 提高代码可读性和可维护性
### 2. 无外键约束
**决策**: 数据库表之间不建立外键约束
**理由**:
- 提高灵活性,业务逻辑完全在代码中控制
- 提高性能,无数据库层面的引用完整性检查开销
- 简化数据库 schema,迁移更容易
- 分布式友好,便于后续微服务拆分
### 3. 轮询机制设计
**决策**: 实现三层独立的轮询机制
**理由**:
- 实名检查、卡流量检查、套餐流量检查业务逻辑独立
- 可以针对不同场景设置不同的轮询间隔
- 提高系统可维护性和可扩展性
### 4. 分佣系统 OR 条件解冻
**决策**: 长期分佣支持时间 OR 流量两种解冻条件
**理由**:
- 提高解冻灵活性
- 代理满足任一条件即可获得佣金
- 提高代理积极性和用户留存率
---
## 性能考虑
### 索引设计
1. **主键索引**: 所有表的 `id` 字段
2. **唯一索引**:
- `iccid` (IoT 卡唯一标识)
- `order_no` (订单号)
- `carrier_code` (运营商编码)
等唯一字段
3. **组合索引**:
- `(carrier_id, status)` - IoT 卡查询优化
- `(owner_type, owner_id)` - 所有权查询优化
- `(status, created_at)` - 订单列表查询优化
4. **单列索引**:
- 外键字段
- 常用查询字段
### 查询优化建议
1. **避免 N+1 查询**: 在业务层使用批量查询
2. **分页查询**: 列表查询必须分页,避免一次性加载大量数据
3. **异步任务**: 使用 Asynq 处理轮询任务,避免阻塞主线程
4. **缓存策略**: 运营商信息、套餐信息等静态数据可以缓存
---
## 安全考虑
### 1. 数据脱敏
- 敏感信息(API 凭证、账户信息)使用 JSONB 存储
- 业务层需要实现加密/解密逻辑
### 2. 权限控制
- 数据权限通过 `owner_type` + `owner_id` 实现
- 业务层需要实现权限检查逻辑
### 3. 审计日志
- 所有表包含 `created_at``updated_at` 时间戳
- 关键操作(分佣审批、提现审批)记录审批人 ID
---
## 后续工作建议
根据 OpenSpec 规范,数据模型层完成后,建议按以下顺序进行后续开发:
### 1. Store 数据访问层
- 实现 GORM 数据访问接口
- 实现事务管理
- 实现查询优化
### 2. Service 业务逻辑层
- 实现核心业务逻辑
- 实现轮询调度器
- 实现分佣计算引擎
- 实现运营商 Gateway 对接
### 3. Handler API 层
- 实现 RESTful API 接口
- 实现参数验证
- 实现错误处理
### 4. 测试
- 单元测试
- 集成测试
- 性能测试
### 5. 文档
- API 文档
- 部署文档
- 运维手册
---
## 团队协作建议
### 1. 代码审查要点
- 检查是否遵循项目规范
- 检查是否有外键约束或 ORM 关联
- 检查字段命名是否符合规范
- 检查常量是否统一管理
### 2. 数据库变更流程
- 所有数据库变更必须通过迁移脚本
- 迁移脚本必须同时编写 UP 和 DOWN
- 迁移脚本必须在测试环境验证后才能上生产
### 3. 文档维护
- 数据模型变更时同步更新文档
- 文档使用中文编写,便于团队理解
- 文档放在 `docs/` 目录统一管理
---
## 风险和挑战
### 1. 无外键约束的数据一致性
**风险**: 删除父记录时可能遗留子记录
**应对措施**:
- 在业务层实现级联删除逻辑
- 定期运行数据一致性检查脚本
- 使用软删除(status 字段)代替物理删除
### 2. 轮询任务性能
**风险**: 大量 IoT 卡可能导致轮询任务堆积
**应对措施**:
- 使用 Asynq 任务队列,支持横向扩展
- 实现限流保护,避免过度调用运营商 API
- 根据卡状态动态调整轮询间隔
### 3. 分佣计算复杂度
**风险**: 多级代理分佣计算可能影响性能
**应对措施**:
- 使用异步任务处理分佣计算
- 实现分佣计算缓存
- 定期优化分佣计算逻辑
---
## 总结
本次实施严格按照 OpenSpec 规范完成了 IoT SIM 管理系统的数据模型层实现,包括 26 张数据库表、11 个 GORM 模型文件、100+ 业务常量,以及完整的文档。
### 成果总结
- ✅ 数据库迁移脚本 (UP + DOWN)
- ✅ GORM 模型定义 (11 个文件, 594 行代码)
- ✅ 业务常量定义 (164 行代码)
- ✅ 代码质量保证 (0 issues)
- ✅ 数据库迁移测试通过
- ✅ 完整技术文档 (5 个文档)
### 技术亮点
1. **无外键约束设计**: 提高灵活性和性能
2. **三层轮询机制**: 实名检查、卡流量检查、套餐流量检查相互独立
3. **三种分佣模式**: 一次性、长期、组合分佣,支持 OR 条件解冻
4. **多所有者模式**: 统一管理资源所有权
5. **真流量/虚流量共存**: 灵活的流量管理机制
6. **行业卡支持**: 区分行业卡和普通卡的不同业务流程
### 遵循的规范
- ✅ Go 代码风格规范 (Effective Go)
- ✅ GORM 模型规范 (显式 column 标签, 无 ORM 关联)
- ✅ 数据库设计规范 (无外键约束, 完整索引)
- ✅ 常量管理规范 (统一定义, 分类管理)
- ✅ 文档规范 (中文编写, 结构清晰)
- ✅ OpenSpec 流程规范 (只实现数据模型层)
---
**实施完成时间**: 2026-01-12
**实施人员**: Claude Sonnet 4.5
**文档版本**: v1.0

View File

@@ -1,658 +0,0 @@
# IoT SIM 管理系统 - 数据模型总结
## 概述
本文档总结了 IoT SIM 管理系统的数据模型层实现,包括 26 张数据库表和对应的 GORM 模型定义。
## 实现范围
- ✅ 数据库迁<E5BA93><E8BF81><EFBFBD>脚本 (migrations/000005_create_iot_sim_management_tables.up/down.sql)
- ✅ GORM 模型定义 (internal/iot/model/*.go)
- ✅ 业务常量定义 (pkg/constants/iot.go)
- ❌ API Handler 层 (不在本阶段范围)
- ❌ Service 业务逻辑层 (不在本阶段范围)
- ❌ Store 数据访问层 (不在本阶段范围)
## 核心业务实体
### 1. 运营商管理 (Carrier)
**表名**: `carriers`
**核心字段**:
- `carrier_code`: 运营商编码 (CMCC/CUCC/CTCC)
- `carrier_name`: 运营商名称 (中国移动/中国联通/中国电信)
- `api_endpoint`: API 接口地址
- `api_credentials`: API 凭证 (JSONB)
**预置数据**: 初始化三大运营商数据
**文件位置**: `internal/iot/model/carrier.go:5`
---
### 2. IoT 卡管理 (IotCard)
**表名**: `iot_cards`
**核心字段**:
- `iccid`: IoT 卡唯一标识 (20 位数字)
- `card_category`: 卡业务类型 (normal-普通卡, industry-行业卡)
- `carrier_id`: 所属运营商 ID
- `owner_type`: 所有者类型 (platform/agent/user/device)
- `owner_id`: 所有者 ID
- `activation_status`: 激活状态 (0-未激活, 1-已激活)
- `real_name_status`: 实名状态 (0-未实名, 1-已实名)
- `network_status`: 网络状态 (0-停机, 1-开机)
- `enable_polling`: 是否参与轮询
**特殊机制**:
- 支持行业卡(无需实名)和普通卡(需实名)
- 多所有者模式 (owner_type + owner_id)
- 轮询开关控制 (enable_polling)
- 流量使用累计 (data_usage_mb)
- Gateway 同步时间戳 (last_sync_time)
**文件位置**: `internal/iot/model/iot_card.go:8`
---
### 3. 设备管理 (Device)
**表名**: `devices`
**核心字段**:
- `device_code`: 设备唯一编码
- `device_name`: 设备名称
- `device_type`: 设备类型
- `sim_slots`: SIM 卡槽数量 (1-4)
- `owner_type`: 所有者类型 (platform/agent/user)
- `owner_id`: 所有者 ID
**关联关系**:
- 通过 `device_sim_bindings` 表关联 1-4 张 IoT 卡
**文件位置**: `internal/iot/model/device.go:5`
---
### 4. 号卡管理 (NumberCard)
**表名**: `number_cards`
**核心字段**:
- `virtual_product_code`: 虚拟商品编码 (用于对应运营商订单)
- `card_name`: 号卡名称
- `carrier`: 运营商
- `data_amount_mb`: 流量额度 (MB)
- `price`: 价格 (元)
**业务说明**: 号卡是完全独立的业务线,从上游平台下单,使用虚拟商品编码映射运营商订单。
**文件位置**: `internal/iot/model/number_card.go:8`
---
## 套餐与流量管理
### 5. 套餐系列 (PackageSeries)
**表名**: `package_series`
**核心字段**:
- `series_code`: 系列编码
- `series_name`: 系列名称
- `description`: 描述
**用途**: 套餐分组,用于一次性分佣规则配置。
**文件位置**: `internal/iot/model/package.go:7`
---
### 6. 套餐 (Package)
**表名**: `packages`
**核心字段**:
- `package_code`: 套餐编码
- `package_name`: 套餐名称
- `series_id`: 所属套餐系列 ID
- `package_type`: 套餐类型 (formal-正式套餐, addon-附加套餐)
- `duration_months`: 套餐时长 (月数)
- `data_type`: 流量类型 (real-真流量, virtual-虚流量)
- `real_data_mb`: 真流量额度 (MB)
- `virtual_data_mb`: 虚流量额度 (MB)
- `data_amount_mb`: 总流量额度 (MB)
- `price`: 套餐价格 (元)
**特殊机制**:
- 支持真流量/虚流量共存
- 停机判断基于虚流量
- 支持正式套餐和附加套餐
**文件位置**: `internal/iot/model/package.go:23`
---
### 7. 代理套餐分配 (AgentPackageAllocation)
**表名**: `agent_package_allocations`
**核心字段**:
- `agent_id`: 代理用户 ID
- `package_id`: 套餐 ID
- `cost_price`: 成本价 (元)
- `retail_price`: 零售价 (元)
**用途**: 为直属下级代理分配套餐,设置佣金模式。
**文件位置**: `internal/iot/model/package.go:48`
---
### 8. 设备-IoT 卡绑定 (DeviceSimBinding)
**表名**: `device_sim_bindings`
**核心字段**:
- `device_id`: 设备 ID
- `iot_card_id`: IoT 卡 ID
- `slot_position`: 插槽位置 (1, 2, 3, 4)
- `bind_status`: 绑定状态 (1-已绑定, 2-已解绑)
- `bind_time`: 绑定时间
- `unbind_time`: 解绑时间
**用途**: 管理设备与 IoT 卡的多对多绑定关系 (1 设备绑定 1-4 张 IoT 卡)。
**文件位置**: `internal/iot/model/package.go:66`
---
### 9. 套餐使用情况 (PackageUsage)
**表名**: `package_usages`
**核心字段**:
- `order_id`: 订单 ID
- `package_id`: 套餐 ID
- `usage_type`: 使用类型 (single_card-单卡套餐, device-设备级套餐)
- `iot_card_id`: IoT 卡 ID (单卡套餐时有值)
- `device_id`: 设备 ID (设备级套餐时有值)
- `data_limit_mb`: 流量限额 (MB)
- `data_usage_mb`: 已使用流量 (MB)
- `real_data_usage_mb`: 真流量使用 (MB)
- `virtual_data_usage_mb`: 虚流量使用 (MB)
- `activated_at`: 套餐生效时间
- `expires_at`: 套餐过期时间
- `status`: 状态 (1-生效中, 2-已用完, 3-已过期)
- `last_package_check_at`: 最后一次套餐流量检查时间
**用途**: 跟踪单卡套餐和设备级套餐的流量使用。
**文件位置**: `internal/iot/model/package.go:85`
---
### 10. 轮询配置 (PollingConfig)
**表名**: `polling_configs`
**核心字段**:
- `config_name`: 配置名称 (如 未实名卡、实名卡)
- `card_condition`: 卡状态条件 (not_real_name/real_name/activated/suspended)
- `carrier_id`: 运营商 ID (NULL 表示所有运营商)
- `real_name_check_enabled`: 是否启用实名检查
- `real_name_check_interval`: 实名检查间隔 (秒)
- `card_data_check_enabled`: 是否启用卡流量检查
- `card_data_check_interval`: 卡流量检查间隔 (秒)
- `package_check_enabled`: 是否启用套餐流量检查
- `package_check_interval`: 套餐流量检查间隔 (秒)
- `priority`: 优先级 (数字越小优先级越高)
**特殊机制**: 支持梯度轮询策略 (实名检查、卡流量检查、套餐流量检查)。
**文件位置**: `internal/iot/model/polling.go:7`
---
### 11. 流量使用记录 (DataUsageRecord)
**表名**: `data_usage_records`
**核心字段**:
- `iot_card_id`: IoT 卡 ID
- `usage_date`: 使用日期
- `data_usage_mb`: 流量使用 (MB)
- `carrier_sync_data`: 运营商同步数据 (JSONB)
- `synced_at`: 同步时间
**用途**: 记录 IoT 卡每日流量使用情况 (历史数据)。
**文件位置**: `internal/iot/model/data_usage.go:5`
---
## 订单管理
### 12. 订单 (Order)
**表名**: `orders`
**核心字段**:
- `order_no`: 订单号 (唯一标识)
- `order_type`: 订单类型 (1-套餐订单, 2-号卡订单)
- `iot_card_id`: IoT 卡 ID (单卡套餐订单时有值)
- `device_id`: 设备 ID (设备级套餐订单时有值)
- `number_card_id`: 号卡 ID (号卡订单时有值)
- `package_id`: 套餐 ID (套餐订单时有值)
- `user_id`: 用户 ID
- `agent_id`: 代理用户 ID
- `amount`: 订单金额 (元)
- `payment_method`: 支付方式 (wallet/online/carrier)
- `status`: 状态 (1-待支付, 2-已支付, 3-已完成, 4-已取消, 5-已退款)
- `carrier_order_id`: 运营商订单 ID
- `carrier_order_data`: 运营商订单原始数据 (JSONB)
**支持场景**:
- 套餐订单 (单卡套餐 / 设备级套餐)
- 号卡订单
**文件位置**: `internal/iot/model/order.go:11`
---
## 分佣系统
### 13. 代理层级关系 (AgentHierarchy)
**表名**: `agent_hierarchies`
**核心字段**:
- `agent_id`: 代理用户 ID
- `parent_agent_id`: 上级代理用户 ID
- `agent_level`: 代理层级 (1-一级代理, 2-二级代理, ...)
- `agent_path`: 代理路径 (如 /1/2/3/)
**用途**: 管理代理的树形层级关系。
**文件位置**: `internal/iot/model/commission.go:8`
---
### 14. 分佣规则 (CommissionRule)
**表名**: `commission_rules`
**核心字段**:
- `rule_name`: 规则名称
- `rule_type`: 规则类型 (one_time-一次性分佣, long_term-长期分佣, combined-组合分佣)
- `package_series_id`: 套餐系列 ID
- `commission_type`: 分佣方式 (fixed-固定金额, percentage-百分比)
- `commission_value`: 分佣值
- `target_level`: 目标层级 (NULL 表示所有层级)
- `enable_ladder`: 是否启用阶梯
- `freeze_days`: 冻结天数 (长期分佣)
- `freeze_data_mb`: 冻结流量 (MB, 长期分佣)
- `unfreeze_mode`: 解冻模式 (auto-自动, manual-手动)
**分佣类型说明**:
- **一次性分佣**: 订单完成后立即发放
- **长期分佣**: 订单完成后冻结,满足解冻条件后发放
- **组合分佣**: 同时包含一次性和长期分佣
**文件位置**: `internal/iot/model/commission.go:24`
---
### 15. 分佣阶梯 (CommissionLadder)
**表名**: `commission_ladder`
**核心字段**:
- `rule_id`: 分佣规则 ID
- `min_quantity`: 最小数量
- `max_quantity`: 最大数量
- `commission_type`: 分佣方式 (fixed/percentage)
- `commission_value`: 分佣值
**用途**: 为分佣规则配置阶梯奖励 (订单数量越多,分佣越高)。
**文件位置**: `internal/iot/model/commission.go:47`
---
### 16. 组合分佣条件 (CommissionCombinedCondition)
**表名**: `commission_combined_conditions`
**核心字段**:
- `rule_id`: 分佣规则 ID
- `condition_type`: 条件类型 (one_time-一次性, long_term-长期)
- `commission_type`: 分佣方式 (fixed/percentage)
- `commission_value`: 分佣值
- `freeze_days`: 冻结天数 (长期分佣)
- `freeze_data_mb`: 冻结流量 (MB, 长期分佣)
**用途**: 定义组合分佣规则的具体条件 (一次性部分 + 长期部分)。
**文件位置**: `internal/iot/model/commission.go:68`
---
### 17. 分佣记录 (CommissionRecord)
**表名**: `commission_records`
**核心字段**:
- `order_id`: 订单 ID
- `agent_id`: 代理用户 ID
- `rule_id`: 分佣规则 ID
- `commission_type`: 分佣类型 (one_time/long_term/combined)
- `commission_amount`: 分佣金额 (元)
- `status`: 状态 (1-待发放, 2-已发放, 3-已冻结, 4-已取消)
- `freeze_days`: 冻结天数
- `freeze_data_mb`: 冻结流量 (MB)
- `unfreeze_conditions`: 解冻条件 (JSONB)
- `unfrozen_at`: 解冻时间
- `distributed_at`: 发放时间
**特殊机制**: 支持 OR 条件解冻 (时间到期 OR 流量达标,满足其一即可解冻)。
**文件位置**: `internal/iot/model/commission.go:88`
---
### 18. 分佣审批 (CommissionApproval)
**表名**: `commission_approvals`
**核心字段**:
- `commission_record_id`: 分佣记录 ID
- `agent_id`: 代理用户 ID
- `approval_status`: 审批状态 (1-待审批, 2-已通过, 3-已拒绝)
- `approver_id`: 审批人 ID
- `approval_reason`: 审批原因
- `approved_at`: 审批时间
**用途**: 管理需要手动审批的分佣记录。
**文件位置**: `internal/iot/model/commission.go:116`
---
### 19. 分佣模板 (CommissionTemplate)
**表名**: `commission_templates`
**核心字段**:
- `template_name`: 模板名称
- `template_data`: 模板数据 (JSONB)
- `description`: 描述
**用途**: 快速创建分佣规则的预设模板。
**文件位置**: `internal/iot/model/commission.go:137`
---
### 20. 运营商结算 (CarrierSettlement)
**表名**: `carrier_settlements`
**核心字段**:
- `carrier_id`: 运营商 ID
- `settlement_month`: 结算月份 (YYYY-MM)
- `total_orders`: 总订单数
- `total_amount`: 总金额 (元)
- `settlement_status`: 结算状态 (1-待结算, 2-已结算, 3-已支付)
- `settled_at`: 结算时间
- `paid_at`: 支付时间
**用途**: 记录与运营商的月度结算情况。
**文件位置**: `internal/iot/model/commission.go:155`
---
## 财务管理
### 21. 提现申请 (CommissionWithdrawalRequest)
**表名**: `commission_withdrawal_requests`
**核心字段**:
- `agent_id`: 代理用户 ID
- `withdrawal_amount`: 提现金额 (元)
- `withdrawal_method`: 提现方式 (bank_card/alipay/wechat)
- `account_info`: 账户信息 (JSONB)
- `status`: 状态 (1-待审核, 2-已通过, 3-已拒绝, 4-已打款, 5-已取消)
- `reviewer_id`: 审核人 ID
- `review_reason`: 审核原因
- `reviewed_at`: 审核时间
- `paid_at`: 打款时间
**用途**: 管理代理用户的佣金提现申请。
**文件位置**: `internal/iot/model/financial.go:8`
---
### 22. 提现设置 (CommissionWithdrawalSetting)
**表名**: `commission_withdrawal_settings`
**核心字段**:
- `agent_id`: 代理用户 ID
- `min_withdrawal_amount`: 最小提现金额 (元)
- `max_withdrawal_amount`: 最大提现金额 (元)
- `withdrawal_fee_rate`: 提现手续费率 (小数)
- `auto_approval_enabled`: 是否启用自动审批
**用途**: 配置代理用户的提现规则。
**文件位置**: `internal/iot/model/financial.go:31`
---
### 23. 收款商户设置 (PaymentMerchantSetting)
**表名**: `payment_merchant_settings`
**核心字段**:
- `merchant_name`: 商户名称
- `merchant_type`: 商户类型 (alipay/wechat/bank)
- `merchant_config`: 商户配置 (JSONB)
- `is_default`: 是否默认商户
**用途**: 配置收款商户信息 (支付宝、微信、银行)。
**文件位置**: `internal/iot/model/financial.go:51`
---
## 系统管理
### 24. 开发能力配置 (DevCapabilityConfig)
**表名**: `dev_capability_configs`
**核心字段**:
- `capability_name`: 能力名称
- `capability_code`: 能力编码
- `capability_config`: 能力配置 (JSONB)
- `description`: 描述
**用途**: 管理系统开发能力配置 (如 API 开关、功能权限等)。
**文件位置**: `internal/iot/model/system.go:5`
---
### 25. 换卡申请 (CardReplacementRequest)
**表名**: `card_replacement_requests`
**核心字段**:
- `old_iot_card_id`: 旧卡 ID
- `new_iot_card_id`: 新卡 ID
- `user_id`: 用户 ID
- `replacement_reason`: 换卡原因
- `status`: 状态 (1-待审核, 2-已通过, 3-已拒绝, 4-已完成)
- `reviewer_id`: 审核人 ID
- `reviewed_at`: 审核时间
- `completed_at`: 完成时间
**用途**: 管理 IoT 卡的换卡申请流程。
**文件位置**: `internal/iot/model/system.go:25`
---
## 数据库设计原则
### 1. 无外键约束
- 数据库表之间**禁止建立外键约束** (Foreign Key Constraints)
- 关联关系通过存储关联 ID 字段手动维护
- 关联数据查询在代码层面显式执行
**设计理由**:
- 灵活性:业务逻辑完全在代码中控制
- 性能:无数据库层面的引用完整性检查开销
- 可控性:开发者完全掌控何时查询关联数据
- 分布式友好:在微服务场景下更容易扩展
---
### 2. GORM 模型规范
- 所有字段必须显式指定 `column:` 标签
- 禁止使用 ORM 关联关系 (`foreignKey`, `references`, `hasMany`, `belongsTo`)
- 所有字段必须添加中文注释
- 字符串字段长度必须明确定义 (VARCHAR(100)/VARCHAR(255)/TEXT)
- 数值字段精度必须明确定义 (DECIMAL(10,2)/BIGINT)
- 时间字段使用 GORM 自动管理 (`autoCreateTime`, `autoUpdateTime`)
---
### 3. 命名规范
- 数据库字段名:下划线命名法 (snake_case),如 `user_id`, `created_at`
- Go 结构体字段名:驼峰命名法 (PascalCase),如 `UserID`, `CreatedAt`
- 表名:复数形式,如 `iot_cards`, `orders`, `commission_rules`
- 常量名:大写驼峰 + 前缀,如 `IotOrderStatusPending`, `CarrierCodeCMCC`
---
## 常量定义
所有业务常量统一定义在 `pkg/constants/iot.go` 文件中,包括:
- 卡类型、卡业务类型、激活状态、实名状态、网络状态
- 所有者类型、卡状态、设备状态
- 套餐类型、流量类型、套餐使用状态
- 订单类型、订单状态、支付方式
- 轮询卡状态条件
- 分佣规则类型、分佣方式、分佣状态、解冻模式
- 提现状态、提现方式、商户类型
- 换卡申请状态、审批状态
**文件位置**: `pkg/constants/iot.go:1`
---
## 数据库迁移
### 迁移脚本
- **UP 脚本**: `migrations/000005_create_iot_sim_management_tables.up.sql`
- 创建 26 张表
- 创建所有必需的索引
- 添加完整的中文注释
- 初始化三大运营商数据
- **DOWN 脚本**: `migrations/000005_create_iot_sim_management_tables.down.sql`
- 按反向依赖顺序删除所有表
### 迁移测试
```bash
# 应用迁移
source .env && migrate -database "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" -path migrations up
# 回滚迁移
source .env && migrate -database "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" -path migrations down 1
# 查看版本
source .env && migrate -database "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" -path migrations version
```
### 测试结果
- ✅ UP 迁移成功 (耗时 616ms)
- ✅ DOWN 迁移成功 (耗时 602ms)
- ✅ 数据库版本正确切换 (4 → 5 → 4 → 5)
---
## 文件清单
### 数据库迁移脚本
- `migrations/000005_create_iot_sim_management_tables.up.sql` (1102 行)
- `migrations/000005_create_iot_sim_management_tables.down.sql` (27 行)
### GORM 模型定义
- `internal/iot/model/carrier.go` (17 行)
- `internal/iot/model/iot_card.go` (40 行)
- `internal/iot/model/device.go` (27 行)
- `internal/iot/model/number_card.go` (26 行)
- `internal/iot/model/package.go` (108 行)
- `internal/iot/model/order.go` (36 行)
- `internal/iot/model/polling.go` (29 行)
- `internal/iot/model/data_usage.go` (20 行)
- `internal/iot/model/commission.go` (175 行)
- `internal/iot/model/financial.go` (70 行)
- `internal/iot/model/system.go` (46 行)
### 常量定义
- `pkg/constants/iot.go` (164 行)
---
## 代码质量
-`go fmt` 格式化通过
-`goimports` 导入整理通过
-`golangci-lint` 质量检查通过 (0 issues)
-`go build` 编译通过
-`go mod tidy` 依赖管理通过
---
## 下一步工作
根据 OpenSpec 规范,本阶段只实现数据模型层,以下工作不在本阶段范围:
- ❌ API Handler 层
- ❌ Service 业务逻辑层
- ❌ Store 数据访问层
- ❌ 单元测试
- ❌ 集成测试
- ❌ API 文档生成
这些工作将在后续阶段按照 OpenSpec 流程逐步实现。
---
## 参考文档
- [表结构详细说明](./表结构详细说明.md)
- [轮询机制说明](./轮询机制说明.md)
- [分佣系统说明](./分佣系统说明.md)
---
**文档版本**: v1.0
**最后更新**: 2026-01-12
**维护人员**: Claude Sonnet 4.5

File diff suppressed because it is too large Load Diff

View File

@@ -1,776 +0,0 @@
# IoT SIM 管理系统 - 轮询机制说明
## 概述
IoT SIM 管理系统实现了一套灵活的三层轮询机制,用于定期检查 IoT 卡的实名状态、流量使用情况和套餐流量情况。轮询机制支持梯度策略配置,可以针对不同卡状态、不同运营商设置不同的轮询间隔和优先级。
---
## 轮询架构
### 三层轮询体系
```
┌──────────────────────────────────────────────────────────┐
│ 轮询调度器 │
│ (Polling Scheduler) │
│ - 读取轮询配置表 │
│ - 按优先级和间隔时间调度任务 │
│ - 使用 Asynq 异步任务队列 │
└──────────────────────────────────────────────────────────┘
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ 实名检查进程 │ │ 卡流量检查进程 │ │ 套餐流量检查进程 │
│ (Real Name) │ │ (Card Data) │ │ (Package Data) │
├────────────────┤ ├────────────────┤ ├────────────────┤
│ - 查询未实名卡 │ │ - 查询激活的卡 │ │ - 查询生效中套餐 │
│ - 调用运营商API │ │ - 同步流量使用 │ │ - 检查流量使用 │
│ - 更新实名状态 │ │ - 更新 IoT 卡 │ │ - 判断是否停机 │
└────────────────┘ └────────────────┘ └────────────────┘
```
---
## 轮询配置表
### 表结构: `polling_configs`
轮询配置表支持灵活的梯度轮询策略:
```sql
CREATE TABLE polling_configs (
id BIGSERIAL PRIMARY KEY,
config_name VARCHAR(100) UNIQUE NOT NULL,
description VARCHAR(500),
card_condition VARCHAR(50),
carrier_id BIGINT,
real_name_check_enabled BOOLEAN DEFAULT false,
real_name_check_interval INT DEFAULT 60,
card_data_check_enabled BOOLEAN DEFAULT false,
card_data_check_interval INT DEFAULT 60,
package_check_enabled BOOLEAN DEFAULT false,
package_check_interval INT DEFAULT 60,
priority INT NOT NULL DEFAULT 100,
status INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### 配置字段说明
| 字段名 | 类型 | 说明 |
|--------|------|------|
| config_name | VARCHAR(100) | 配置名称,如"未实名卡-移动"、"已激活卡-联通" |
| card_condition | VARCHAR(50) | 卡状态条件: `not_real_name`/`real_name`/`activated`/`suspended` |
| carrier_id | BIGINT | 运营商ID,NULL 表示所有运营商 |
| real_name_check_enabled | BOOLEAN | 是否启用实名检查 |
| real_name_check_interval | INT | 实名检查间隔(秒) |
| card_data_check_enabled | BOOLEAN | 是否启用卡流量检查 |
| card_data_check_interval | INT | 卡流量检查间隔(秒) |
| package_check_enabled | BOOLEAN | 是否启用套餐流量检查 |
| package_check_interval | INT | 套餐流量检查间隔(秒) |
| priority | INT | 优先级(数字越小优先级越高) |
| status | INT | 状态: 1-启用, 2-禁用 |
---
## 轮询配置示例
### 示例 1: 未实名卡快速轮询
```sql
INSERT INTO polling_configs (
config_name,
description,
card_condition,
carrier_id,
real_name_check_enabled,
real_name_check_interval,
card_data_check_enabled,
card_data_check_interval,
priority,
status
) VALUES (
'未实名卡-快速轮询',
'对未实名卡每30秒检查一次实名状态',
'not_real_name',
NULL, -- 所有运营商
true, -- 启用实名检查
30, -- 30秒间隔
false, -- 不检查流量
0,
10, -- 高优先级
1 -- 启用
);
```
**说明**: 未实名卡需要频繁检查实名状态,以便及时发现已完成实名认证的卡。
---
### 示例 2: 已激活卡流量监控
```sql
INSERT INTO polling_configs (
config_name,
description,
card_condition,
carrier_id,
real_name_check_enabled,
real_name_check_interval,
card_data_check_enabled,
card_data_check_interval,
package_check_enabled,
package_check_interval,
priority,
status
) VALUES (
'已激活卡-流量监控',
'对已激活卡每60秒检查流量使用',
'activated',
NULL,
false, -- 不检查实名
0,
true, -- 启用卡流量检查
60, -- 60秒间隔
true, -- 启用套餐流量检查
60, -- 60秒间隔
20, -- 中优先级
1
);
```
**说明**: 已激活卡需要监控流量使用,防止超额使用和及时停机。
---
### 示例 3: 移动运营商特殊策略
```sql
INSERT INTO polling_configs (
config_name,
description,
card_condition,
carrier_id,
real_name_check_enabled,
real_name_check_interval,
card_data_check_enabled,
card_data_check_interval,
priority,
status
) VALUES (
'移动-已激活卡-慢速轮询',
'移动运营商已激活卡每180秒检查一次流量',
'activated',
1, -- 中国移动 carrier_id
false,
0,
true,
180, -- 180秒间隔
50, -- 低优先级
1
);
```
**说明**: 可以针对特定运营商设置不同的轮询策略,优化 API 调用频率。
---
## 三种轮询进程
### 1. 实名检查进程 (Real Name Check)
**目标**: 检查未实名的 IoT 卡是否已完成实名认证
**工作流程**:
1. 查询符合条件的 IoT 卡:
- `card_category = 'normal'` (普通卡需要实名)
- `real_name_status = 0` (未实名)
- `enable_polling = true` (参与轮询)
- 根据 `last_real_name_check_at` 判断是否到达检查间隔
2. 调用运营商 Gateway API 查询实名状态
3. 更新 IoT 卡的 `real_name_status``last_real_name_check_at`
4. 记录日志和异常情况
**轮询间隔控制**:
```go
// 伪代码
if time.Since(card.LastRealNameCheckAt) >= config.RealNameCheckInterval {
// 执行实名检查
result := gateway.CheckRealName(card.ICCID)
card.RealNameStatus = result.Status
card.LastRealNameCheckAt = time.Now()
db.Save(&card)
}
```
**配置参数**:
- `real_name_check_enabled`: 是否启用
- `real_name_check_interval`: 检查间隔(秒)
**注意事项**:
- 行业卡 (`card_category = 'industry'`) 无需实名检查
- 已实名的卡 (`real_name_status = 1`) 不再参与轮询
---
### 2. 卡流量检查进程 (Card Data Check)
**目标**: 同步 IoT 卡的流量使用情况
**工作流程**:
1. 查询符合条件的 IoT 卡:
- `activation_status = 1` (已激活)
- `enable_polling = true` (参与轮询)
- 根据 `last_data_check_at` 判断是否到达检查间隔
2. 调用运营商 Gateway API 查询流量使用
3. 更新 IoT 卡的 `data_usage_mb``last_data_check_at`
4. 记录流量使用历史到 `data_usage_records`
5. 判断是否需要停机:
- 如果流量超过套餐的虚流量额度,触发停机逻辑
**轮询间隔控制**:
```go
// 伪代码
if time.Since(card.LastDataCheckAt) >= config.CardDataCheckInterval {
// 执行流量检查
usage := gateway.GetDataUsage(card.ICCID)
card.DataUsageMB = usage.TotalMB
card.LastDataCheckAt = time.Now()
db.Save(&card)
// 记录历史数据
record := DataUsageRecord{
IotCardID: card.ID,
UsageDate: time.Now().Format("2006-01-02"),
DataUsageMB: usage.TodayMB,
CarrierSyncData: usage.RawData,
SyncedAt: time.Now(),
}
db.Create(&record)
}
```
**配置参数**:
- `card_data_check_enabled`: 是否启用
- `card_data_check_interval`: 检查间隔(秒)
**停机判断逻辑**:
```go
// 伪代码
if card.DataUsageMB >= package.VirtualDataMB {
// 触发停机
card.NetworkStatus = 0 // 停机
gateway.SuspendCard(card.ICCID)
}
```
---
### 3. 套餐流量检查进程 (Package Check)
**目标**: 检查套餐流量使用情况,判断套餐状态
**工作流程**:
1. 查询符合条件的套餐使用记录:
- `status = 1` (生效中)
- `expires_at > NOW()` (未过期)
- 根据 `last_package_check_at` 判断是否到达检查间隔
2. 计算套餐的流量使用情况:
- 单卡套餐: 统计该卡的流量使用
- 设备级套餐: 统计该设备绑定的所有卡的流量使用
3. 更新套餐使用记录的 `data_usage_mb``last_package_check_at`
4. 判断套餐状态:
- 如果流量用完: `status = 2` (已用完)
- 如果时间过期: `status = 3` (已过期)
5. 如果套餐用完或过期,触发停机逻辑
**轮询间隔控制**:
```go
// 伪代码
if time.Since(packageUsage.LastPackageCheckAt) >= config.PackageCheckInterval {
// 执行套餐检查
var totalUsage int64
if packageUsage.UsageType == "single_card" {
// 单卡套餐
card := db.FindIotCardByID(packageUsage.IotCardID)
totalUsage = card.DataUsageMB
} else {
// 设备级套餐
bindings := db.FindDeviceSimBindings(packageUsage.DeviceID)
for _, binding := range bindings {
card := db.FindIotCardByID(binding.IotCardID)
totalUsage += card.DataUsageMB
}
}
packageUsage.DataUsageMB = totalUsage
packageUsage.LastPackageCheckAt = time.Now()
// 判断状态
if totalUsage >= packageUsage.DataLimitMB {
packageUsage.Status = 2 // 已用完
// 触发停机
}
if time.Now().After(packageUsage.ExpiresAt) {
packageUsage.Status = 3 // 已过期
// 触发停机
}
db.Save(&packageUsage)
}
```
**配置参数**:
- `package_check_enabled`: 是否启用
- `package_check_interval`: 检查间隔(秒)
**停机判断逻辑**:
```go
// 伪代码
if packageUsage.Status == 2 || packageUsage.Status == 3 {
// 套餐用完或过期,触发停机
if packageUsage.UsageType == "single_card" {
card := db.FindIotCardByID(packageUsage.IotCardID)
card.NetworkStatus = 0 // 停机
gateway.SuspendCard(card.ICCID)
} else {
// 设备级套餐,停掉所有绑定的卡
bindings := db.FindDeviceSimBindings(packageUsage.DeviceID)
for _, binding := range bindings {
card := db.FindIotCardByID(binding.IotCardID)
card.NetworkStatus = 0
gateway.SuspendCard(card.ICCID)
}
}
}
```
---
## 轮询调度器设计
### 调度器架构
```go
// 伪代码
type PollingScheduler struct {
db *gorm.DB
queue *asynq.Client
}
func (s *PollingScheduler) Start() {
// 启动三个独立的调度协程
go s.scheduleRealNameCheck()
go s.scheduleCardDataCheck()
go s.schedulePackageCheck()
}
func (s *PollingScheduler) scheduleRealNameCheck() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
configs := s.loadPollingConfigs("real_name_check_enabled = true")
for _, config := range configs {
// 查询需要检查的卡
cards := s.findCardsForRealNameCheck(config)
for _, card := range cards {
// 使用 Asynq 异步任务队列
task := asynq.NewTask("iot:realname:check", map[string]interface{}{
"card_id": card.ID,
"config_id": config.ID,
})
s.queue.Enqueue(task, asynq.ProcessIn(0))
}
}
}
}
```
### 任务队列设计
使用 Asynq 异步任务队列处理轮询任务:
```go
// 伪代码
type RealNameCheckHandler struct {
db *gorm.DB
gateway *CarrierGateway
}
func (h *RealNameCheckHandler) ProcessTask(ctx context.Context, task *asynq.Task) error {
var payload struct {
CardID uint `json:"card_id"`
ConfigID uint `json:"config_id"`
}
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
return err
}
// 加载卡信息
card := h.db.FindIotCardByID(payload.CardID)
// 调用运营商 API
result, err := h.gateway.CheckRealName(card.ICCID)
if err != nil {
return err
}
// 更新卡状态
card.RealNameStatus = result.Status
card.LastRealNameCheckAt = time.Now()
h.db.Save(&card)
return nil
}
```
---
## 轮询优先级和并发控制
### 优先级机制
轮询配置表的 `priority` 字段控制执行优先级:
- **高优先级 (1-30)**: 紧急任务,如未实名卡检查
- **中优先级 (31-70)**: 常规任务,如流量监控
- **低优先级 (71-100)**: 非紧急任务,如历史数据同步
调度器按优先级排序执行:
```sql
SELECT * FROM polling_configs
WHERE status = 1
ORDER BY priority ASC, id ASC;
```
### 并发控制
使用 Asynq 的并发控制功能:
```go
// 伪代码
queue := asynq.NewClient(asynq.RedisClientOpt{Addr: "localhost:6379"})
// 设置队列并发数
queues := map[string]int{
"iot:realname": 10, // 实名检查队列,10个并发
"iot:carddata": 20, // 卡流量检查队列,20个并发
"iot:package": 20, // 套餐检查队列,20个并发
}
server := asynq.NewServer(
asynq.RedisClientOpt{Addr: "localhost:6379"},
asynq.Config{Queues: queues},
)
```
### 限流保护
为了避免过度调用运营商 API,需要实现限流保护:
```go
// 伪代码
type RateLimiter struct {
limiter *rate.Limiter
}
func NewRateLimiter(carrierID uint) *RateLimiter {
// 每秒最多 10 次 API 调用
return &RateLimiter{
limiter: rate.NewLimiter(rate.Limit(10), 10),
}
}
func (r *RateLimiter) Wait(ctx context.Context) error {
return r.limiter.Wait(ctx)
}
```
---
## 轮询间隔策略
### 推荐配置
| 卡状态 | 实名检查间隔 | 卡流量检查间隔 | 套餐流量检查间隔 | 优先级 |
|--------|-------------|---------------|----------------|-------|
| 未实名卡 | 30秒 | - | - | 10 (高) |
| 已实名未激活 | - | - | - | - |
| 已激活卡(正常) | - | 60秒 | 60秒 | 20 (中) |
| 已激活卡(套餐即将用完) | - | 30秒 | 30秒 | 15 (高) |
| 已停用卡 | - | - | - | - |
### 动态调整策略
根据卡的流量使用情况动态调整轮询间隔:
```go
// 伪代码
func calculateCheckInterval(packageUsage *PackageUsage) int {
usagePercent := float64(packageUsage.DataUsageMB) / float64(packageUsage.DataLimitMB)
if usagePercent >= 0.9 {
return 30 // 90%以上,30秒检查一次
} else if usagePercent >= 0.7 {
return 60 // 70-90%,60秒检查一次
} else {
return 180 // 70%以下,180秒检查一次
}
}
```
---
## 轮询开关控制
### 全局开关
IoT 卡的 `enable_polling` 字段控制是否参与轮询:
```sql
-- 禁用某张卡的轮询
UPDATE iot_cards SET enable_polling = false WHERE iccid = '89860123456789012345';
-- 启用某张卡的轮询
UPDATE iot_cards SET enable_polling = true WHERE iccid = '89860123456789012345';
```
### 配置开关
轮询配置表的 `status` 字段控制整个配置是否启用:
```sql
-- 禁用某个轮询配置
UPDATE polling_configs SET status = 2 WHERE config_name = '未实名卡-快速轮询';
-- 启用某个轮询配置
UPDATE polling_configs SET status = 1 WHERE config_name = '未实名卡-快速轮询';
```
### 单项开关
轮询配置表的 `*_check_enabled` 字段控制具体检查类型:
```sql
-- 只启用实名检查,禁用流量检查
UPDATE polling_configs
SET real_name_check_enabled = true,
card_data_check_enabled = false,
package_check_enabled = false
WHERE config_name = '未实名卡-快速轮询';
```
---
## 错误处理和重试
### 错误处理策略
轮询任务可能因为以下原因失败:
1. 运营商 API 超时
2. 运营商 API 返回错误
3. 数据库连接失败
4. 网络故障
使用 Asynq 的重试机制:
```go
// 伪代码
task := asynq.NewTask("iot:realname:check", payload)
// 设置重试策略
opts := []asynq.Option{
asynq.MaxRetry(3), // 最多重试 3 次
asynq.Timeout(30 * time.Second), // 任务超时时间 30 秒
}
queue.Enqueue(task, opts...)
```
### 失败日志记录
记录失败的轮询任务:
```go
// 伪代码
type PollingLog struct {
ID uint `gorm:"primaryKey"`
TaskType string // realname/carddata/package
CardID uint
ConfigID uint
Success bool
ErrorMsg string
ExecutedAt time.Time
CreatedAt time.Time
}
func logPollingResult(taskType string, cardID, configID uint, err error) {
log := PollingLog{
TaskType: taskType,
CardID: cardID,
ConfigID: configID,
Success: err == nil,
ErrorMsg: fmt.Sprintf("%v", err),
ExecutedAt: time.Now(),
CreatedAt: time.Now(),
}
db.Create(&log)
}
```
---
## 监控和告警
### 监控指标
1. **轮询任务执行成功率**
- 实名检查成功率
- 卡流量检查成功率
- 套餐流量检查成功率
2. **轮询任务延迟**
- 任务入队时间到执行时间的延迟
- 平均延迟、P95、P99
3. **运营商 API 调用统计**
- 每分钟 API 调用次数
- API 响应时间
- API 错误率
4. **卡状态统计**
- 未实名卡数量
- 已激活卡数量
- 已停用卡数量
### 告警规则
1. **高失败率告警**
- 如果某类轮询任务 5 分钟内失败率超过 50%,触发告警
2. **高延迟告警**
- 如果轮询任务延迟超过 5 分钟,触发告警
3. **API 异常告警**
- 如果运营商 API 连续失败 10 次,触发告警
4. **流量异常告警**
- 如果某张卡流量使用突增(1小时内增加超过 100MB),触发告警
---
## 最佳实践
### 1. 合理设置轮询间隔
- **频繁轮询的代价**: 增加运营商 API 调用次数,可能触发限流
- **稀疏轮询的风险**: 流量超额检测不及时,可能导致停机延迟
- **建议**: 根据业务需求和运营商 API 限制平衡轮询频率
### 2. 使用批量查询
```go
// 不推荐: 逐个查询
for _, card := range cards {
usage := gateway.GetDataUsage(card.ICCID)
card.DataUsageMB = usage.TotalMB
db.Save(&card)
}
// 推荐: 批量查询
iccids := []string{}
for _, card := range cards {
iccids = append(iccids, card.ICCID)
}
usages := gateway.BatchGetDataUsage(iccids) // 批量查询
for _, card := range cards {
card.DataUsageMB = usages[card.ICCID]
}
db.Save(&cards) // 批量更新
```
### 3. 实现幂等性
轮询任务可能会重复执行,必须保证幂等性:
```go
// 伪代码
func ProcessRealNameCheck(cardID uint) error {
// 加锁,防止重复执行
lockKey := fmt.Sprintf("iot:realname:lock:%d", cardID)
lock := redis.SetNX(lockKey, "1", 60*time.Second)
if !lock {
return errors.New("task already running")
}
defer redis.Del(lockKey)
// 执行检查
card := db.FindIotCardByID(cardID)
result := gateway.CheckRealName(card.ICCID)
card.RealNameStatus = result.Status
card.LastRealNameCheckAt = time.Now()
db.Save(&card)
return nil
}
```
### 4. 记录详细日志
```go
// 伪代码
logger.Info("开始实名检查",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Uint("config_id", config.ID),
)
result, err := gateway.CheckRealName(card.ICCID)
if err != nil {
logger.Error("实名检查失败",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Error(err),
)
return err
}
logger.Info("实名检查成功",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Int("real_name_status", result.Status),
)
```
---
## 总结
IoT SIM 管理系统的轮询机制具有以下特点:
1. **三层轮询体系**: 实名检查、卡流量检查、套餐流量检查相互独立
2. **灵活配置**: 支持按卡状态、运营商、优先级配置不同的轮询策略
3. **异步任务队列**: 使用 Asynq 实现高并发、可重试的任务处理
4. **梯度策略**: 支持根据流量使用情况动态调整轮询间隔
5. **开关控制**: 支持全局、配置、单项的轮询开关
6. **错误处理**: 完善的重试机制和错误日志记录
7. **监控告警**: 实时监控轮询任务执行情况和运营商 API 调用
通过合理配置和使用轮询机制,可以实现 IoT 卡的自动化管理,提高运营效率,降低人工成本。
---
**文档版本**: v1.0
**最后更新**: 2026-01-12
**维护人员**: Claude Sonnet 4.5

View File

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

View File

@@ -1,163 +0,0 @@
# 对象存储使用指南
本文档介绍如何在后端代码中使用对象存储服务。
## 配置
通过环境变量配置对象存储:
```bash
# 存储提供商
export JUNHONG_STORAGE_PROVIDER="s3"
# S3 配置
export JUNHONG_STORAGE_S3_ENDPOINT="http://obs-helf.cucloud.cn"
export JUNHONG_STORAGE_S3_REGION="cn-langfang-2"
export JUNHONG_STORAGE_S3_BUCKET="cmp"
export JUNHONG_STORAGE_S3_ACCESS_KEY_ID="YOUR_ACCESS_KEY"
export JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY="YOUR_SECRET_KEY"
export JUNHONG_STORAGE_S3_USE_SSL="false"
export JUNHONG_STORAGE_S3_PATH_STYLE="true"
# 预签名 URL 配置
export JUNHONG_STORAGE_PRESIGN_UPLOAD_EXPIRES="15m"
export JUNHONG_STORAGE_PRESIGN_DOWNLOAD_EXPIRES="24h"
# 临时文件目录
export JUNHONG_STORAGE_TEMP_DIR="/tmp/junhong-storage"
```
详细配置说明见 [环境变量配置文档](../environment-variables.md)
## StorageService 使用
### 获取预签名上传 URL
```go
result, err := storageService.GetUploadURL(ctx, "iot_import", "cards.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
if err != nil {
return err
}
// result.URL - 预签名上传 URL
// result.FileKey - 文件路径(用于后续业务接口)
// result.ExpiresIn - URL 有效期(秒)
```
### 下载文件到临时目录
```go
localPath, cleanup, err := storageService.DownloadToTemp(ctx, fileKey)
if err != nil {
return err
}
defer cleanup() // 处理完成后自动删除临时文件
// 使用 localPath 读取文件内容
f, _ := os.Open(localPath)
defer f.Close()
```
### 直接上传文件
```go
reader := bytes.NewReader(content)
err := storageService.Provider().Upload(ctx, fileKey, reader, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
```
### 检查文件是否存在
```go
exists, err := storageService.Provider().Exists(ctx, fileKey)
```
### 删除文件
```go
err := storageService.Provider().Delete(ctx, fileKey)
```
## Purpose 类型
| Purpose | 说明 | 生成路径 | ContentType |
|---------|------|---------|-------------|
| iot_import | ICCID 导入 (Excel) | imports/YYYY/MM/DD/uuid.xlsx | application/vnd.openxmlformats... |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | application/vnd.openxmlformats... |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | 自动检测 |
## 错误处理
存储相关错误码定义在 `pkg/errors/codes.go`
| 错误码 | 说明 |
|-------|------|
| 1090 | 对象存储服务未配置 |
| 1091 | 文件上传失败 |
| 1092 | 文件下载失败 |
| 1093 | 文件不存在 |
| 1094 | 不支持的文件用途 |
| 1095 | 不支持的文件类型 |
## 在 Handler 中使用
```go
type MyHandler struct {
storageService *storage.Service
}
func (h *MyHandler) Upload(c *fiber.Ctx) error {
var req dto.GetUploadURLRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数解析失败")
}
result, err := h.storageService.GetUploadURL(
c.UserContext(),
req.Purpose,
req.FileName,
req.ContentType,
)
if err != nil {
return errors.New(errors.CodeStorageUploadFailed, err.Error())
}
return response.Success(c, result)
}
```
## 在 Worker 中使用
```go
func (h *TaskHandler) HandleTask(ctx context.Context, task *asynq.Task) error {
// 从任务记录获取文件路径
fileKey := importTask.StorageKey
// 下载到临时文件
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, fileKey)
if err != nil {
return err
}
defer cleanup()
// 解析文件
f, _ := os.Open(localPath)
defer f.Close()
// 处理文件内容...
}
```
## 测试验证
运行对象存储功能测试:
```bash
go run scripts/test_storage.go
```
测试内容包括:
1. 生成预签名上传 URL
2. 上传测试文件
3. 检查文件是否存在
4. 下载到临时文件
5. 删除测试文件

View File

@@ -1,250 +0,0 @@
# 对象存储前端接入指南
## 文件上传流程
```
前端 后端 API 对象存储
│ │ │
│ 1. POST /storage/upload-url │
│ {file_name, content_type, purpose} │
│ ─────────────────────────► │
│ │ │
│ 2. 返回 {upload_url, file_key, expires_in} │
│ ◄───────────────────────── │
│ │ │
│ 3. PUT upload_url (文件内容) │
│ ─────────────────────────────────────────────────► │
│ │ │
│ 4. 上传成功 (200 OK) │
│ ◄───────────────────────────────────────────────── │
│ │ │
│ 5. POST /iot-cards/import │
│ {carrier_id, batch_no, file_key} │
│ ─────────────────────────► │
│ │ │
│ 6. 返回任务创建成功 │
│ ◄───────────────────────── │
```
## 获取预签名 URL 接口
### 请求
```http
POST /api/admin/storage/upload-url
Content-Type: application/json
Authorization: Bearer {token}
{
"file_name": "cards.xlsx",
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"purpose": "iot_import"
}
```
### 响应
```json
{
"code": 0,
"message": "成功",
"data": {
"upload_url": "http://obs-helf.cucloud.cn/cmp/imports/2025/01/24/abc123.xlsx?X-Amz-Algorithm=...",
"file_key": "imports/2025/01/24/abc123.xlsx",
"expires_in": 900
}
}
```
### purpose 可选值
| 值 | 说明 | 生成路径 |
|---|------|---------|
| iot_import | ICCID 导入 (Excel) | imports/YYYY/MM/DD/uuid.xlsx |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext |
## 使用预签名 URL 上传文件
获取到 `upload_url` 后,直接使用 PUT 请求上传文件到对象存储:
```javascript
const response = await fetch(upload_url, {
method: 'PUT',
headers: {
'Content-Type': content_type
},
body: file
});
if (response.ok) {
console.log('上传成功');
} else {
console.error('上传失败:', response.status);
}
```
## ICCID 导入接口变更BREAKING CHANGE
### 变更前
```http
POST /api/admin/iot-cards/import
Content-Type: multipart/form-data
carrier_id=1
batch_no=BATCH-2025-01
file=@cards.csv
```
### 变更后
```http
POST /api/admin/iot-cards/import
Content-Type: application/json
Authorization: Bearer {token}
{
"carrier_id": 1,
"batch_no": "BATCH-2025-01",
"file_key": "imports/2025/01/24/abc123.xlsx"
}
```
## 完整代码示例TypeScript
```typescript
interface UploadURLResponse {
upload_url: string;
file_key: string;
expires_in: number;
}
async function uploadAndImportCards(
file: File,
carrierId: number,
batchNo: string
): Promise<void> {
// 1. 获取预签名上传 URL
const urlResponse = await fetch('/api/admin/storage/upload-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({
file_name: file.name,
content_type: file.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
})
});
if (!urlResponse.ok) {
throw new Error('获取上传 URL 失败');
}
const { data } = await urlResponse.json();
const { upload_url, file_key } = data as UploadURLResponse;
// 2. 上传文件到对象存储
const uploadResponse = await fetch(upload_url, {
method: 'PUT',
headers: {
'Content-Type': file.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
},
body: file
});
if (!uploadResponse.ok) {
throw new Error('文件上传失败');
}
// 3. 调用导入接口
const importResponse = await fetch('/api/admin/iot-cards/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({
carrier_id: carrierId,
batch_no: batchNo,
file_key: file_key
})
});
if (!importResponse.ok) {
throw new Error('导入任务创建失败');
}
console.log('导入任务已创建');
}
```
## 错误处理和重试策略
### 预签名 URL 过期
预签名 URL 有效期为 15 分钟。如果上传时 URL 已过期,需要重新获取:
```typescript
async function uploadWithRetry(file: File, purpose: string, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const { upload_url, file_key } = await getUploadURL(file.name, file.type, purpose);
try {
await uploadFile(upload_url, file);
return file_key;
} catch (error) {
if (i === maxRetries - 1) throw error;
console.warn(`上传失败,重试 ${i + 1}/${maxRetries}`);
}
}
}
```
### 网络错误
对象存储上传可能因网络问题失败,建议实现重试机制:
```typescript
async function uploadFile(url: string, file: File, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (response.ok) return;
if (response.status >= 500) {
// 服务端错误,可重试
continue;
}
throw new Error(`上传失败: ${response.status}`);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
```
## 常见问题
### Q: 上传时报 CORS 错误
确保对象存储已配置 CORS 规则允许前端域名访问。
### Q: 预签名 URL 无法使用
1. 检查 URL 是否过期15 分钟有效期)
2. 确保 Content-Type 与获取 URL 时指定的一致
3. 检查文件大小是否超过限制
### Q: file_key 可以重复使用吗
可以。file_key 一旦上传成功就永久有效,可以在多个业务接口中使用同一个 file_key。

View File

@@ -1,317 +0,0 @@
# OpenAPI 文档增强总结
## 更新日期
2026-01-15
## 增强内容
### 1. 自动认证标记
为所有需要认证的端点自动添加 `security` 标记。
**实现方式**
-`RouteSpec` 中使用 `Auth: true` 字段标记需要认证的端点
- `Register` 函数自动传递 `Auth` 字段到 OpenAPI 生成器
- 生成器自动添加 `security: [BearerAuth: []]` 到操作定义
**示例**
公开端点(`Auth: false`
```yaml
/api/admin/login:
post:
summary: 后台登录
# 无 security 字段
```
认证端点(`Auth: true`
```yaml
/api/admin/logout:
post:
summary: 登出
security:
- BearerAuth: []
```
### 2. 标准错误响应
为所有端点自动添加标准错误响应。
**错误响应规则**
- **所有端点**400 (请求参数错误), 500 (服务器内部错误)
- **认证端点**:额外添加 401 (未认证或认证已过期), 403 (无权访问)
**ErrorResponse Schema**
```yaml
ErrorResponse:
type: object
required:
- code
- message
- timestamp
properties:
code:
type: integer
description: 错误码
message:
type: string
description: 错误消息
timestamp:
type: string
format: date-time
description: 时间戳
```
**示例**
公开端点错误响应:
```yaml
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ModelLoginResponse'
"400":
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"500":
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
```
认证端点错误响应:
```yaml
responses:
"200":
description: OK
"400":
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"401":
description: 未认证或认证已过期
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"403":
description: 无权访问
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"500":
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
```
### 3. Bearer Token 认证定义
在 OpenAPI 规范中添加 Bearer Token 认证方案定义。
```yaml
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
```
## 修改的文件
### 核心文件
1. **pkg/openapi/generator.go**
- 修改 `AddOperation` 方法,新增 `requiresAuth` 参数
- 新增 `addSecurityRequirement` 方法:为操作添加认证要求
- 新增 `addStandardErrorResponses` 方法:添加标准错误响应
- 新增 `addErrorResponseSchema` 方法:添加错误响应 Schema 定义
- 新增 `ptrString` 辅助函数
2. **internal/routes/registry.go**
- 更新 `Register` 函数,传递 `spec.Auth` 到生成器
### 路由注册文件
更新以下文件中的 `RouteSpec`,为所有端点添加 `Auth` 字段:
1. **internal/routes/admin.go**
- 公开端点login, refresh-token`Auth: false`
- 认证端点logout, me, password`Auth: true`
2. **internal/routes/h5.go**
- 公开端点login, refresh-token`Auth: false`
- 认证端点logout, me, password`Auth: true`
3. **internal/routes/account.go**
- 所有账号管理端点:`Auth: true` (17 个端点)
4. **internal/routes/role.go**
- 所有角色管理端点:`Auth: true` (9 个端点)
5. **internal/routes/permission.go**
- 所有权限管理端点:`Auth: true` (6 个端点)
### 文档生成脚本
**cmd/gendocs/main.go**
- 添加 `AdminAuth` Handler 到 handlers 结构体
- 确保认证端点包含在生成的文档中
## 验证结果
### 1. 编译验证
```bash
✅ go build ./... - 编译通过
✅ go build ./pkg/openapi/... - OpenAPI 包编译通过
✅ go build ./internal/routes/... - 路由包编译通过
```
### 2. 文档生成验证
```bash
CONFIG_ENV=dev go run cmd/gendocs/main.go
✅ 文档生成成功docs/admin-openapi.yaml
✅ 包含所有端点(认证 + 业务端点)
```
### 3. 内容验证
**Security Scheme**
```bash
✅ grep "securitySchemes:" docs/admin-openapi.yaml
✅ BearerAuth 定义存在
```
**ErrorResponse Schema**
```bash
✅ grep "ErrorResponse:" docs/admin-openapi.yaml
✅ 包含 code, message, timestamp 字段
✅ Required 字段定义正确
```
**公开端点login**
```bash
✅ 只有 400, 500 错误响应
✅ 没有 security 标记
✅ 没有 401, 403 错误响应
```
**认证端点logout**
```bash
✅ 有 400, 401, 403, 500 错误响应
✅ 有 security: [BearerAuth: []]
✅ 错误响应引用 ErrorResponse schema
```
## 使用方法
### 1. 注册新端点
在路由注册时,显式设置 `Auth` 字段:
```go
// 公开端点
Register(router, doc, basePath, "POST", "/public", handler, RouteSpec{
Summary: "公开端点",
Tags: []string{"公开"},
Input: new(RequestModel),
Output: new(ResponseModel),
Auth: false, // 不需要认证
})
// 认证端点
Register(authGroup, doc, basePath, "GET", "/protected", handler, RouteSpec{
Summary: "受保护端点",
Tags: []string{"业务"},
Input: nil,
Output: new(ResponseModel),
Auth: true, // 需要认证
})
```
### 2. 生成文档
```bash
# 开发环境
CONFIG_ENV=dev go run cmd/gendocs/main.go
# 生产环境
CONFIG_ENV=prod go run cmd/gendocs/main.go
```
生成的文档位于 `docs/admin-openapi.yaml`
### 3. 查看文档
**方法 1使用 Swagger UI**
```bash
# 访问 https://editor.swagger.io/
# 将 docs/admin-openapi.yaml 内容粘贴到编辑器
```
**方法 2使用 Postman**
```bash
# File → Import → Upload Files
# 选择 docs/admin-openapi.yaml
```
**方法 3使用 Redoc**
```bash
npx @redocly/cli preview-docs docs/admin-openapi.yaml
```
## 后续优化(可选)
当前已完成的高优先级任务:
- ✅ 自动添加 security 标记
- ✅ 自动添加标准错误响应
- ✅ 定义 ErrorResponse schema
- ✅ 更新所有路由注册
低优先级增强(可在后续迭代完成):
- [ ] 为请求/响应模型添加示例值example
- [ ] 为字段添加详细的验证规则说明(自动从 validator 标签提取)
这些低优先级功能不影响当前文档的可用性,可以根据需要在后续版本中添加。
## 影响范围
**破坏性变更**:无
**向后兼容**:是
- 旧代码不需要修改即可工作
- 未设置 `Auth` 字段的 RouteSpec 默认为 `false`(公开端点)
**API 变更**:无
- 只影响 OpenAPI 文档生成
- 不影响运行时行为
## 总结
本次增强为 OpenAPI 文档自动生成系统添加了以下关键功能:
1. **自动认证标记**:通过 `Auth` 字段自动为认证端点添加 `security` 标记
2. **标准错误响应**:自动为所有端点添加统一的错误响应定义
3. **错误响应 Schema**:定义了标准的 `ErrorResponse` 结构
这些增强使得:
- 文档更加完整和规范
- API 使用者能清楚了解哪些端点需要认证
- 错误处理文档化,提升 API 可用性
- 减少手动维护文档的工作量
所有高优先级功能已完成并验证通过,可以投入使用。

View File

@@ -1,181 +0,0 @@
# 订单超时自动取消功能
## 功能概述
为待支付订单(微信/支付宝)添加 30 分钟超时自动取消机制。超时后自动取消订单并解冻钱包余额(如有冻结)。
## 核心设计
### 超时流程
```
用户下单(微信/支付宝)
├── 设置 expires_at = 当前时间 + 30 分钟
├── 订单状态: payment_status = 1待支付
├── 场景 1: 用户在 30 分钟内支付
│ ├── 支付成功 → 清除 expires_at设为 NULL
│ └── 订单正常完成
└── 场景 2: 超过 30 分钟未支付
├── Asynq Scheduler 每分钟触发扫描
├── 查询 expires_at <= NOW() AND payment_status = 1
├── 取消订单 → payment_status = 5已取消
├── 清除 expires_at
└── 解冻钱包余额(如有)
```
### 不设置超时的场景
- **钱包支付**:立即扣款,无需超时
- **线下支付**:管理员手动确认,无需超时
- **混合支付**:需要在线支付部分才设置超时
## 技术实现
### 数据库变更
```sql
-- 迁移文件: migrations/000069_add_order_expiration.up.sql
ALTER TABLE tb_order ADD COLUMN expires_at TIMESTAMPTZ;
-- 部分索引: 仅索引待支付订单,减少索引大小
CREATE INDEX idx_order_expires ON tb_order (expires_at, payment_status)
WHERE expires_at IS NOT NULL AND payment_status = 1;
```
### 涉及文件
| 层级 | 文件 | 变更说明 |
|------|------|----------|
| 迁移 | `migrations/000069_add_order_expiration.up.sql` | 添加 expires_at 字段和索引 |
| 迁移 | `migrations/000069_add_order_expiration.down.sql` | 回滚脚本 |
| 常量 | `pkg/constants/constants.go` | 添加任务类型和超时参数 |
| 模型 | `internal/model/order.go` | 添加 ExpiresAt 字段 |
| DTO | `internal/model/dto/order_dto.go` | 添加 ExpiresAt、IsExpired 响应字段 |
| Store | `internal/store/postgres/order_store.go` | 添加 FindExpiredOrders、is_expired 过滤 |
| Service | `internal/service/order/service.go` | 创建订单设置超时、取消逻辑、批量取消 |
| 任务 | `internal/task/order_expire.go` | 订单超时任务处理器 |
| 任务 | `internal/task/alert_check.go` | 告警检查任务处理器(从 ticker 迁移) |
| 任务 | `internal/task/data_cleanup.go` | 数据清理任务处理器(从 ticker 迁移) |
| 队列 | `pkg/queue/types.go` | 添加 OrderExpirer 接口和 WorkerStores/Services 字段 |
| 队列 | `pkg/queue/handler.go` | 注册 3 个新任务处理器 |
| Bootstrap | `internal/bootstrap/worker_stores.go` | 添加 CardWallet Store |
| Bootstrap | `internal/bootstrap/worker_services.go` | 添加 OrderService 初始化 |
| Worker | `cmd/worker/main.go` | 替换 ticker 为 Asynq Scheduler |
### 常量定义
```go
// pkg/constants/constants.go
TaskTypeOrderExpire = "order:expire" // 订单超时任务
TaskTypeAlertCheck = "alert:check" // 告警检查任务
TaskTypeDataCleanup = "data:cleanup" // 数据清理任务
OrderExpireTimeout = 30 * time.Minute // 订单超时时间
OrderExpireBatchSize = 100 // 每次批量取消数量
```
### 接口变更
#### 订单列表查询新增过滤参数
```
GET /api/admin/orders?is_expired=true
GET /api/h5/orders?is_expired=true
```
- `is_expired=true`: 仅返回已超时的订单
- `is_expired=false`: 仅返回未超时的订单
#### 订单响应新增字段
```json
{
"expires_at": "2025-02-28T12:30:00+08:00",
"is_expired": false
}
```
- `expires_at`: 超时时间,`null` 表示无超时(钱包/线下支付)
- `is_expired`: 是否已超时(计算字段)
## 定时任务调度器重构
### 变更前time.Ticker
```go
// cmd/worker/main.go 中的 goroutine
alertChecker := startAlertChecker(ctx, ...) // time.Ticker 每分钟
cleanupChecker := startCleanupScheduler(ctx, ...) // time.Timer 每天凌晨 2 点
```
**问题**
- 单点运行,无法分布式
- 无重试机制
- 无任务状态监控
### 变更后Asynq Scheduler
```go
// Asynq Scheduler 统一管理
asynqScheduler.Register("@every 1m", asynq.NewTask("order:expire", nil))
asynqScheduler.Register("@every 1m", asynq.NewTask("alert:check", nil))
asynqScheduler.Register("0 2 * * *", asynq.NewTask("data:cleanup", nil))
```
**优势**
- 通过 Redis 实现分布式调度
- 自动重试失败任务
- 可通过 Asynq Dashboard 监控
- 统一的任务处理模式
### 调度规则
| 任务 | 调度表达式 | 说明 |
|------|-----------|------|
| 订单超时取消 | `@every 1m` | 每分钟扫描一次 |
| 告警检查 | `@every 1m` | 每分钟检查一次 |
| 数据清理 | `0 2 * * *` | 每天凌晨 2 点执行 |
## 钱包解冻逻辑
### 取消订单时的解冻流程
```
cancelOrder(ctx, order)
├── 幂等更新: WHERE payment_status = 1 → 5
├── 清除 expires_at
├── 如果是代理钱包支付 (payment_method = wallet, buyer_type = agent)
│ └── AgentWalletStore.UnfreezeBalanceWithTx(tx, shopID, amount)
└── 如果是卡钱包支付 (payment_method = wallet/mixed, buyer_type != agent)
└── 直接更新 frozen_balance -= amount (WHERE frozen_balance >= amount)
```
### 幂等性保障
- 使用 `WHERE payment_status = 1` 条件更新,确保只取消待支付订单
- `RowsAffected == 0` 说明订单已被处理(已支付或已取消),直接跳过
- 批量取消时,单个订单失败不影响其他订单
## 循环依赖解决方案
`internal/service/order` 导入 `pkg/queue`(使用 queue.Client`pkg/queue/types.go` 需要引用 OrderService。
**解决方案**:在 `pkg/queue/types.go` 定义 `OrderExpirer` 接口,`internal/task/order_expire.go` 定义同名局部接口。Go 的结构化类型系统使 `order.Service` 自动满足两个接口,无需显式声明。
```go
// pkg/queue/types.go
type OrderExpirer interface {
CancelExpiredOrders(ctx context.Context) (int, error)
}
// WorkerServices 中使用接口类型
OrderExpirer OrderExpirer
// internal/task/order_expire.go局部接口避免导入 pkg/queue
type OrderExpirer interface {
CancelExpiredOrders(ctx context.Context) (int, error)
}
```

View File

@@ -1,333 +0,0 @@
# 订单支付系统功能总结
## 概述
add-order-payment 提案实现了完整的订单和支付流程,核心是"强充"机制:用户不能直接给钱包充值,必须通过购买套餐来充值。这样每笔充值都有对应的套餐购买记录,便于佣金计算和业务追踪。
## 核心功能
### 1. 订单管理
**新增模型**
- `Order`:订单模型,记录套餐购买信息
- `OrderItem`:订单明细(支持一个订单购买多个套餐)
**订单字段**
- 订单号、订单类型(单卡购买/设备购买)
- 买家信息(个人客户/代理店铺)
- 关联的卡/设备 ID
- 支付金额、支付状态、支付方式
- 佣金计算状态
**业务流程**
1. 用户选择套餐,创建订单
2. 用户支付(微信/支付宝/钱包余额)
3. 支付成功后,套餐生效,流量额度增加
4. 触发佣金计算Phase 5
### 2. API 端点
**后台管理端** (`/api/admin/orders`):
- `POST /orders` - 创建订单
- `GET /orders` - 获取订单列表(支持分页和筛选)
- `GET /orders/:id` - 获取订单详情
- `POST /orders/:id/cancel` - 取消订单
**H5 端** (`/api/h5/orders`):
- `POST /orders` - 创建订单
- `GET /orders` - 获取订单列表
- `GET /orders/:id` - 获取订单详情
- `POST /orders/:id/wallet-pay` - 钱包支付
**支付回调** (`/api/callback`):
- `POST /wechat-pay` - 微信支付回调
- `POST /alipay` - 支付宝回调
### 3. 业务规则
**购买限制**
- 只能购买卡/设备关联的套餐系列下的套餐
- 只能购买已上架且启用的套餐
- 设备购买时,套餐分配给设备下所有卡(流量共享)
- 订单金额 = 套餐零售价(代理设置的售价)
**支付流程**
- 钱包支付:事务扣减余额 → 更新订单状态 → 激活套餐 → 更新销售统计
- 第三方支付:验证签名 → 幂等处理 → 激活套餐 → 更新销售统计
**套餐激活**
- 创建 PackageUsage 记录
- 更新 ShopSeriesCommissionStats销售统计
- 快照佣金配置版本
## 数据库设计
### 表结构
**tb_order**(订单表):
- `id`, `created_at`, `updated_at`, `deleted_at`
- `creator`, `updater`
- `order_no`(订单号,唯一)
- `order_type`订单类型1=单卡购买2=设备购买)
- `buyer_type`买家类型1=个人客户2=代理店铺)
- `buyer_id`(买家 ID
- `iot_card_id`IoT 卡 ID
- `device_id`(设备 ID
- `total_amount`(总金额,分)
- `payment_method`支付方式1=钱包2=微信3=支付宝)
- `payment_status`支付状态1=待支付2=已支付3=已取消)
- `paid_at`(支付时间)
- `commission_status`佣金状态1=未计算2=已计算)
- `commission_config_version`(佣金配置快照版本)
**tb_order_item**(订单明细表):
- `id`, `created_at`, `updated_at`, `deleted_at`
- `order_id`(订单 ID
- `package_id`(套餐 ID
- `package_name`(套餐名称)
- `quantity`(数量)
- `unit_price`(单价,分)
- `amount`(小计金额,分)
### 索引设计
```sql
-- tb_order
CREATE UNIQUE INDEX idx_order_no ON tb_order(order_no);
CREATE INDEX idx_buyer ON tb_order(buyer_type, buyer_id);
CREATE INDEX idx_payment_status ON tb_order(payment_status);
CREATE INDEX idx_iot_card ON tb_order(iot_card_id);
CREATE INDEX idx_device ON tb_order(device_id);
-- tb_order_item
CREATE INDEX idx_order_id ON tb_order_item(order_id);
CREATE INDEX idx_package_id ON tb_order_item(package_id);
```
## 代码结构
### Store 层
**OrderStore** (`internal/store/postgres/order_store.go`):
- `Create(ctx, order) error` - 创建订单
- `GetByID(ctx, id) (*Order, error)` - 按 ID 查询
- `GetByIDWithItems(ctx, id) (*Order, []OrderItem, error)` - 查询订单及明细
- `GetByOrderNo(ctx, orderNo) (*Order, error)` - 按订单号查询
- `Update(ctx, order) error` - 更新订单
- `UpdatePaymentStatus(ctx, id, status, paidAt) error` - 更新支付状态
- `List(ctx, req) ([]Order, int64, error)` - 分页查询
- `GenerateOrderNo() string` - 生成订单号
**OrderItemStore** (`internal/store/postgres/order_item_store.go`):
- `BatchCreate(ctx, items) error` - 批量创建明细
- `ListByOrderID(ctx, orderID) ([]OrderItem, error)` - 查询订单明细
### Service 层
**PurchaseValidationService** (`internal/service/purchase_validation/service.go`):
- `ValidateCardPurchase(ctx, cardID, packageID) error` - 验证卡购买权限
- `ValidateDevicePurchase(ctx, deviceID, packageID) error` - 验证设备购买权限
- `ValidatePackageStatus(ctx, packageID) error` - 验证套餐状态
- `GetPurchasePrice(ctx, packageID, buyerType, buyerID) (int64, error)` - 获取购买价格
**OrderService** (`internal/service/order/service.go`):
- `Create(ctx, req) (*Order, error)` - 创建订单
- `Get(ctx, id) (*OrderResponse, error)` - 获取订单详情
- `List(ctx, req) ([]OrderResponse, int64, error)` - 获取订单列表
- `Cancel(ctx, id) error` - 取消订单
- `WalletPay(ctx, id, req) error` - 钱包支付
- `HandlePaymentCallback(ctx, orderNo, paymentMethod) error` - 处理支付回调
### Handler 层
**AdminOrderHandler** (`internal/handler/admin/order.go`):
- `Create(c)` - 创建订单
- `Get(c)` - 获取订单详情
- `List(c)` - 获取订单列表
- `Cancel(c)` - 取消订单
**H5OrderHandler** (`internal/handler/h5/order.go`):
- `Create(c)` - 创建订单
- `Get(c)` - 获取订单详情
- `List(c)` - 获取订单列表
- `WalletPay(c)` - 钱包支付
**PaymentCallbackHandler** (`internal/handler/callback/payment.go`):
- `WechatPayCallback(c)` - 微信支付回调
- `AlipayCallback(c)` - 支付宝回调
## 测试覆盖
### 单元测试
**OrderStore 测试** (`order_store_test.go`):
- ✅ 创建订单
- ✅ 按 ID 查询
- ✅ 按 ID 查询(含明细)
- ✅ 按订单号查询
- ✅ 更新订单
- ✅ 更新支付状态
- ✅ 分页查询
- ✅ 生成订单号
**OrderItemStore 测试** (`order_item_store_test.go`):
- ✅ 批量创建明细
- ✅ 查询订单明细
**PurchaseValidationService 测试** (`service_test.go`):
- ✅ 验证卡购买(成功/卡不存在/套餐系列不匹配/套餐未上架)
- ✅ 验证设备购买(成功/设备不存在/套餐系列不匹配)
- ✅ 获取购买价格(个人客户零售价/代理成本价)
**OrderService 测试** (`service_test.go`):
- ✅ 创建单卡订单
- ✅ 创建设备订单
- ✅ 获取订单详情
- ✅ 获取订单列表
- ✅ 取消订单
- ✅ 钱包支付(成功/订单不存在/无权操作/重复支付)
### 集成测试
- ✅ 编译验证:`go build ./...`
- ✅ 服务启动验证
- ✅ OpenAPI 文档生成验证
## 验证结果
### 编译验证
```bash
✅ go build ./... 编译通过
```
### 服务启动
```bash
✅ ./api 启动成功
✅ /health 健康检查通过
```
### OpenAPI 文档
```yaml
✅ /api/admin/orders 路由已生成
✅ /api/h5/orders 路由已生成
✅ /api/callback/wechat-pay 路由已生成
✅ /api/callback/alipay 路由已生成
```
### 测试通过率
```bash
✅ OrderStore 单元测试8/8 通过
✅ OrderItemStore 单元测试4/4 通过
✅ PurchaseValidationService 测试3/3 通过
✅ OrderService 测试6/6 通过
```
## 使用指南
### 创建订单(单卡购买)
**请求**
```http
POST /api/h5/orders
Authorization: Bearer {token}
Content-Type: application/json
{
"order_type": 1,
"iot_card_id": 101,
"package_ids": [201, 202]
}
```
**响应**
```json
{
"code": 0,
"data": {
"id": 1001,
"order_no": "ORD202601281234567890",
"order_type": 1,
"buyer_type": 1,
"buyer_id": 301,
"iot_card_id": 101,
"total_amount": 39900,
"payment_status": 1,
"items": [
{
"id": 2001,
"package_id": 201,
"package_name": "月套餐 3000G",
"quantity": 1,
"unit_price": 19900,
"amount": 19900
}
]
},
"msg": "success"
}
```
### 钱包支付
**请求**
```http
POST /api/h5/orders/1001/wallet-pay
Authorization: Bearer {token}
Content-Type: application/json
{
"payment_method": 1
}
```
**响应**
```json
{
"code": 0,
"msg": "支付成功"
}
```
### 查询订单列表
**请求**
```http
GET /api/h5/orders?payment_status=2&page=1&page_size=20
Authorization: Bearer {token}
```
**响应**
```json
{
"code": 0,
"data": {
"list": [...],
"total": 100
},
"msg": "success"
}
```
## 依赖关系
**依赖**
- Phase 3add-card-device-series-binding- 卡/设备套餐系列关联
- Wallet 模型 - 钱包余额管理
**被依赖**
- Phase 5add-one-time-commission- 一次性佣金计算
## 后续优化
1. **支付集成**:完成微信支付、支付宝支付的真实对接
2. **订单超时**:实现订单超时自动取消机制
3. **支付重试**:处理支付失败的重试逻辑
4. **退款流程**:实现订单退款功能
5. **发票管理**:支持开具电子发票
## 相关文档
- [提案文档](../../openspec/changes/add-order-payment/proposal.md)
- [设计文档](../../openspec/changes/add-order-payment/design.md)
- [任务清单](../../openspec/changes/add-order-payment/tasks.md)
- [项目规范](../../AGENTS.md)

View File

@@ -1,456 +0,0 @@
# 订单状态与佣金系统分析报告
## 一、订单状态定义
### 1.1 订单支付状态Payment Status
**文件**: `internal/model/order.go` (第 98-104 行)
```go
const (
PaymentStatusPending = 1 // 待支付
PaymentStatusPaid = 2 // 已支付
PaymentStatusCancelled = 3 // 已取消
PaymentStatusRefunded = 4 // 已退款
)
```
**数据库字段**: `tb_order.payment_status` (int)
**说明**:
- 订单创建时默认为 `PaymentStatusPending`(待支付)
- 支付成功后更新为 `PaymentStatusPaid`(已支付)
- 待支付订单超时 30 分钟自动取消,状态变为 `PaymentStatusCancelled`
- 退款后状态变为 `PaymentStatusRefunded`
### 1.2 订单佣金状态Commission Status
**文件**: `internal/model/order.go` (第 106-110 行)
```go
const (
CommissionStatusPending = 1 // 待计算
CommissionStatusCalculated = 2 // 已计算
)
```
**数据库字段**: `tb_order.commission_status` (int)
**说明**:
- 订单创建时默认为 `CommissionStatusPending`(待计算)
- 佣金计算完成后更新为 `CommissionStatusCalculated`(已计算)
- 佣金计算是异步任务,通过 Asynq 队列处理
---
## 二、订单状态流转逻辑
### 2.1 订单创建流程
**文件**: `internal/service/order/service.go`
#### 支付方式决定初始状态:
**1. 线下支付offline- 平台代购**
- 创建订单时直接设置 `PaymentStatus = PaymentStatusPaid`(已支付)
- 立即激活套餐
- 立即入队佣金计算任务
- 代码位置: 第 189-209 行、第 464-502 行
**2. 钱包支付wallet- 代理自购或代购**
- 创建订单时直接设置 `PaymentStatus = PaymentStatusPaid`(已支付)
- 在事务中完成:创建订单 → 扣款 → 激活套餐
- 代码位置: 第 210-289 行、第 504-573 行
**3. 微信/支付宝支付wechat/alipay- H5 端**
- 创建订单时设置 `PaymentStatus = PaymentStatusPending`(待支付)
- 设置 `ExpiresAt` 为 30 分钟后(用于自动取消)
- 支付成功后由支付回调更新状态为 `PaymentStatusPaid`
- 代码位置: 第 754-927 行
### 2.2 订单超时自动取消
**文件**: `internal/service/order/service.go` (第 1314-1349 行)
```go
func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
// 查询超时订单expires_at < now
orders, err := s.orderStore.FindExpiredOrders(ctx, constants.OrderExpireBatchSize)
// 批量取消订单
for _, order := range orders {
s.cancelOrder(ctx, order)
}
}
```
**取消逻辑** (第 1351-1385 行):
- 只有 `PaymentStatusPending` 的订单才能取消
- 更新状态为 `PaymentStatusCancelled`
- 清除 `ExpiresAt` 字段
- 如果是钱包支付,解冻钱包余额
**触发方式**:
- Asynq Scheduler 每分钟执行一次
- 常量: `constants.OrderExpireTimeout = 30 * time.Minute`
---
## 三、佣金系统与订单状态的关系
### 3.1 佣金计算触发条件
**文件**: `internal/service/commission_calculation/service.go` (第 74-119 行)
```go
func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
// 1. 检查订单佣金状态
if order.CommissionStatus == model.CommissionStatusCalculated {
return nil // 已计算,跳过
}
// 2. 计算成本价差佣金
costDiffRecords, err := s.CalculateCostDiffCommission(ctx, order)
// 3. 入账佣金
for _, record := range costDiffRecords {
s.creditCommissionInTx(ctx, tx, record)
}
// 4. 触发一次性佣金(仅非代购订单)
if !order.IsPurchaseOnBehalf {
s.triggerOneTimeCommissionForCardInTx(ctx, tx, order, *order.IotCardID)
}
// 5. 更新订单佣金状态为已计算
tx.Model(&model.Order{}).Update("commission_status", CommissionStatusCalculated)
}
```
**触发时机**:
- 线下支付offline订单创建后立即入队
- 钱包支付wallet订单创建后立即入队
- 微信/支付宝支付:支付成功回调后入队
### 3.2 佣金类型与订单关系
#### 成本价差佣金Cost Diff Commission
**文件**: `internal/service/commission_calculation/service.go` (第 121-230 行)
```go
func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) {
// 计算销售店铺的利润
sellerProfit := order.TotalAmount - order.SellerCostPrice
// 沿着代理链向上分配佣金
// 每一级代理的佣金 = 该级成本价 - 下级成本价
}
```
**关键字段**:
- `order.SellerShopID`: 销售店铺ID佣金归属
- `order.SellerCostPrice`: 销售成本价(用于计算利润)
- `order.SeriesID`: 系列ID用于查询分配配置
**佣金状态**: `CommissionStatusReleased`(已入账)
#### 一次性佣金One-Time Commission
**触发条件**:
- 仅非代购订单触发(`!order.IsPurchaseOnBehalf`
- 单卡首次购买或设备首次购买时触发
- 代码位置: 第 102-111 行
---
## 四、钱包冻结与佣金提现流程
### 4.1 钱包冻结状态定义
**文件**: `pkg/constants/wallet.go` (第 17-22 行)
```go
const (
AgentWalletStatusNormal = 1 // 正常
AgentWalletStatusFrozen = 2 // 冻结(钱包整体冻结)
AgentWalletStatusClosed = 3 // 关闭
)
```
**冻结余额字段**:
- `tb_agent_wallet.frozen_balance`: 冻结余额(分)
- `tb_agent_wallet.balance`: 总余额(分)
- 可用余额 = balance - frozen_balance
### 4.2 佣金提现流程3 阶段)
#### 阶段 1: 代理发起提现申请
**文件**: `internal/service/shop_commission/service.go` (第 517-649 行)
```go
func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req *dto.CreateMyWithdrawalReq) {
// 1. 验证可用余额
if req.Amount > wallet.GetAvailableBalance() {
return "可提现余额不足"
}
// 2. 冻结余额(在事务中)
tx.Model(&AgentWallet{}).
Where("id = ? AND balance - frozen_balance >= ?", wallet.ID, req.Amount).
Updates(map[string]interface{}{
"frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
})
// 3. 创建提现申请记录
withdrawalRequest = &CommissionWithdrawalRequest{
Status: 1, // 待审核
}
// 4. 创建钱包流水
transaction = &AgentWalletTransaction{
TransactionType: "withdrawal",
Amount: -req.Amount,
Status: TransactionStatusProcessing,
}
}
```
**状态**: `WithdrawalStatusPending`(待审核)
#### 阶段 2: 平台审核通过
**文件**: `internal/service/commission_withdrawal/service.go` (第 145-256 行)
```go
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdrawalReq) {
// 1. 检查冻结余额
if wallet.FrozenBalance < amount {
return "钱包冻结余额不足"
}
// 2. 从冻结余额扣款(在事务中)
s.agentWalletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount)
// 3. 创建提现交易流水
transaction = &AgentWalletTransaction{
TransactionType: "withdrawal",
Amount: -amount,
}
// 4. 更新提现申请状态
updates["status"] = WithdrawalStatusApproved
}
```
**状态**: `WithdrawalStatusApproved`(已通过)
#### 阶段 3: 平台审核拒绝
**文件**: `internal/service/commission_withdrawal/service.go` (第 258-310 行)
```go
func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawalReq) {
// 1. 解冻余额(在事务中)
s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, withdrawal.Amount)
// 2. 创建退款交易流水
transaction = &AgentWalletTransaction{
TransactionType: "refund",
Amount: withdrawal.Amount, // 正数,增加可用余额
}
// 3. 更新提现申请状态
updates["status"] = WithdrawalStatusRejected
}
```
**状态**: `WithdrawalStatusRejected`(已拒绝)
### 4.3 钱包余额计算
**文件**: `internal/service/shop_commission/service.go` (第 154-198 行)
```go
func (s *Service) buildFundSummaryItem(shop, mainWallet, commissionWallet) {
// 佣金钱包
balance = commissionWallet.Balance
frozenBalance = commissionWallet.FrozenBalance
// 总佣金 = 可用 + 冻结 + 已提现
totalCommission := balance + frozenBalance + withdrawnAmount
// 未提现佣金 = 总佣金 - 已提现
unwithdrawCommission := totalCommission - withdrawnAmount
// 可提现佣金 = 可用 - 提现中
availableCommission := balance - withdrawingAmount
return ShopFundSummaryItem{
TotalCommission: totalCommission,
WithdrawnCommission: withdrawnAmount,
UnwithdrawCommission: unwithdrawCommission,
FrozenCommission: frozenBalance,
WithdrawingCommission: withdrawingAmount,
AvailableCommission: availableCommission,
}
}
```
---
## 五、关键数据模型
### 5.1 订单模型
**文件**: `internal/model/order.go`
| 字段 | 类型 | 说明 |
|------|------|------|
| `payment_status` | int | 支付状态1-待支付 2-已支付 3-已取消 4-已退款) |
| `commission_status` | int | 佣金状态1-待计算 2-已计算) |
| `seller_shop_id` | uint | 销售店铺ID用于成本价差佣金 |
| `seller_cost_price` | int64 | 销售成本价(分) |
| `series_id` | uint | 系列ID用于查询分配配置 |
| `is_purchase_on_behalf` | bool | 是否代购订单 |
| `expires_at` | time.Time | 订单过期时间(待支付订单) |
### 5.2 佣金记录模型
**文件**: `internal/model/commission.go`
| 字段 | 类型 | 说明 |
|------|------|------|
| `shop_id` | uint | 店铺ID佣金归属 |
| `order_id` | uint | 订单ID |
| `commission_source` | string | 佣金来源cost_diff-成本价差 one_time-一次性) |
| `amount` | int64 | 佣金金额(分) |
| `status` | int | 状态1-已入账 2-已失效) |
### 5.3 代理钱包模型
**文件**: `internal/model/agent_wallet.go`
| 字段 | 类型 | 说明 |
|------|------|------|
| `balance` | int64 | 总余额(分) |
| `frozen_balance` | int64 | 冻结余额(分) |
| `status` | int | 钱包状态1-正常 2-冻结 3-关闭) |
| `version` | int | 版本号(乐观锁) |
### 5.4 提现申请模型
**文件**: `internal/model/commission_withdrawal_request.go`
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | int | 状态1-待审核 2-已通过 3-已拒绝) |
| `amount` | int64 | 提现金额(分) |
| `frozen_balance` | int64 | 冻结余额(分) |
---
## 六、关键常量
### 6.1 订单相关常量
**文件**: `pkg/constants/constants.go`
```go
const (
OrderExpireTimeout = 30 * time.Minute // 订单超时时间
OrderExpireBatchSize = 100 // 批量处理数量
)
```
### 6.2 钱包相关常量
**文件**: `pkg/constants/wallet.go`
```go
const (
AgentWalletStatusNormal = 1 // 正常
AgentWalletStatusFrozen = 2 // 冻结
AgentWalletStatusClosed = 3 // 关闭
AgentTransactionTypeWithdrawal = "withdrawal" // 提现
TransactionStatusProcessing = 3 // 处理中
)
```
---
## 七、订单状态与佣金的完整流程图
```
订单创建
├─ 线下支付offline
│ ├─ PaymentStatus = PaymentStatusPaid已支付
│ ├─ 立即激活套餐
│ ├─ 入队佣金计算
│ └─ CommissionStatus = CommissionStatusCalculated
├─ 钱包支付wallet
│ ├─ PaymentStatus = PaymentStatusPaid已支付
│ ├─ 扣款 → 激活套餐(事务)
│ ├─ 入队佣金计算
│ └─ CommissionStatus = CommissionStatusCalculated
└─ 微信/支付宝wechat/alipay
├─ PaymentStatus = PaymentStatusPending待支付
├─ ExpiresAt = now + 30min
├─ 支付成功回调
│ ├─ PaymentStatus = PaymentStatusPaid
│ ├─ 激活套餐
│ ├─ 入队佣金计算
│ └─ CommissionStatus = CommissionStatusCalculated
└─ 超时自动取消30min
├─ PaymentStatus = PaymentStatusCancelled
├─ 解冻钱包余额
└─ CommissionStatus = CommissionStatusPending不计算
佣金计算流程
├─ 成本价差佣金
│ ├─ 计算销售店铺利润
│ ├─ 沿代理链向上分配
│ └─ 入账到分佣钱包
└─ 一次性佣金(非代购订单)
├─ 单卡/设备首次购买触发
└─ 入账到分佣钱包
提现流程3 阶段)
├─ 阶段 1: 代理发起申请
│ ├─ 验证可用余额
│ ├─ 冻结余额frozen_balance += amount
│ ├─ 创建提现申请Status = 待审核)
│ └─ 创建钱包流水Status = 处理中)
├─ 阶段 2a: 平台审核通过
│ ├─ 从冻结余额扣款frozen_balance -= amount
│ ├─ 创建提现交易流水
│ └─ 更新提现申请Status = 已通过)
└─ 阶段 2b: 平台审核拒绝
├─ 解冻余额frozen_balance -= amount
├─ 创建退款交易流水
└─ 更新提现申请Status = 已拒绝)
```
---
## 八、重要业务规则
### 8.1 订单支付规则
1. **待支付订单自动取消**: 30 分钟未支付自动取消,解冻钱包余额
2. **幂等性检查**: 防止同一买家对同一资源短时间内重复下单
3. **强充要求**: 首次购买需满足最低充值要求
### 8.2 佣金计算规则
1. **成本价差佣金**: 仅当 `seller_profit > 0` 时才计算
2. **一次性佣金**: 仅非代购订单触发,且仅首次购买时触发
3. **佣金链路**: 沿代理层级向上分配,若中间断裂则标记为待审核
### 8.3 提现规则
1. **冻结机制**: 提现申请时冻结余额,防止重复提现
2. **可用余额**: balance - frozen_balance用于验证提现金额
3. **手续费**: 提现时扣除手续费,实际到账 = 申请金额 - 手续费
---
## 九、相关文件索引
| 功能 | 文件 | 关键行数 |
|------|------|---------|
| 订单模型 | `internal/model/order.go` | 9-138 |
| 订单创建 | `internal/service/order/service.go` | 114-354, 359-674, 679-928 |
| 订单超时取消 | `internal/service/order/service.go` | 1314-1385 |
| 佣金计算 | `internal/service/commission_calculation/service.go` | 74-230 |
| 提现申请 | `internal/service/shop_commission/service.go` | 517-649 |
| 提现审核 | `internal/service/commission_withdrawal/service.go` | 145-310 |
| 钱包模型 | `internal/model/agent_wallet.go` | 9-94 |
| 钱包常量 | `pkg/constants/wallet.go` | 1-234 |
| 订单常量 | `pkg/constants/constants.go` | 153-166 |

View File

@@ -1,196 +0,0 @@
# 订单状态与佣金系统 - 快速参考
## 订单支付状态速查表
| 状态值 | 常量名 | 说明 | 触发条件 | 后续操作 |
|-------|-------|------|---------|---------|
| 1 | `PaymentStatusPending` | 待支付 | 微信/支付宝支付创建订单 | 支付回调或30分钟超时 |
| 2 | `PaymentStatusPaid` | 已支付 | 线下/钱包支付或支付成功回调 | 激活套餐、计算佣金 |
| 3 | `PaymentStatusCancelled` | 已取消 | 待支付订单超时30分钟 | 解冻钱包余额 |
| 4 | `PaymentStatusRefunded` | 已退款 | 退款操作 | 返还金额 |
## 订单佣金状态速查表
| 状态值 | 常量名 | 说明 | 触发条件 | 后续操作 |
|-------|-------|------|---------|---------|
| 1 | `CommissionStatusPending` | 待计算 | 订单创建时默认 | 异步任务计算佣金 |
| 2 | `CommissionStatusCalculated` | 已计算 | 佣金计算完成 | 佣金入账到分佣钱包 |
## 订单创建流程速查
### 线下支付offline
```
创建订单 → PaymentStatus=2(已支付) → 激活套餐 → 入队佣金计算
```
### 钱包支付wallet
```
创建订单 → PaymentStatus=2(已支付) → 扣款 → 激活套餐 → 入队佣金计算
```
### 微信/支付宝wechat/alipay
```
创建订单 → PaymentStatus=1(待支付) → 支付回调 → PaymentStatus=2 → 激活套餐 → 入队佣金计算
30分钟超时 → PaymentStatus=3(已取消) → 解冻余额
```
## 钱包冻结状态速查表
| 字段 | 说明 | 计算方式 |
|------|------|---------|
| `balance` | 总余额 | 充值 + 佣金入账 - 扣款 |
| `frozen_balance` | 冻结余额 | 提现申请时冻结 |
| 可用余额 | 可提现金额 | `balance - frozen_balance` |
## 提现流程速查
### 阶段 1: 代理发起申请
```
验证可用余额 ✓
冻结余额 (frozen_balance += amount)
创建提现申请 (Status=1 待审核)
创建钱包流水 (Status=3 处理中)
```
### 阶段 2a: 平台审核通过
```
检查冻结余额 ✓
从冻结余额扣款 (frozen_balance -= amount)
创建提现交易流水
更新提现申请 (Status=2 已通过)
```
### 阶段 2b: 平台审核拒绝
```
解冻余额 (frozen_balance -= amount)
创建退款交易流水
更新提现申请 (Status=3 已拒绝)
```
## 佣金类型速查
| 佣金类型 | 常量值 | 触发条件 | 计算方式 |
|---------|-------|---------|---------|
| 成本价差 | `cost_diff` | 所有订单 | 销售价 - 销售成本价 |
| 一次性 | `one_time` | 非代购订单首次购买 | 根据配置 |
## 关键代码位置速查
### 订单相关
- 订单模型: `internal/model/order.go:98-110`
- 订单创建: `internal/service/order/service.go:114-928`
- 订单超时: `internal/service/order/service.go:1314-1385`
### 佣金相关
- 佣金计算: `internal/service/commission_calculation/service.go:74-119`
- 成本价差: `internal/service/commission_calculation/service.go:121-230`
### 提现相关
- 发起申请: `internal/service/shop_commission/service.go:517-649`
- 审核通过: `internal/service/commission_withdrawal/service.go:145-256`
- 审核拒绝: `internal/service/commission_withdrawal/service.go:258-310`
## 常用常量速查
```go
// 订单超时
constants.OrderExpireTimeout = 30 * time.Minute
// 钱包状态
constants.AgentWalletStatusNormal = 1 // 正常
constants.AgentWalletStatusFrozen = 2 // 冻结
constants.AgentWalletStatusClosed = 3 // 关闭
// 交易状态
constants.TransactionStatusSuccess = 1 // 成功
constants.TransactionStatusFailed = 2 // 失败
constants.TransactionStatusProcessing = 3 // 处理中
// 提现状态
constants.WithdrawalStatusPending = 1 // 待审核
constants.WithdrawalStatusApproved = 2 // 已通过
constants.WithdrawalStatusRejected = 3 // 已拒绝
```
## 常见问题速查
### Q: 订单什么时候计算佣金?
A: 订单支付成功后立即入队佣金计算任务(异步)。线下/钱包支付创建时已支付,微信/支付宝支付在回调时支付。
### Q: 提现时冻结的余额什么时候解冻?
A:
- 审核通过:从冻结余额扣款(不解冻,直接扣除)
- 审核拒绝:解冻余额回到可用余额
### Q: 可用余额如何计算?
A: `可用余额 = 总余额 - 冻结余额`
### Q: 订单超时自动取消后还会计算佣金吗?
A: 不会。取消的订单佣金状态保持为 `待计算`,不会入队计算。
### Q: 代购订单是否计算一次性佣金?
A: 不计算。一次性佣金仅对非代购订单触发。
### Q: 成本价差佣金如何沿代理链分配?
A: 从销售店铺开始,沿着 `parent_id` 向上逐级分配。每一级的佣金 = 该级成本价 - 下级成本价。
## 数据库查询速查
### 查询待支付订单
```sql
SELECT * FROM tb_order WHERE payment_status = 1 AND expires_at < NOW();
```
### 查询待计算佣金的订单
```sql
SELECT * FROM tb_order WHERE commission_status = 1;
```
### 查询店铺可用余额
```sql
SELECT balance - frozen_balance as available_balance
FROM tb_agent_wallet
WHERE shop_id = ? AND wallet_type = 'commission';
```
### 查询提现中的金额
```sql
SELECT SUM(amount) as withdrawing_amount
FROM tb_commission_withdrawal_request
WHERE shop_id = ? AND status = 1;
```
### 查询已提现的金额
```sql
SELECT SUM(amount) as withdrawn_amount
FROM tb_commission_withdrawal_request
WHERE shop_id = ? AND status = 2;
```
## 业务规则速查
1. **订单支付规则**
- 待支付订单 30 分钟未支付自动取消
- 取消时解冻钱包余额(如有)
- 防止重复下单(幂等性检查)
2. **佣金计算规则**
- 成本价差佣金:仅当 `销售价 > 销售成本价` 时计算
- 一次性佣金:仅非代购订单首次购买时触发
- 佣金链路断裂时标记为待审核
3. **提现规则**
- 提现申请时冻结余额
- 可用余额 = 总余额 - 冻结余额
- 手续费 = 提现金额 × 费率 / 10000
- 实际到账 = 提现金额 - 手续费

Some files were not shown because too many files have changed in this diff Show More