合并七月迭代分支
This commit is contained in:
@@ -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 查看队列状态。
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 如使用方法有变更,请同步更新本文档。
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 如功能有重大更新,请同步更新本文档。
|
||||
@@ -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)
|
||||
@@ -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") // 不推荐
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:即使代码中有 panic,Recover 中间件也会自动捕获并转换为错误响应,确保服务不崩溃。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 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): 初始版本
|
||||
@@ -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 {
|
||||
// 如果这里发生 panic,Recover 中间件会捕获
|
||||
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. **可维护性**:清晰的错误分类和日志级别
|
||||
|
||||
系统已准备好投入生产环境使用。
|
||||
@@ -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: 缺少 Token?Token 无效?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 panic,Recover 中间件无法捕获
|
||||
- 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): 初始版本
|
||||
@@ -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 | 条件 | 所属店铺 ID(user_type=1 时可为空) |
|
||||
| parent_id | int | 条件 | 上级账号 ID(user_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,
|
||||
})
|
||||
```
|
||||
|
||||
**方式 2:root 用户自动跳过过滤**
|
||||
|
||||
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());
|
||||
```
|
||||
|
||||
**预期查询结果**:
|
||||
|
||||
- **用户 A(root)**: 返回所有 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)
|
||||
@@ -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=25,Redis 连接池 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 文档**:生成 OpenAPI(Swagger)规范文档
|
||||
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 权限系统和数据权限过滤
|
||||
@@ -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. 创建 Store(CRUD 方法)
|
||||
3. 创建 Service(业务逻辑)
|
||||
4. 创建 Handler(HTTP 接口)
|
||||
5. 添加路由文件
|
||||
6. 更新 Services 容器
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 递归查询测试
|
||||
- 缓存读写测试
|
||||
- 数据权限 Scope 测试
|
||||
- 软删除测试
|
||||
|
||||
### 集成测试
|
||||
|
||||
- 数据库迁移测试
|
||||
- 层级数据权限过滤测试
|
||||
- 跨店铺数据隔离测试
|
||||
- API 端点测试
|
||||
|
||||
## 技术决策记录
|
||||
|
||||
### 为什么不使用外键?
|
||||
|
||||
1. **灵活性**:业务逻辑完全在代码中控制
|
||||
2. **性能**:无外键约束检查开销
|
||||
3. **分布式友好**:便于未来拆分微服务
|
||||
|
||||
### 为什么使用 WITH RECURSIVE?
|
||||
|
||||
1. **原生支持**:PostgreSQL 内置支持
|
||||
2. **性能优异**:单次查询获取所有下级
|
||||
3. **深度无限**:支持任意层级的递归
|
||||
|
||||
### 为什么缓存过期时间是 30 分钟?
|
||||
|
||||
1. **平衡性**:在实时性和性能之间取得平衡
|
||||
2. **业务特点**:账号层级变化不频繁
|
||||
3. **可配置**:可根据业务需求调整
|
||||
@@ -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` 的常见问题
|
||||
|
||||
@@ -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
|
||||
@@ -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 行:CreateH5Order(H5订单创建)
|
||||
└─ 第 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.1K,29个章节,45+个代码位置
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 第一次接触系统?
|
||||
1. 阅读 order_status_search_summary.md(5分钟)
|
||||
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
|
||||
**维护者**:开发团队
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) - 机器可读的完整接口文档
|
||||
@@ -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` 使用同一个 Handler,20 个接口完全重复
|
||||
- 代理账号缺少角色管理功能
|
||||
- 企业账号命名错误(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
|
||||
// 代理用户 A(shop_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) - 机器可读的接口文档
|
||||
@@ -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)
|
||||
@@ -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\":\"%'` 并人工确认是否掩码
|
||||
@@ -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;
|
||||
@@ -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` 合理
|
||||
@@ -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` 建议只用于问题排查,不参与主界面渲染。
|
||||
@@ -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. 尝试使用默认账号登录管理后台
|
||||
@@ -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`
|
||||
@@ -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 组织层级关系
|
||||
|
||||
**店铺层级**:
|
||||
```
|
||||
平台
|
||||
├── 店铺A(level=1, parent_id=NULL)
|
||||
│ ├── 店铺B(level=2, parent_id=A)
|
||||
│ │ └── 店铺C(level=3, parent_id=B)
|
||||
│ └── 店铺D(level=2, parent_id=A)
|
||||
└── 店铺E(level=1, parent_id=NULL)
|
||||
```
|
||||
|
||||
**企业归属**:
|
||||
```
|
||||
平台
|
||||
├── 企业Z(owner_shop_id=NULL)平台直属
|
||||
├── 店铺A
|
||||
│ ├── 企业X(owner_shop_id=A)
|
||||
│ └── 企业Y(owner_shop_id=A)
|
||||
└── 店铺B
|
||||
└── 店铺C
|
||||
└── 企业W(owner_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
|
||||
**审核状态**: 待用户审核
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"` // 上级店铺ID(NULL表示一级代理)
|
||||
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"` // 归属店铺ID(NULL表示平台直属)
|
||||
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(归属店铺A,owner_shop_id=店铺A.ID)
|
||||
│ └── 企业Y(归属店铺A,owner_shop_id=店铺A.ID)
|
||||
└── 店铺B
|
||||
└── 店铺C
|
||||
└── 企业W(归属店铺C,owner_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`
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
@@ -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分 | 500000(5000元) |
|
||||
| frozen_balance | BIGINT | 是 | 0 | 冻结余额(单位:分),用于待结算的分佣、提现等 | 10000(100元) |
|
||||
| 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 | 是 | 无 | 变动前余额(单位:分) | 100000(1000元) |
|
||||
| balance_after | BIGINT | 是 | 无 | 变动后余额(单位:分) | 150000(1500元) |
|
||||
| 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 | 是 | 无 | 充值金额(单位:分) | 100000(1000元) |
|
||||
| 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 | 钱包支付金额(单位:分) | 30000(300元) |
|
||||
| online_payment_amount | BIGINT | 是 | 0 | 在线支付金额(单位:分) | 20000(200元) |
|
||||
|
||||
**业务规则**:
|
||||
- 订单总金额 `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` 锁定行,适合写多场景
|
||||
|
||||
钱包系统使用乐观锁,因为余额查询频繁,扣款相对较少。
|
||||
@@ -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. 用户发起充值请求 → 创建 RechargeRecord(status=1 待支付)
|
||||
2. 调用支付网关 → 获取支付链接
|
||||
3. 用户完成支付 → 支付回调更新 RechargeRecord(status=2 已支付)
|
||||
4. 系统处理充值 → 创建 WalletTransaction(type=recharge)
|
||||
5. 更新 Wallet 余额 → 使用乐观锁(version+1)
|
||||
6. 充值完成 → 更新 RechargeRecord(status=3 已完成)
|
||||
```
|
||||
|
||||
#### 消费流程
|
||||
```
|
||||
1. 用户购买套餐 → 检查钱包余额
|
||||
2. 冻结金额 → 增加 frozen_balance
|
||||
3. 订单完成 → 扣减 frozen_balance 和 balance
|
||||
4. 创建 WalletTransaction(type=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. 用户申请换卡 → 创建 CardReplacementRecord(status=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_tag(usage_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 元后转手给个人客户 B,B 登录后看不到剩余 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
|
||||
```
|
||||
|
||||
回滚会从备份表恢复数据,但会丢失备份后的新增数据。
|
||||
@@ -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
|
||||
**报告状态**:✅ 迁移成功,所有验证通过
|
||||
28501
docs/admin-openapi.yaml
28501
docs/admin-openapi.yaml
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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`。认证类失败只返回统一错误码和消息。
|
||||
@@ -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`)创建订单成功后,前端获取支付参数的接口本次未实现。充值回调处理已完整实现——等支付发起改造完成后,完整的充值支付闭环即可联通。
|
||||
@@ -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(事件溯源订单)冲突——但值得重新讨论,原因是……
|
||||
@@ -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 编号。
|
||||
@@ -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 标签"),使用本表中对应的标签字符串。
|
||||
@@ -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
|
||||
**维护者**: 开发团队
|
||||
@@ -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 文档为准。
|
||||
@@ -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 测试和文档展示!** 🎉
|
||||
@@ -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)
|
||||
511
docs/api/auth.md
511
docs/api/auth.md
@@ -1,511 +0,0 @@
|
||||
# B 端认证 API 文档
|
||||
|
||||
本文档描述君鸿卡管系统 B 端认证接口(后台管理和 H5),包括登录、登出、Token 刷新、用户信息查询和密码修改功能。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
### 基础信息
|
||||
|
||||
- **后台 API 前缀**: `/api/admin`
|
||||
- **H5 API 前缀**: `/api/h5`
|
||||
- **认证方式**: Bearer Token (存储在 Redis)
|
||||
- **Token 类型**:
|
||||
- Access Token:24 小时有效期,用于 API 访问
|
||||
- Refresh Token:7 天有效期,用于刷新 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 Token:24 小时自动过期
|
||||
- Refresh Token:7 天自动过期
|
||||
- 修改密码后所有旧 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
|
||||
**维护者**: 君鸿卡管系统开发团队
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 小时复机保护期**(保护期内禁止停机)。
|
||||
|
||||
无响应 body,HTTP 200 即成功。
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/admin/assets/card/:iccid/stop`
|
||||
|
||||
手动停机单张卡(通过 ICCID)。若卡绑定的设备在**复机保护期**内,返回 403。
|
||||
|
||||
无响应 body,HTTP 200 即成功。
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/admin/assets/card/:iccid/start`
|
||||
|
||||
手动复机单张卡(通过 ICCID)。若卡绑定的设备在**停机保护期**内,返回 403。
|
||||
|
||||
无响应 body,HTTP 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 秒内只能主动刷新一次) |
|
||||
@@ -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_token(24h)
|
||||
TokenMgr->>Redis: 存储 refresh_token(7天)
|
||||
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. TokenManager(Token 管理器)
|
||||
|
||||
**职责**:
|
||||
- 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 Token:24 小时自动过期
|
||||
- Refresh Token:7 天自动过期
|
||||
- 修改密码后立即撤销所有旧 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 |
|
||||
|--------|-------------|-----|
|
||||
| 撤销能力 | ✅ 立即生效 | ❌ 无法撤销 |
|
||||
| 性能 | ✅ 5ms(Redis 查询) | ✅ 0ms(本地验证) |
|
||||
| 存储负担 | ⚠️ Redis 内存 | ✅ 无服务端存储 |
|
||||
| 灵活性 | ✅ 可存储复杂信息 | ⚠️ Payload 有大小限制 |
|
||||
| 适用场景 | B 端系统(需要撤销) | C 端系统(高并发) |
|
||||
|
||||
**决策理由**:
|
||||
- B 端用户数量有限(< 1000),Redis 内存负担可接受
|
||||
- 修改密码、账号禁用等场景需要立即撤销 Token
|
||||
- 需要存储完整的用户上下文信息(ShopID、EnterpriseID 等)
|
||||
|
||||
### 为什么使用双令牌机制?
|
||||
|
||||
**问题**:如果只有一个 Token:
|
||||
- 短生命周期:用户频繁掉线,体验差
|
||||
- 长生命周期:Token 泄露风险增加
|
||||
|
||||
**解决方案**:
|
||||
- Access Token(24小时):用于 API 访问,频繁传输,短生命周期降低泄露风险
|
||||
- Refresh Token(7天):用于刷新 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 验证:< 5ms(Redis GET 操作)
|
||||
- Token 生成:< 10ms(Redis SET + SADD 操作)
|
||||
- Token 撤销:< 5ms(Redis 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
|
||||
**维护者**: 君鸿卡管系统开发团队
|
||||
@@ -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 中存储 Token(XSS 风险)
|
||||
- 不要在日志中记录完整 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
|
||||
**维护者**: 君鸿卡管系统开发团队
|
||||
@@ -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
@@ -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_token(5分钟有效)
|
||||
│
|
||||
▼
|
||||
微信授权(前端完成)
|
||||
│
|
||||
├── 公众号 → [A2] wechat-login (code + asset_token)
|
||||
└── 小程序 → [A3] miniapp-login (code + asset_token)
|
||||
│
|
||||
▼
|
||||
解析 asset_token → 获取微信 openid
|
||||
→ 查找/创建客户 → 绑定资产
|
||||
→ 签发 JWT + Redis 存储
|
||||
│
|
||||
▼
|
||||
返回 { token, need_bind_phone, is_new_user }
|
||||
│
|
||||
▼
|
||||
need_bind_phone == true?
|
||||
YES → [A4] 发送验证码 → [A5] 绑定手机号
|
||||
NO → 进入主页面
|
||||
```
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 有状态 JWT(JWT + Redis)
|
||||
|
||||
- JWT payload 仅含 `customer_id` + `exp`
|
||||
- 登录时将 token 写入 Redis,TTL 与 JWT 一致
|
||||
- 每次请求在中间件同时校验 JWT 签名和 Redis 有效状态
|
||||
- 支持服务端主动失效(封禁、强制下线、退出登录)
|
||||
- 单点登录:新登录覆盖旧 token
|
||||
|
||||
### OpenID 多记录管理
|
||||
|
||||
- 新增 `tb_personal_customer_openid` 表
|
||||
- 同一客户可在多个 AppID(公众号/小程序)下拥有不同 OpenID
|
||||
- 唯一约束:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
|
||||
- 客户查找逻辑:openid 精确匹配 → unionid 回退合并 → 创建新客户
|
||||
|
||||
### 资产绑定
|
||||
|
||||
- 每次登录创建 `PersonalCustomerDevice` 绑定记录
|
||||
- 同一资产允许被多个客户绑定(支持转手场景)
|
||||
- 首次绑定时自动将资产状态从「在库(1)」更新为「已销售(2)」
|
||||
|
||||
### 微信配置动态加载
|
||||
|
||||
- 登录时从数据库 `tb_wechat_config` 动态读取激活配置
|
||||
- 优先走 WechatConfigService 的 Redis 缓存
|
||||
- 小程序登录直接 HTTP 调用微信 `jscode2session`(不依赖 PowerWeChat SDK)
|
||||
|
||||
## 限流策略
|
||||
|
||||
| 接口 | 维度 | 限制 |
|
||||
|------|------|------|
|
||||
| A1 | IP | 30 次/分钟 |
|
||||
| A4 | 手机号 | 60 秒冷却 |
|
||||
| A4 | IP | 20 次/小时 |
|
||||
| A4 | 手机号 | 10 次/天 |
|
||||
|
||||
## 新增/修改文件
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `internal/model/personal_customer_openid.go` | OpenID 关联模型 |
|
||||
| `internal/model/dto/client_auth_dto.go` | A1-A7 请求/响应 DTO |
|
||||
| `internal/store/postgres/personal_customer_openid_store.go` | OpenID Store |
|
||||
| `internal/service/client_auth/service.go` | 认证 Service(核心业务逻辑) |
|
||||
| `internal/handler/app/client_auth.go` | 认证 Handler(7 个端点) |
|
||||
| `pkg/wechat/miniapp.go` | 小程序 SDK 封装 |
|
||||
| `migrations/000083_add_personal_customer_openid.up.sql` | 迁移文件 |
|
||||
| `migrations/000083_add_personal_customer_openid.down.sql` | 回滚文件 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `internal/middleware/personal_auth.go` | 增加 Redis 双重校验 |
|
||||
| `pkg/constants/redis.go` | 新增 token 和限流 Redis Key |
|
||||
| `pkg/errors/codes.go` | 新增错误码 1180-1186 |
|
||||
| `pkg/config/defaults/config.yaml` | 新增 `client.require_phone_binding` |
|
||||
| `pkg/wechat/wechat.go` | 新增 MiniAppServiceInterface |
|
||||
| `pkg/wechat/config.go` | 新增 3 个 DB 动态工厂函数 |
|
||||
| `internal/bootstrap/types.go` | 新增 ClientAuth Handler 字段 |
|
||||
| `internal/bootstrap/handlers.go` | 实例化 ClientAuth Handler |
|
||||
| `internal/bootstrap/services.go` | 初始化 ClientAuth Service |
|
||||
| `internal/bootstrap/stores.go` | 初始化 OpenID Store |
|
||||
| `internal/routes/personal.go` | 注册 7 个认证端点 |
|
||||
| `cmd/api/docs.go` | 注册文档生成器 |
|
||||
| `cmd/gendocs/main.go` | 注册文档生成器 |
|
||||
|
||||
## 错误码
|
||||
|
||||
| 码值 | 常量名 | 说明 |
|
||||
|------|--------|------|
|
||||
| 1180 | CodeAssetNotFound | 资产不存在 |
|
||||
| 1181 | CodeWechatConfigUnavailable | 微信配置不可用 |
|
||||
| 1182 | CodeSmsSendFailed | 短信发送失败 |
|
||||
| 1183 | CodeVerificationCodeInvalid | 验证码错误或已过期 |
|
||||
| 1184 | CodePhoneAlreadyBound | 手机号已被其他客户绑定 |
|
||||
| 1185 | CodeAlreadyBoundPhone | 已绑定手机号不可重复绑定 |
|
||||
| 1186 | CodeOldPhoneMismatch | 旧手机号与当前绑定不匹配 |
|
||||
|
||||
## 数据库变更
|
||||
|
||||
- 新建表 `tb_personal_customer_openid`(迁移 000083)
|
||||
- 唯一索引:`idx_pco_app_id_open_id` (app_id, open_id) 软删除条件
|
||||
- 普通索引:`idx_pco_customer_id` (customer_id)
|
||||
- 条件索引:`idx_pco_union_id` (union_id) WHERE union_id != ''
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置路径 | 环境变量 | 默认值 | 说明 |
|
||||
|---------|---------|-------|------|
|
||||
| `client.require_phone_binding` | `JUNHONG_CLIENT_REQUIRE_PHONE_BINDING` | `true` | 是否要求绑定手机号 |
|
||||
@@ -1,122 +0,0 @@
|
||||
# 客户端核心业务 API — 功能总结
|
||||
|
||||
## 概述
|
||||
|
||||
本提案为客户端(C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。
|
||||
|
||||
**前置依赖**:提案 0(数据模型修复)、提案 1(C 端认证系统)。
|
||||
|
||||
## API 端点一览
|
||||
|
||||
### 模块 B:资产信息(4 个接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/c/v1/asset/info` | B1 资产基本信息查询 |
|
||||
| GET | `/api/c/v1/asset/packages` | B2 可购买套餐列表 |
|
||||
| GET | `/api/c/v1/asset/package-history` | B3 历史套餐列表 |
|
||||
| POST | `/api/c/v1/asset/refresh` | B4 手动刷新资产状态 |
|
||||
|
||||
### 模块 C:钱包与充值(5 个接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/c/v1/wallet/detail` | C1 钱包详情(不存在自动创建) |
|
||||
| GET | `/api/c/v1/wallet/transactions` | C2 钱包流水列表 |
|
||||
| GET | `/api/c/v1/wallet/recharge-check` | C3 充值预检(强充检查) |
|
||||
| POST | `/api/c/v1/wallet/recharge` | C4 创建充值订单(JSAPI 支付) |
|
||||
| GET | `/api/c/v1/wallet/recharges` | C5 充值订单列表 |
|
||||
|
||||
### 模块 D:套餐购买(3 个接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/c/v1/orders/create` | D1 创建套餐购买订单(含强充分流) |
|
||||
| GET | `/api/c/v1/orders` | D2 套餐订单列表 |
|
||||
| GET | `/api/c/v1/orders/:id` | D3 套餐订单详情 |
|
||||
|
||||
### 模块 E:实名认证(1 个接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/c/v1/realname/link` | E1 获取实名跳转链接 |
|
||||
|
||||
### 模块 F:设备能力(5 个接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/c/v1/device/cards` | F1 设备卡列表 |
|
||||
| POST | `/api/c/v1/device/reboot` | F2 设备重启 |
|
||||
| POST | `/api/c/v1/device/factory-reset` | F3 恢复出厂设置 |
|
||||
| POST | `/api/c/v1/device/wifi` | F4 设置 WiFi |
|
||||
| POST | `/api/c/v1/device/switch-card` | F5 切卡 |
|
||||
|
||||
## 核心设计决策
|
||||
|
||||
### 1. 数据权限绕过
|
||||
|
||||
客户端调用后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)` 绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
|
||||
|
||||
### 2. 归属校验
|
||||
|
||||
所有涉及资产操作的接口统一前置归属校验:查询 `PersonalCustomerDevice` 条件 `customer_id = 当前登录客户` 且 `virtual_no = 资产虚拟号`,未命中返回 403。
|
||||
|
||||
### 3. Generation 过滤
|
||||
|
||||
客户端历史查询统一附加 `WHERE generation = 资产当前 generation`,确保转手后数据隔离。
|
||||
|
||||
### 4. OpenID 安全规范
|
||||
|
||||
支付接口(C4/D1)所需 OpenID 由后端按 `customer_id + app_type` 查询,客户端禁止传入 OpenID。根据 `app_type` 选择对应的微信 AppID 创建支付实例。
|
||||
|
||||
### 5. 强充两阶段
|
||||
|
||||
- 第一阶段(同步):充值入账、更新状态
|
||||
- 第二阶段(异步 Asynq):钱包扣款 → 创建订单 → 激活套餐
|
||||
|
||||
`AssetRechargeRecord.auto_purchase_status` 字段追踪异步状态(pending/success/failed)。
|
||||
|
||||
## 新增文件
|
||||
|
||||
```
|
||||
internal/model/dto/client_asset_dto.go # 资产模块 DTO
|
||||
internal/model/dto/client_wallet_dto.go # 钱包模块 DTO
|
||||
internal/model/dto/client_order_dto.go # 订单模块 DTO
|
||||
internal/model/dto/client_realname_device_dto.go # 实名+设备模块 DTO
|
||||
internal/handler/app/client_asset.go # 资产 Handler
|
||||
internal/handler/app/client_wallet.go # 钱包 Handler
|
||||
internal/handler/app/client_order.go # 订单 Handler
|
||||
internal/handler/app/client_realname.go # 实名 Handler
|
||||
internal/handler/app/client_device.go # 设备 Handler
|
||||
internal/service/client_order/service.go # 客户端订单编排 Service
|
||||
internal/task/auto_purchase.go # 强充异步自动购买任务
|
||||
migrations/000084_add_auto_purchase_status_*.sql # 数据库迁移
|
||||
```
|
||||
|
||||
## 修改文件
|
||||
|
||||
```
|
||||
pkg/constants/constants.go # 新增 auto_purchase_status 常量 + 任务类型
|
||||
pkg/constants/redis.go # 新增客户端购买幂等键
|
||||
pkg/errors/codes.go # 新增 NEED_REALNAME/OPENID_NOT_FOUND 错误码
|
||||
internal/model/asset_wallet.go # AssetRechargeRecord 新增字段
|
||||
internal/bootstrap/types.go # 5 个 Handler 字段
|
||||
internal/bootstrap/handlers.go # Handler 实例化
|
||||
internal/routes/personal.go # 18 个路由注册
|
||||
pkg/openapi/handlers.go # 文档生成 Handler
|
||||
cmd/api/docs.go # 文档注册
|
||||
cmd/gendocs/main.go # 文档注册
|
||||
```
|
||||
|
||||
## 新增错误码
|
||||
|
||||
| 错误码 | 常量名 | 消息 |
|
||||
|--------|--------|------|
|
||||
| 1187 | CodeNeedRealname | 该套餐需实名认证后购买 |
|
||||
| 1188 | CodeOpenIDNotFound | 未找到微信授权信息,请先完成授权 |
|
||||
|
||||
## 数据库变更
|
||||
|
||||
- 表:`tb_asset_recharge_record`
|
||||
- 新增字段:`auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL`
|
||||
- 迁移版本:000084
|
||||
@@ -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 级问题)。
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
**维护者**: 开发团队
|
||||
@@ -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元 ✓
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
分配配置:
|
||||
平台给A:20元
|
||||
A给A1:8元
|
||||
A1给A2:5元
|
||||
|
||||
触发首充时:
|
||||
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/)
|
||||
@@ -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) - 可视化的流程图
|
||||
|
||||
@@ -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 // 待人工修正
|
||||
```
|
||||
|
||||
@@ -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):数据库字段定义
|
||||
@@ -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) | ICCID(20位数字) |
|
||||
| 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
|
||||
@@ -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. ✅ 启用 HTTPS(Let's Encrypt)
|
||||
3. ✅ 配置监控和告警(Prometheus + Grafana)
|
||||
4. ✅ 设置自动备份脚本
|
||||
|
||||
---
|
||||
|
||||
## 支持
|
||||
|
||||
如有问题,请联系运维团队或提交 Issue。
|
||||
@@ -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) 进行首次部署。
|
||||
90
docs/deployment/production-runbook.md
Normal file
90
docs/deployment/production-runbook.md
Normal 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 天。发布前仍须人工新建一次备份并确认校验通过,不能只依赖凌晨的最近备份。恢复命令待维护者实际演练或确认后补充。
|
||||
@@ -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`,避免长期依赖手工基线流程。
|
||||
@@ -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 ./...` 验证编译通过。
|
||||
@@ -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": "已激活"
|
||||
}
|
||||
```
|
||||
@@ -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)作为实际停机阈值,保证不超用。
|
||||
|
||||
客户端展示时,系统将真实用量按比例换算回真总流量的尺度,使客户的体感与购买的套餐一致:
|
||||
- 当真用量达到 9G(VirtualDataMB)时,卡被停机
|
||||
- 此时展示用量 = 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. 丰富现有卡/设备 DTO(IotCardDetailResponse、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 设备手动刷新冷却期 Key,TTL = 冷却时长(建议 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 提案阶段。
|
||||
@@ -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
|
||||
556
docs/engineering/从零构建Agent友好项目最佳实践.md
Normal file
556
docs/engineering/从零构建Agent友好项目最佳实践.md
Normal 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` 获取的通用知识。
|
||||
|
||||
### 尺寸与验证
|
||||
|
||||
- 80~120 行是目标,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)
|
||||
|
||||
### 补充实践
|
||||
|
||||
- [Augment:How to write good AGENTS.md files](https://www.augmentcode.com/blog/how-to-write-good-agents-dot-md-files)
|
||||
- [BetterClaw:AGENTS.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)
|
||||
- [O’Reilly:How to write a good spec for AI agents](https://www.oreilly.com/radar/how-to-write-a-good-spec-for-ai-agents/)
|
||||
302
docs/engineering/工程约束.md
Normal file
302
docs/engineering/工程约束.md
Normal 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 使用 GORM,MUST 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 或 map,MUST 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 在同一事务写 Outbox;MUST 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 Log,HTTP 请求写 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
|
||||
- **更新触发条件**:新增配置或依赖升级
|
||||
@@ -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
|
||||
- ✅ 错误处理和数据权限
|
||||
- ✅ 事务保证和级联操作
|
||||
|
||||
功能已通过编译验证,可以部署测试。前端需要配合调整以支持新的授权流程。
|
||||
@@ -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
|
||||
// 步骤1:constants.go 中定义
|
||||
const (
|
||||
RechargeStatusPending = 1 // 待支付
|
||||
RechargeStatusPaid = 2 // 已支付
|
||||
RechargeStatusCompleted = 3 // 已完成
|
||||
RechargeStatusClosed = 4 // 已关闭
|
||||
RechargeStatusRefunded = 5 // 已退款
|
||||
)
|
||||
|
||||
// 步骤2:DTO 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 数据迁移,改动前先评估影响。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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+),建议在文档中明确说明。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系后端开发团队。
|
||||
@@ -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 中批量强制回填历史订单,只在运行时兼容读取并为后续人工校验提供依据。
|
||||
@@ -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` 仅作为历史兼容态存在;新计算链路应尽量落为明确业务结果。
|
||||
- 本次不重构一次性佣金规则本身,只保证其异常待审记录能纳入订单最终流程结果判断。
|
||||
@@ -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 再写回数据库。
|
||||
@@ -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 = 10000(100 元,B 看到的订单金额)
|
||||
- actual_paid_amount = 8000(80 元,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}`
|
||||
- TTL:3 分钟
|
||||
- 分布式锁防止并发:`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)
|
||||
@@ -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`
|
||||
@@ -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(¤tOrder, 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)
|
||||
@@ -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) - 统一错误处理规范
|
||||
@@ -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) - 测试规范和最佳实践
|
||||
30
docs/integrations/alipay/README.md
Normal file
30
docs/integrations/alipay/README.md
Normal 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>
|
||||
|
||||
渠道版本、认证、字段、通知状态或重试语义变化时更新本文。
|
||||
28
docs/integrations/fuiou/README.md
Normal file
28
docs/integrations/fuiou/README.md
Normal 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`。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符和重复通知;本次不调用真实渠道。
|
||||
|
||||
端点、编码、签名字段、成功码或通知语义变化时更新本文。
|
||||
30
docs/integrations/gateway/README.md
Normal file
30
docs/integrations/gateway/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Gateway 接入契约
|
||||
|
||||
## 元数据
|
||||
|
||||
- Owner:IoT 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` 及同目录能力文件。真实写操作可能改变卡或设备状态,本次只允许静态核对和隔离账号只读验证,不调用生产写接口。
|
||||
|
||||
协议、路径、认证、成功码、超时或重试分类变化时更新本文。
|
||||
32
docs/integrations/object-storage/README.md
Normal file
32
docs/integrations/object-storage/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# S3 兼容对象存储接入契约
|
||||
|
||||
## 元数据
|
||||
|
||||
- Owner:基础设施维护人
|
||||
- 实现:`pkg/storage/`
|
||||
- SDK:`github.com/aws/aws-sdk-go v1.55.5`
|
||||
- 核验日期:2026-08-07
|
||||
|
||||
## 当前实际使用范围
|
||||
|
||||
系统支持对象上传、下载、Head 元数据、删除、存在性判断,以及上传/下载预签名 URL;Provider 当前只接受 `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、端点兼容性、认证、对象语义或预签名期限变化时更新本文。
|
||||
28
docs/integrations/sms/README.md
Normal file
28
docs/integrations/sms/README.md
Normal 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 重建不发送真实短信。
|
||||
|
||||
协议版本、端点、认证、字段、成功码或重试语义变化时更新本文。
|
||||
34
docs/integrations/wechat/README.md
Normal file
34
docs/integrations/wechat/README.md
Normal 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 版本、认证、通知或重试语义变化时更新本文。
|
||||
41
docs/integrations/wecom/README.md
Normal file
41
docs/integrations/wecom/README.md
Normal 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>
|
||||
|
||||
端点、模板字段、认证、回调加密或补偿语义变化时更新本文。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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%)
|
||||
- 前端缓存,登录后只传输一次
|
||||
@@ -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. 删除测试文件
|
||||
@@ -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。
|
||||
@@ -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 可用性
|
||||
- 减少手动维护文档的工作量
|
||||
|
||||
所有高优先级功能已完成并验证通过,可以投入使用。
|
||||
@@ -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)
|
||||
}
|
||||
```
|
||||
@@ -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 3(add-card-device-series-binding)- 卡/设备套餐系列关联
|
||||
- Wallet 模型 - 钱包余额管理
|
||||
|
||||
**被依赖**:
|
||||
- Phase 5(add-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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user