Merge branch '002-gorm-postgres-asynq': 数据持久化与异步任务处理集成
This commit is contained in:
@@ -1,5 +1,90 @@
|
||||
Version Change: 2.2.0 → 2.3.0
|
||||
Date: 2025-11-11
|
||||
|
||||
NEW PRINCIPLES ADDED:
|
||||
- VIII. Access Logging Standards (访问日志规范) - NEW principle for comprehensive request/response logging
|
||||
|
||||
MODIFIED SECTIONS:
|
||||
- Added new Principle VIII with mandatory access logging requirements
|
||||
- Rule: ALL requests MUST be logged to access.log without exception
|
||||
- Rule: Request parameters (query + body) MUST be logged (limited to 50KB)
|
||||
- Rule: Response parameters (body) MUST be logged (limited to 50KB)
|
||||
- Rule: Logging MUST happen via centralized Logger middleware
|
||||
- Rule: No middleware can bypass access logging (including auth failures)
|
||||
- Rule: Body truncation MUST indicate "... (truncated)" when over limit
|
||||
- Rationale for comprehensive logging: debugging, audit trails, compliance
|
||||
|
||||
TEMPLATES REQUIRING UPDATES:
|
||||
✅ .specify/templates/plan-template.md - Added access logging check in Constitution Check
|
||||
✅ .specify/templates/tasks-template.md - Added access logging verification in Quality Gates
|
||||
|
||||
FOLLOW-UP ACTIONS:
|
||||
- None required - logging implementation already completed
|
||||
|
||||
RATIONALE:
|
||||
MINOR version bump (2.3.0) - New principle added for access logging standards.
|
||||
This establishes a mandatory governance rule that ALL HTTP requests must be logged
|
||||
with complete request and response data, regardless of middleware short-circuiting
|
||||
(auth failures, rate limits, etc.). This ensures:
|
||||
1. Complete audit trail for all API interactions
|
||||
2. Debugging capability for all failure scenarios
|
||||
3. Compliance with logging requirements
|
||||
4. No special cases or exceptions in logging
|
||||
|
||||
This is a MINOR bump (not PATCH) because it adds a new mandatory principle that
|
||||
affects the development workflow and quality gates, requiring verification that
|
||||
all middleware respects the logging standard.
|
||||
-->
|
||||
<!--
|
||||
SYNC IMPACT REPORT - Constitution Amendment
|
||||
Version Change: 2.3.0 → 2.4.0
|
||||
Date: 2025-11-13
|
||||
|
||||
NEW PRINCIPLES ADDED:
|
||||
- IX. Database Design Principles (数据库设计原则) - NEW principle for database schema and ORM relationship management
|
||||
|
||||
MODIFIED SECTIONS:
|
||||
- Added new Principle IX with mandatory database design requirements
|
||||
- Rule: Database tables MUST NOT have foreign key constraints
|
||||
- Rule: GORM models MUST NOT use ORM association tags (foreignKey, hasMany, belongsTo, etc.)
|
||||
- Rule: Table relationships MUST be maintained manually via ID fields
|
||||
- Rule: Associated data queries MUST be explicit in code, not ORM magic
|
||||
- Rule: Model structs MUST ONLY contain simple fields
|
||||
- Rule: Migration scripts MUST NOT include foreign key constraints
|
||||
- Rule: Migration scripts MUST NOT include triggers for relationship maintenance
|
||||
- Rule: Time fields (created_at, updated_at) MUST be handled by GORM, not database triggers
|
||||
- Rationale: Flexibility, performance, simplicity, maintainability, distributed-friendly
|
||||
|
||||
TEMPLATES REQUIRING UPDATES:
|
||||
⚠️ .specify/templates/plan-template.md - Should add database design principle check
|
||||
⚠️ .specify/templates/tasks-template.md - Should add migration script validation
|
||||
|
||||
FOLLOW-UP ACTIONS:
|
||||
- Migration scripts updated (removed foreign keys and triggers)
|
||||
- User and Order models updated (removed GORM associations)
|
||||
- OrderService.ListOrdersByUserID added for manual relationship queries
|
||||
|
||||
RATIONALE:
|
||||
MINOR version bump (2.4.0) - New principle added for database design standards.
|
||||
This establishes a mandatory governance rule that database relationships must NOT
|
||||
be enforced at the database or ORM level, but rather managed explicitly in code.
|
||||
This ensures:
|
||||
1. Business logic flexibility (no database constraints limiting deletion, updates)
|
||||
2. Performance (no foreign key check overhead in high-concurrency scenarios)
|
||||
3. Code clarity (explicit queries, no ORM magic like N+1 queries or unexpected preloading)
|
||||
4. Developer control (decide when and how to query associated data)
|
||||
5. Maintainability (simpler schema, easier migrations, no complex FK dependencies)
|
||||
6. Distributed-friendly (manual relationships work across databases/microservices)
|
||||
7. GORM value retention (keep core features: auto timestamps, soft delete, query builder)
|
||||
|
||||
This is a MINOR bump (not PATCH) because it adds a new mandatory principle that
|
||||
fundamentally changes how we design database schemas and model relationships,
|
||||
affecting all future database-related development.
|
||||
|
||||
PREVIOUS AMENDMENTS:
|
||||
- 2.3.0 (2025-11-11): Added Principle VIII - Access Logging Standards
|
||||
- 2.2.0 (Previous): Added comprehensive code quality and Go idiomatic principles
|
||||
-->
|
||||
============================================
|
||||
Version Change: 2.2.0 → 2.3.0
|
||||
Date: 2025-11-11
|
||||
@@ -53,7 +138,7 @@ all middleware respects the logging standard.
|
||||
- 所有数据库操作 **MUST** 通过 GORM 进行
|
||||
- 所有配置管理 **MUST** 使用 Viper
|
||||
- 所有日志记录 **MUST** 使用 Zap + Lumberjack.v2
|
||||
- 所有 JSON 序列化 **MUST** 使用 sonic
|
||||
- 所有 JSON 序列化 **SHOULD** 优先使用 sonic,仅在必须使用标准库的场景(如某些第三方库要求)才使用 `encoding/json`
|
||||
- 所有异步任务 **MUST** 使用 Asynq
|
||||
- **MUST** 使用 Go 官方工具链:`go fmt`、`go vet`、`golangci-lint`
|
||||
- **MUST** 使用 Go Modules 进行依赖管理
|
||||
@@ -277,10 +362,28 @@ logger.Error("数据库连接失败",
|
||||
zap.Error(err))
|
||||
```
|
||||
|
||||
**函数复杂度和职责分离 (Function Complexity and Responsibility Separation):**
|
||||
|
||||
- 函数长度 **MUST NOT** 超过合理范围(通常 50-100 行,核心逻辑建议 ≤ 50 行)
|
||||
- 超过 100 行的函数 **MUST** 拆分为多个小函数,每个函数只负责一件事
|
||||
- `main()` 函数 **MUST** 只做编排(orchestration),不包含具体实现逻辑
|
||||
- `main()` 函数中的每个初始化步骤 **SHOULD** 提取为独立的辅助函数
|
||||
- 编排函数(orchestrator)**MUST** 清晰表达流程,避免嵌套的实现细节
|
||||
- **MUST** 遵循单一职责原则(Single Responsibility Principle)
|
||||
- 虽然 **MUST NOT** 过度封装,但 **MUST** 在职责边界清晰的地方进行适度分离
|
||||
|
||||
**理由 (RATIONALE):**
|
||||
|
||||
清晰的分层架构和代码组织使代码易于理解、测试和维护。统一的错误处理和响应格式提升 API 一致性和客户端集成体验。依赖注入模式便于单元测试和模块替换。集中管理常量和 Redis key 避免拼写错误、重复定义和命名不一致,提升代码可维护性和重构安全性。Redis key 统一管理便于监控、调试和缓存策略调整。遵循 Go 官方代码风格确保代码一致性和可读性。
|
||||
|
||||
函数复杂度控制和职责分离的理由:
|
||||
1. **可读性**: 小函数易于阅读和理解,特别是 main 函数清晰表达程序流程
|
||||
2. **可测试性**: 小函数易于编写单元测试,提高测试覆盖率
|
||||
3. **可维护性**: 职责单一的函数修改风险低,不易引入 bug
|
||||
4. **可复用性**: 提取的辅助函数可以在其他地方复用
|
||||
5. **减少认知负担**: 阅读者不需要同时理解过多细节
|
||||
6. **便于重构**: 小函数更容易安全地重构和优化
|
||||
|
||||
避免硬编码和强制使用常量的规则能够:
|
||||
1. **提高可维护性**:修改常量值只需改一处,不需要搜索所有硬编码位置
|
||||
2. **减少错误**:避免手动输入错误(拼写错误、大小写错误)
|
||||
@@ -1061,6 +1164,139 @@ accessLogger.Info("",
|
||||
|
||||
---
|
||||
|
||||
### IX. Database Design Principles (数据库设计原则)
|
||||
|
||||
**规则 (RULES):**
|
||||
|
||||
- 数据库表之间 **MUST NOT** 建立外键约束(Foreign Key Constraints)
|
||||
- GORM 模型之间 **MUST NOT** 使用 ORM 关联关系(`foreignKey`、`references`、`hasMany`、`belongsTo` 等标签)
|
||||
- 表之间的关联 **MUST** 通过存储关联 ID 字段手动维护
|
||||
- 关联数据查询 **MUST** 在代码层面显式执行,不依赖 ORM 的自动加载(Lazy Loading)或预加载(Eager Loading)
|
||||
- 模型结构体 **MUST ONLY** 包含简单字段,不应包含其他模型的嵌套引用
|
||||
- 数据库迁移脚本 **MUST NOT** 包含外键约束定义
|
||||
- 数据库迁移脚本 **MUST NOT** 包含触发器用于维护关联数据
|
||||
- 时间字段(`created_at`、`updated_at`)的更新 **MUST** 由 GORM 自动处理,不使用数据库触发器
|
||||
|
||||
**正确的关联设计:**
|
||||
|
||||
```go
|
||||
// ✅ User 模型 - 完全独立
|
||||
type User struct {
|
||||
BaseModel
|
||||
Username string `gorm:"uniqueIndex;not null;size:50"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:100"`
|
||||
Password string `gorm:"not null;size:255"`
|
||||
Status string `gorm:"not null;size:20;default:'active'"`
|
||||
}
|
||||
|
||||
// ✅ Order 模型 - 仅存储 UserID
|
||||
type Order struct {
|
||||
BaseModel
|
||||
OrderID string `gorm:"uniqueIndex;not null;size:50"`
|
||||
UserID uint `gorm:"not null;index"` // 仅存储 ID,无 ORM 关联
|
||||
Amount int64 `gorm:"not null"`
|
||||
Status string `gorm:"not null;size:20;default:'pending'"`
|
||||
}
|
||||
|
||||
// ✅ 手动查询关联数据
|
||||
func (s *OrderService) GetOrderWithUser(ctx context.Context, orderID uint) (*OrderDetail, error) {
|
||||
// 查询订单
|
||||
order, err := s.store.Order.GetByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 手动查询关联的用户
|
||||
user, err := s.store.User.GetByID(ctx, order.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 组装返回数据
|
||||
return &OrderDetail{
|
||||
Order: order,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**错误的关联设计(禁止):**
|
||||
|
||||
```go
|
||||
// ❌ 使用 GORM 外键关联
|
||||
type Order struct {
|
||||
BaseModel
|
||||
OrderID string
|
||||
UserID uint
|
||||
User *User `gorm:"foreignKey:UserID"` // ❌ 禁止
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ❌ 使用 GORM hasMany 关联
|
||||
type User struct {
|
||||
BaseModel
|
||||
Username string
|
||||
Orders []Order `gorm:"foreignKey:UserID"` // ❌ 禁止
|
||||
}
|
||||
|
||||
// ❌ 在迁移脚本中定义外键约束
|
||||
CREATE TABLE tb_order (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
CONSTRAINT fk_order_user FOREIGN KEY (user_id)
|
||||
REFERENCES tb_user(id) ON DELETE RESTRICT -- ❌ 禁止
|
||||
);
|
||||
|
||||
// ❌ 使用数据库触发器更新时间
|
||||
CREATE TRIGGER update_order_updated_at
|
||||
BEFORE UPDATE ON tb_order
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column(); -- ❌ 禁止
|
||||
|
||||
// ❌ 依赖 GORM 预加载
|
||||
orders, err := db.Preload("User").Find(&orders) // ❌ 禁止
|
||||
```
|
||||
|
||||
**GORM BaseModel 自动时间管理:**
|
||||
|
||||
```go
|
||||
// ✅ GORM 自动处理时间字段
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time // GORM 自动填充创建时间
|
||||
UpdatedAt time.Time // GORM 自动更新修改时间
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"` // 软删除支持
|
||||
}
|
||||
|
||||
// 创建记录时,GORM 自动设置 CreatedAt 和 UpdatedAt
|
||||
db.Create(&user)
|
||||
|
||||
// 更新记录时,GORM 自动更新 UpdatedAt
|
||||
db.Save(&user)
|
||||
```
|
||||
|
||||
**理由 (RATIONALE):**
|
||||
|
||||
移除数据库外键约束和 ORM 关联关系的理由:
|
||||
|
||||
1. **灵活性**:业务逻辑完全在代码中控制,不受数据库约束限制。例如删除用户时可以根据业务需求决定是级联删除订单、保留订单还是转移订单,而不是被 `ON DELETE CASCADE/RESTRICT` 强制约束。
|
||||
|
||||
2. **性能**:无外键约束意味着无数据库层面的引用完整性检查开销。在高并发场景下,外键检查和锁竞争会成为性能瓶颈。
|
||||
|
||||
3. **简单直接**:显式的关联数据查询使数据流向清晰可见,代码行为明确。避免了 ORM 的"魔法"行为(N+1 查询问题、意外的预加载、Lazy Loading 陷阱)。
|
||||
|
||||
4. **可控性**:开发者完全掌控何时查询关联数据、查询哪些关联数据。可以根据场景优化查询(批量查询、缓存等),而不是依赖 ORM 的自动行为。
|
||||
|
||||
5. **可维护性**:数据库 schema 更简单,迁移更容易。修改表结构不需要处理复杂的外键依赖关系。代码重构时不会被数据库约束限制。
|
||||
|
||||
6. **分布式友好**:在微服务和分布式数据库场景下,外键约束往往无法跨数据库工作。手动维护关联从设计上就支持未来的服务拆分。
|
||||
|
||||
7. **GORM 基础功能**:保留 GORM 的核心价值(自动时间管理、软删除、查询构建、事务支持),去除复杂的关联功能,达到简单性和功能性的平衡。
|
||||
|
||||
这种设计哲学符合"明确优于隐式"的原则,代码的行为一目了然,没有隐藏的数据库操作和 ORM 魔法。
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow (开发工作流程)
|
||||
|
||||
### 分支管理
|
||||
@@ -1171,4 +1407,4 @@ accessLogger.Info("",
|
||||
|
||||
---
|
||||
|
||||
**Version**: 2.3.0 | **Ratified**: 2025-11-10 | **Last Amended**: 2025-11-11
|
||||
**Version**: 2.4.0 | **Ratified**: 2025-11-10 | **Last Amended**: 2025-11-13
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Auto-generated from all feature plans. Last updated: 2025-11-10
|
||||
|
||||
## Active Technologies
|
||||
- Go 1.25.4 + Fiber (HTTP 框架), GORM (ORM), Asynq (任务队列), Viper (配置), Zap (日志), golang-migrate (数据库迁移) (002-gorm-postgres-asynq)
|
||||
- PostgreSQL 14+(主数据库), Redis 6.0+(任务队列存储) (002-gorm-postgres-asynq)
|
||||
|
||||
- Go 1.25.4 (001-fiber-middleware-integration)
|
||||
|
||||
@@ -23,6 +25,8 @@ tests/
|
||||
Go 1.25.1: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 002-gorm-postgres-asynq: Added Go 1.25.4 + Fiber (HTTP 框架), GORM (ORM), Asynq (任务队列), Viper (配置), Zap (日志), golang-migrate (数据库迁移)
|
||||
- 002-gorm-postgres-asynq: Added Go 1.25.4
|
||||
|
||||
- 001-fiber-middleware-integration: Added Go 1.25.1
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
- **请求 ID 追踪**:UUID 跨日志的请求追踪
|
||||
- **Panic 恢复**:优雅的 panic 处理和堆栈跟踪日志
|
||||
- **统一错误响应**:一致的错误格式和本地化消息
|
||||
- **数据持久化**:GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
|
||||
- **异步任务处理**:Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
|
||||
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
||||
- **代理商体系**:层级管理和分佣结算
|
||||
- **批量同步**:卡状态、实名状态、流量使用情况
|
||||
|
||||
@@ -17,8 +17,13 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/user"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/validator"
|
||||
)
|
||||
|
||||
@@ -83,9 +88,44 @@ func main() {
|
||||
}
|
||||
appLogger.Info("Redis 已连接", zap.String("address", redisAddr))
|
||||
|
||||
// 初始化 PostgreSQL 连接
|
||||
db, err := database.InitPostgreSQL(&cfg.Database, appLogger)
|
||||
if err != nil {
|
||||
appLogger.Fatal("初始化 PostgreSQL 失败", zap.Error(err))
|
||||
}
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
appLogger.Error("关闭 PostgreSQL 连接失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 初始化 Asynq 任务提交客户端
|
||||
queueClient := queue.NewClient(redisClient, appLogger)
|
||||
defer func() {
|
||||
if err := queueClient.Close(); err != nil {
|
||||
appLogger.Error("关闭 Asynq 客户端失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建令牌验证器
|
||||
tokenValidator := validator.NewTokenValidator(redisClient, appLogger)
|
||||
|
||||
// 初始化 Store 层
|
||||
store := postgres.NewStore(db, appLogger)
|
||||
|
||||
// 初始化 Service 层
|
||||
userService := user.NewService(store, appLogger)
|
||||
orderService := order.NewService(store, appLogger)
|
||||
|
||||
// 初始化 Handler 层
|
||||
userHandler := handler.NewUserHandler(userService, appLogger)
|
||||
orderHandler := handler.NewOrderHandler(orderService, appLogger)
|
||||
taskHandler := handler.NewTaskHandler(queueClient, appLogger)
|
||||
healthHandler := handler.NewHealthHandler(db, redisClient, appLogger)
|
||||
|
||||
// 启动配置文件监听器(热重载)
|
||||
watchCtx, cancelWatch := context.WithCancel(context.Background())
|
||||
defer cancelWatch()
|
||||
@@ -125,7 +165,7 @@ func main() {
|
||||
// 路由注册
|
||||
|
||||
// 公共端点(无需认证)
|
||||
app.Get("/health", handler.HealthCheck)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// API v1 路由组
|
||||
v1 := app.Group("/api/v1")
|
||||
@@ -160,8 +200,22 @@ func main() {
|
||||
))
|
||||
}
|
||||
|
||||
// 注册受保护的路由
|
||||
v1.Get("/users", handler.GetUsers)
|
||||
// 用户路由
|
||||
v1.Post("/users", userHandler.CreateUser)
|
||||
v1.Get("/users/:id", userHandler.GetUser)
|
||||
v1.Put("/users/:id", userHandler.UpdateUser)
|
||||
v1.Delete("/users/:id", userHandler.DeleteUser)
|
||||
v1.Get("/users", userHandler.ListUsers)
|
||||
|
||||
// 订单路由
|
||||
v1.Post("/orders", orderHandler.CreateOrder)
|
||||
v1.Get("/orders/:id", orderHandler.GetOrder)
|
||||
v1.Put("/orders/:id", orderHandler.UpdateOrder)
|
||||
v1.Get("/orders", orderHandler.ListOrders)
|
||||
|
||||
// 任务路由
|
||||
v1.Post("/tasks/email", taskHandler.SubmitEmailTask)
|
||||
v1.Post("/tasks/sync", taskHandler.SubmitSyncTask)
|
||||
|
||||
// 优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
@@ -1 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
panic("加载配置失败: " + err.Error())
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
if err := logger.InitLoggers(
|
||||
cfg.Logging.Level,
|
||||
cfg.Logging.Development,
|
||||
logger.LogRotationConfig{
|
||||
Filename: cfg.Logging.AppLog.Filename,
|
||||
MaxSize: cfg.Logging.AppLog.MaxSize,
|
||||
MaxBackups: cfg.Logging.AppLog.MaxBackups,
|
||||
MaxAge: cfg.Logging.AppLog.MaxAge,
|
||||
Compress: cfg.Logging.AppLog.Compress,
|
||||
},
|
||||
logger.LogRotationConfig{
|
||||
Filename: cfg.Logging.AccessLog.Filename,
|
||||
MaxSize: cfg.Logging.AccessLog.MaxSize,
|
||||
MaxBackups: cfg.Logging.AccessLog.MaxBackups,
|
||||
MaxAge: cfg.Logging.AccessLog.MaxAge,
|
||||
Compress: cfg.Logging.AccessLog.Compress,
|
||||
},
|
||||
); err != nil {
|
||||
panic("初始化日志失败: " + err.Error())
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync() // 忽略 sync 错误
|
||||
}()
|
||||
|
||||
appLogger := logger.GetAppLogger()
|
||||
appLogger.Info("Worker 服务启动中...")
|
||||
|
||||
// 连接 Redis
|
||||
redisAddr := cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port)
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.Redis.PoolSize,
|
||||
MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
DialTimeout: cfg.Redis.DialTimeout,
|
||||
ReadTimeout: cfg.Redis.ReadTimeout,
|
||||
WriteTimeout: cfg.Redis.WriteTimeout,
|
||||
})
|
||||
defer func() {
|
||||
if err := redisClient.Close(); err != nil {
|
||||
appLogger.Error("关闭 Redis 客户端失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// 测试 Redis 连接
|
||||
ctx := context.Background()
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
appLogger.Fatal("连接 Redis 失败", zap.Error(err))
|
||||
}
|
||||
appLogger.Info("Redis 已连接", zap.String("address", redisAddr))
|
||||
|
||||
// 初始化 PostgreSQL 连接
|
||||
db, err := database.InitPostgreSQL(&cfg.Database, appLogger)
|
||||
if err != nil {
|
||||
appLogger.Fatal("初始化 PostgreSQL 失败", zap.Error(err))
|
||||
}
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
appLogger.Error("关闭 PostgreSQL 连接失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建 Asynq Worker 服务器
|
||||
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
|
||||
|
||||
// 创建任务处理器管理器并注册所有处理器
|
||||
taskHandler := queue.NewHandler(db, redisClient, appLogger)
|
||||
taskHandler.RegisterHandlers()
|
||||
|
||||
appLogger.Info("Worker 服务器配置完成",
|
||||
zap.Int("concurrency", cfg.Queue.Concurrency),
|
||||
zap.Any("queues", cfg.Queue.Queues))
|
||||
|
||||
// 优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// 启动 Worker 服务器(阻塞运行)
|
||||
go func() {
|
||||
if err := workerServer.Run(taskHandler.GetMux()); err != nil {
|
||||
appLogger.Fatal("Worker 服务器运行失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
appLogger.Info("Worker 服务器已启动")
|
||||
|
||||
// 等待关闭信号
|
||||
<-quit
|
||||
appLogger.Info("正在关闭 Worker 服务器...")
|
||||
|
||||
// 优雅关闭 Worker 服务器(等待正在执行的任务完成)
|
||||
workerServer.Shutdown()
|
||||
|
||||
appLogger.Info("Worker 服务器已停止")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,26 @@ redis:
|
||||
read_timeout: "3s"
|
||||
write_timeout: "3s"
|
||||
|
||||
database:
|
||||
host: "cxd.whcxd.cn"
|
||||
port: 16159
|
||||
user: "erp_pgsql"
|
||||
password: "erp_2025"
|
||||
dbname: "junhong_cmp_test"
|
||||
sslmode: "disable"
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 10
|
||||
conn_max_lifetime: "5m"
|
||||
|
||||
queue:
|
||||
concurrency: 10
|
||||
queues:
|
||||
critical: 6
|
||||
default: 3
|
||||
low: 1
|
||||
retry_max: 5
|
||||
timeout: "10m"
|
||||
|
||||
logging:
|
||||
level: "debug" # 开发环境使用 debug 级别
|
||||
development: true # 启用开发模式(美化日志输出)
|
||||
|
||||
@@ -15,6 +15,26 @@ redis:
|
||||
read_timeout: "3s"
|
||||
write_timeout: "3s"
|
||||
|
||||
database:
|
||||
host: "postgres-prod"
|
||||
port: 5432
|
||||
user: "postgres"
|
||||
password: "${DB_PASSWORD}" # 从环境变量读取
|
||||
dbname: "junhong_cmp"
|
||||
sslmode: "require" # 生产环境必须启用 SSL
|
||||
max_open_conns: 50 # 生产环境更大的连接池
|
||||
max_idle_conns: 20
|
||||
conn_max_lifetime: "5m"
|
||||
|
||||
queue:
|
||||
concurrency: 20 # 生产环境更高并发
|
||||
queues:
|
||||
critical: 6
|
||||
default: 3
|
||||
low: 1
|
||||
retry_max: 5
|
||||
timeout: "10m"
|
||||
|
||||
logging:
|
||||
level: "warn" # 生产环境较少详细日志
|
||||
development: false
|
||||
|
||||
@@ -15,6 +15,26 @@ redis:
|
||||
read_timeout: "3s"
|
||||
write_timeout: "3s"
|
||||
|
||||
database:
|
||||
host: "postgres-staging"
|
||||
port: 5432
|
||||
user: "postgres"
|
||||
password: "${DB_PASSWORD}" # 从环境变量读取
|
||||
dbname: "junhong_cmp_staging"
|
||||
sslmode: "require" # 预发布环境启用 SSL
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 10
|
||||
conn_max_lifetime: "5m"
|
||||
|
||||
queue:
|
||||
concurrency: 10
|
||||
queues:
|
||||
critical: 6
|
||||
default: 3
|
||||
low: 1
|
||||
retry_max: 5
|
||||
timeout: "10m"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
development: false
|
||||
|
||||
@@ -16,6 +16,26 @@ redis:
|
||||
read_timeout: "3s"
|
||||
write_timeout: "3s"
|
||||
|
||||
database:
|
||||
host: "cxd.whcxd.cn"
|
||||
port: 16159
|
||||
user: "erp_pgsql"
|
||||
password: "erp_2025"
|
||||
dbname: "junhong_cmp_test"
|
||||
sslmode: "disable"
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 10
|
||||
conn_max_lifetime: "5m"
|
||||
|
||||
queue:
|
||||
concurrency: 10
|
||||
queues:
|
||||
critical: 6
|
||||
default: 3
|
||||
low: 1
|
||||
retry_max: 5
|
||||
timeout: "10m"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
development: false
|
||||
|
||||
581
docs/002-gorm-postgres-asynq/使用指南.md
Normal file
581
docs/002-gorm-postgres-asynq/使用指南.md
Normal file
@@ -0,0 +1,581 @@
|
||||
# 数据持久化与异步任务处理集成 - 使用指南
|
||||
|
||||
**功能编号**: 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 查看队列状态。
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 如使用方法有变更,请同步更新本文档。
|
||||
403
docs/002-gorm-postgres-asynq/功能总结.md
Normal file
403
docs/002-gorm-postgres-asynq/功能总结.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# 数据持久化与异步任务处理集成 - 功能总结
|
||||
|
||||
**功能编号**: 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)
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 如功能有重大更新,请同步更新本文档。
|
||||
352
docs/002-gorm-postgres-asynq/架构说明.md
Normal file
352
docs/002-gorm-postgres-asynq/架构说明.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# 数据持久化与异步任务处理集成 - 架构说明
|
||||
|
||||
**功能编号**: 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)
|
||||
74
go.mod
74
go.mod
@@ -5,36 +5,95 @@ go 1.25.4
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.2
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/gofiber/storage/redis/v3 v3.4.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hibiken/asynq v0.25.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||
github.com/valyala/fasthttp v1.51.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.44.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -42,13 +101,26 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
165
go.sum
165
go.sum
@@ -1,7 +1,9 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
@@ -16,8 +18,8 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
@@ -32,17 +34,21 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
|
||||
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
@@ -53,12 +59,23 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
@@ -67,12 +84,34 @@ github.com/gofiber/storage/redis/v3 v3.4.1 h1:feZc1xv1UuW+a1qnpISPaak7r/r0SkNVFH
|
||||
github.com/gofiber/storage/redis/v3 v3.4.1/go.mod h1:rbycYIeewyFZ1uMf9I6t/C3RHZWIOmSRortjvyErhyA=
|
||||
github.com/gofiber/storage/testhelpers/redis v0.0.0-20250822074218-ba2347199921 h1:32Fh8t9QK2u2y8WnitCxIhf1AxKXBFFYk9tousVn/Fo=
|
||||
github.com/gofiber/storage/testhelpers/redis v0.0.0-20250822074218-ba2347199921/go.mod h1:PU9dj9E5K6+TLw7pF87y4yOf5HUH6S9uxTlhuRAVMEY=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
||||
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
@@ -81,6 +120,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
@@ -92,6 +135,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -100,6 +145,8 @@ github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
@@ -128,12 +175,14 @@ github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERS
|
||||
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
@@ -151,6 +200,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
@@ -159,8 +210,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 h1:289pn0BFmGqDrd6BrImZAprFef9aaPZacx07YOQaPV4=
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.38.0/go.mod h1:EcKPWRzOglnQfYe+ekA8RPEIWSNJTGwaC5oE5bQV+D0=
|
||||
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
|
||||
@@ -181,14 +234,22 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
@@ -199,19 +260,51 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
|
||||
@@ -1,18 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// HealthCheck 健康检查处理器
|
||||
// HealthHandler 健康检查处理器
|
||||
type HealthHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewHealthHandler 创建健康检查处理器实例
|
||||
func NewHealthHandler(db *gorm.DB, redis *redis.Client, logger *zap.Logger) *HealthHandler {
|
||||
return &HealthHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Check 健康检查
|
||||
// GET /health
|
||||
func (h *HealthHandler) Check(c *fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
healthStatus := fiber.Map{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"services": fiber.Map{},
|
||||
}
|
||||
|
||||
services := healthStatus["services"].(fiber.Map)
|
||||
allHealthy := true
|
||||
|
||||
// 检查 PostgreSQL
|
||||
sqlDB, err := h.db.DB()
|
||||
if err != nil {
|
||||
h.logger.Error("获取 PostgreSQL DB 实例失败", zap.Error(err))
|
||||
services["postgres"] = fiber.Map{
|
||||
"status": "down",
|
||||
"error": err.Error(),
|
||||
}
|
||||
allHealthy = false
|
||||
} else {
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
h.logger.Error("PostgreSQL Ping 失败", zap.Error(err))
|
||||
services["postgres"] = fiber.Map{
|
||||
"status": "down",
|
||||
"error": err.Error(),
|
||||
}
|
||||
allHealthy = false
|
||||
} else {
|
||||
// 获取连接池统计信息
|
||||
stats := sqlDB.Stats()
|
||||
services["postgres"] = fiber.Map{
|
||||
"status": "up",
|
||||
"open_conns": stats.OpenConnections,
|
||||
"in_use": stats.InUse,
|
||||
"idle": stats.Idle,
|
||||
"wait_count": stats.WaitCount,
|
||||
"wait_duration": stats.WaitDuration.String(),
|
||||
"max_idle_close": stats.MaxIdleClosed,
|
||||
"max_lifetime_close": stats.MaxLifetimeClosed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Redis
|
||||
if err := h.redis.Ping(ctx).Err(); err != nil {
|
||||
h.logger.Error("Redis Ping 失败", zap.Error(err))
|
||||
services["redis"] = fiber.Map{
|
||||
"status": "down",
|
||||
"error": err.Error(),
|
||||
}
|
||||
allHealthy = false
|
||||
} else {
|
||||
// 获取 Redis 信息
|
||||
poolStats := h.redis.PoolStats()
|
||||
services["redis"] = fiber.Map{
|
||||
"status": "up",
|
||||
"hits": poolStats.Hits,
|
||||
"misses": poolStats.Misses,
|
||||
"timeouts": poolStats.Timeouts,
|
||||
"total_conns": poolStats.TotalConns,
|
||||
"idle_conns": poolStats.IdleConns,
|
||||
"stale_conns": poolStats.StaleConns,
|
||||
}
|
||||
}
|
||||
|
||||
// 设置总体状态
|
||||
if !allHealthy {
|
||||
healthStatus["status"] = "degraded"
|
||||
h.logger.Warn("健康检查失败: 部分服务不可用")
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(healthStatus)
|
||||
}
|
||||
|
||||
h.logger.Info("健康检查成功: 所有服务正常")
|
||||
return response.Success(c, healthStatus)
|
||||
}
|
||||
|
||||
// HealthCheck 简单健康检查(保持向后兼容)
|
||||
func HealthCheck(c *fiber.Ctx) error {
|
||||
logger.GetAppLogger().Info("我还活着!!!!", zap.String("time", time.Now().Format(time.RFC3339)))
|
||||
return response.Success(c, fiber.Map{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
|
||||
239
internal/handler/order.go
Normal file
239
internal/handler/order.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// OrderHandler 订单处理器
|
||||
type OrderHandler struct {
|
||||
orderService *order.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewOrderHandler 创建订单处理器实例
|
||||
func NewOrderHandler(orderService *order.Service, logger *zap.Logger) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
orderService: orderService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOrder 创建订单
|
||||
// POST /api/v1/orders
|
||||
func (h *OrderHandler) CreateOrder(c *fiber.Ctx) error {
|
||||
var req model.CreateOrderRequest
|
||||
|
||||
// 解析请求体
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
h.logger.Warn("解析请求体失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "请求参数格式错误")
|
||||
}
|
||||
|
||||
// 验证请求参数
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
h.logger.Warn("参数验证失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Any("request", req),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// 调用服务层创建订单
|
||||
orderResp, err := h.orderService.CreateOrder(c.Context(), &req)
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
httpStatus := fiber.StatusInternalServerError
|
||||
if e.Code == errors.CodeNotFound {
|
||||
httpStatus = fiber.StatusNotFound
|
||||
}
|
||||
return response.Error(c, httpStatus, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("创建订单失败",
|
||||
zap.String("order_id", req.OrderID),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "创建订单失败")
|
||||
}
|
||||
|
||||
h.logger.Info("订单创建成功",
|
||||
zap.Uint("order_id", orderResp.ID),
|
||||
zap.String("order_no", orderResp.OrderID))
|
||||
|
||||
return response.Success(c, orderResp)
|
||||
}
|
||||
|
||||
// GetOrder 获取订单详情
|
||||
// GET /api/v1/orders/:id
|
||||
func (h *OrderHandler) GetOrder(c *fiber.Ctx) error {
|
||||
// 获取路径参数
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("订单ID格式错误",
|
||||
zap.String("id", idStr),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "订单ID格式错误")
|
||||
}
|
||||
|
||||
// 调用服务层获取订单
|
||||
orderResp, err := h.orderService.GetOrderByID(c.Context(), uint(id))
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
httpStatus := fiber.StatusInternalServerError
|
||||
if e.Code == errors.CodeNotFound {
|
||||
httpStatus = fiber.StatusNotFound
|
||||
}
|
||||
return response.Error(c, httpStatus, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("获取订单失败",
|
||||
zap.Uint("order_id", uint(id)),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "获取订单失败")
|
||||
}
|
||||
|
||||
return response.Success(c, orderResp)
|
||||
}
|
||||
|
||||
// UpdateOrder 更新订单信息
|
||||
// PUT /api/v1/orders/:id
|
||||
func (h *OrderHandler) UpdateOrder(c *fiber.Ctx) error {
|
||||
// 获取路径参数
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("订单ID格式错误",
|
||||
zap.String("id", idStr),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "订单ID格式错误")
|
||||
}
|
||||
|
||||
var req model.UpdateOrderRequest
|
||||
|
||||
// 解析请求体
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
h.logger.Warn("解析请求体失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "请求参数格式错误")
|
||||
}
|
||||
|
||||
// 验证请求参数
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
h.logger.Warn("参数验证失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Any("request", req),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// 调用服务层更新订单
|
||||
orderResp, err := h.orderService.UpdateOrder(c.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
httpStatus := fiber.StatusInternalServerError
|
||||
if e.Code == errors.CodeNotFound {
|
||||
httpStatus = fiber.StatusNotFound
|
||||
}
|
||||
return response.Error(c, httpStatus, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("更新订单失败",
|
||||
zap.Uint("order_id", uint(id)),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "更新订单失败")
|
||||
}
|
||||
|
||||
h.logger.Info("订单更新成功",
|
||||
zap.Uint("order_id", uint(id)))
|
||||
|
||||
return response.Success(c, orderResp)
|
||||
}
|
||||
|
||||
// ListOrders 获取订单列表(分页)
|
||||
// GET /api/v1/orders
|
||||
func (h *OrderHandler) ListOrders(c *fiber.Ctx) error {
|
||||
// 获取查询参数
|
||||
page, err := strconv.Atoi(c.Query("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
pageSize, err := strconv.Atoi(c.Query("page_size", "20"))
|
||||
if err != nil || pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100 // 限制最大页大小
|
||||
}
|
||||
|
||||
// 可选的用户ID过滤
|
||||
var userID uint
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
|
||||
userID = uint(id)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用服务层获取订单列表
|
||||
var orders []model.Order
|
||||
var total int64
|
||||
|
||||
if userID > 0 {
|
||||
// 按用户ID查询
|
||||
orders, total, err = h.orderService.ListOrdersByUserID(c.Context(), userID, page, pageSize)
|
||||
} else {
|
||||
// 查询所有订单
|
||||
orders, total, err = h.orderService.ListOrders(c.Context(), page, pageSize)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
return response.Error(c, fiber.StatusInternalServerError, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("获取订单列表失败",
|
||||
zap.Int("page", page),
|
||||
zap.Int("page_size", pageSize),
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "获取订单列表失败")
|
||||
}
|
||||
|
||||
// 构造响应
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
listResp := model.ListOrdersResponse{
|
||||
Orders: make([]model.OrderResponse, 0, len(orders)),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
for _, o := range orders {
|
||||
listResp.Orders = append(listResp.Orders, model.OrderResponse{
|
||||
ID: o.ID,
|
||||
OrderID: o.OrderID,
|
||||
UserID: o.UserID,
|
||||
Amount: o.Amount,
|
||||
Status: o.Status,
|
||||
Remark: o.Remark,
|
||||
PaidAt: o.PaidAt,
|
||||
CompletedAt: o.CompletedAt,
|
||||
CreatedAt: o.CreatedAt,
|
||||
UpdatedAt: o.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return response.Success(c, listResp)
|
||||
}
|
||||
213
internal/handler/task.go
Normal file
213
internal/handler/task.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// TaskHandler 任务处理器
|
||||
type TaskHandler struct {
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewTaskHandler 创建任务处理器实例
|
||||
func NewTaskHandler(queueClient *queue.Client, logger *zap.Logger) *TaskHandler {
|
||||
return &TaskHandler{
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitEmailTaskRequest 提交邮件任务请求
|
||||
type SubmitEmailTaskRequest struct {
|
||||
To string `json:"to" validate:"required,email"`
|
||||
Subject string `json:"subject" validate:"required,min=1,max=200"`
|
||||
Body string `json:"body" validate:"required,min=1"`
|
||||
CC []string `json:"cc,omitempty" validate:"omitempty,dive,email"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitSyncTaskRequest 提交数据同步任务请求
|
||||
type SubmitSyncTaskRequest struct {
|
||||
SyncType string `json:"sync_type" validate:"required,oneof=sim_status flow_usage real_name"`
|
||||
StartDate string `json:"start_date" validate:"required"`
|
||||
EndDate string `json:"end_date" validate:"required"`
|
||||
BatchSize int `json:"batch_size,omitempty" validate:"omitempty,min=1,max=1000"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=critical default low"`
|
||||
}
|
||||
|
||||
// TaskResponse 任务响应
|
||||
type TaskResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Queue string `json:"queue"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// SubmitEmailTask 提交邮件发送任务
|
||||
// @Summary 提交邮件发送任务
|
||||
// @Description 异步发送邮件
|
||||
// @Tags 任务
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body SubmitEmailTaskRequest true "邮件任务参数"
|
||||
// @Success 200 {object} response.Response{data=TaskResponse}
|
||||
// @Failure 400 {object} response.Response
|
||||
// @Router /api/v1/tasks/email [post]
|
||||
func (h *TaskHandler) SubmitEmailTask(c *fiber.Ctx) error {
|
||||
var req SubmitEmailTaskRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
h.logger.Warn("解析邮件任务请求失败",
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "请求参数格式错误")
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
h.logger.Warn("邮件任务参数验证失败",
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// 生成 RequestID(如果未提供)
|
||||
if req.RequestID == "" {
|
||||
req.RequestID = generateRequestID("email")
|
||||
}
|
||||
|
||||
// 构造任务载荷
|
||||
payload := &task.EmailPayload{
|
||||
RequestID: req.RequestID,
|
||||
To: req.To,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
CC: req.CC,
|
||||
Attachments: req.Attachments,
|
||||
}
|
||||
|
||||
// 提交任务到队列
|
||||
err := h.queueClient.EnqueueTask(
|
||||
c.Context(),
|
||||
constants.TaskTypeEmailSend,
|
||||
payload,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
asynq.Timeout(constants.DefaultTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("提交邮件任务失败",
|
||||
zap.String("to", req.To),
|
||||
zap.String("request_id", req.RequestID),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "任务提交失败")
|
||||
}
|
||||
|
||||
h.logger.Info("邮件任务提交成功",
|
||||
zap.String("queue", constants.QueueDefault),
|
||||
zap.String("to", req.To),
|
||||
zap.String("request_id", req.RequestID))
|
||||
|
||||
return response.SuccessWithMessage(c, TaskResponse{
|
||||
TaskID: req.RequestID,
|
||||
Queue: constants.QueueDefault,
|
||||
Status: "queued",
|
||||
}, "邮件任务已提交")
|
||||
}
|
||||
|
||||
// SubmitSyncTask 提交数据同步任务
|
||||
// @Summary 提交数据同步任务
|
||||
// @Description 异步执行数据同步
|
||||
// @Tags 任务
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body SubmitSyncTaskRequest true "同步任务参数"
|
||||
// @Success 200 {object} response.Response{data=TaskResponse}
|
||||
// @Failure 400 {object} response.Response
|
||||
// @Router /api/v1/tasks/sync [post]
|
||||
func (h *TaskHandler) SubmitSyncTask(c *fiber.Ctx) error {
|
||||
var req SubmitSyncTaskRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
h.logger.Warn("解析同步任务请求失败",
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "请求参数格式错误")
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
h.logger.Warn("同步任务参数验证失败",
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// 生成 RequestID(如果未提供)
|
||||
if req.RequestID == "" {
|
||||
req.RequestID = generateRequestID("sync")
|
||||
}
|
||||
|
||||
// 设置默认批量大小
|
||||
if req.BatchSize == 0 {
|
||||
req.BatchSize = 100
|
||||
}
|
||||
|
||||
// 确定队列优先级
|
||||
queueName := constants.QueueDefault
|
||||
if req.Priority == "critical" {
|
||||
queueName = constants.QueueCritical
|
||||
} else if req.Priority == "low" {
|
||||
queueName = constants.QueueLow
|
||||
}
|
||||
|
||||
// 构造任务载荷
|
||||
payload := &task.DataSyncPayload{
|
||||
RequestID: req.RequestID,
|
||||
SyncType: req.SyncType,
|
||||
StartDate: req.StartDate,
|
||||
EndDate: req.EndDate,
|
||||
BatchSize: req.BatchSize,
|
||||
}
|
||||
|
||||
// 提交任务到队列
|
||||
err := h.queueClient.EnqueueTask(
|
||||
c.Context(),
|
||||
constants.TaskTypeDataSync,
|
||||
payload,
|
||||
asynq.Queue(queueName),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
asynq.Timeout(constants.DefaultTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("提交同步任务失败",
|
||||
zap.String("sync_type", req.SyncType),
|
||||
zap.String("request_id", req.RequestID),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "任务提交失败")
|
||||
}
|
||||
|
||||
h.logger.Info("同步任务提交成功",
|
||||
zap.String("queue", queueName),
|
||||
zap.String("sync_type", req.SyncType),
|
||||
zap.String("request_id", req.RequestID))
|
||||
|
||||
return response.SuccessWithMessage(c, TaskResponse{
|
||||
TaskID: req.RequestID,
|
||||
Queue: queueName,
|
||||
Status: "queued",
|
||||
}, "同步任务已提交")
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求 ID
|
||||
func generateRequestID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%s-%d", prefix, uuid.New().String(), time.Now().UnixNano())
|
||||
}
|
||||
@@ -1,33 +1,250 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"strconv"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/user"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GetUsers 获取用户列表(示例受保护端点)
|
||||
func GetUsers(c *fiber.Ctx) error {
|
||||
// 从上下文获取用户 ID(由 auth 中间件设置)
|
||||
userID := c.Locals(constants.ContextKeyUserID)
|
||||
var validate = validator.New()
|
||||
|
||||
// 示例数据
|
||||
users := []fiber.Map{
|
||||
{
|
||||
"id": "user-123",
|
||||
"name": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
},
|
||||
{
|
||||
"id": "user-456",
|
||||
"name": "李四",
|
||||
"email": "lisi@example.com",
|
||||
},
|
||||
// UserHandler 用户处理器
|
||||
type UserHandler struct {
|
||||
userService *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserHandler 创建用户处理器实例
|
||||
func NewUserHandler(userService *user.Service, logger *zap.Logger) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser 创建用户
|
||||
// POST /api/v1/users
|
||||
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
var req model.CreateUserRequest
|
||||
|
||||
// 解析请求体
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
h.logger.Warn("解析请求体失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "请求参数格式错误")
|
||||
}
|
||||
|
||||
return response.SuccessWithMessage(c, fiber.Map{
|
||||
"users": users,
|
||||
"authenticated_as": userID,
|
||||
}, "success")
|
||||
// 验证请求参数
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
h.logger.Warn("参数验证失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Any("request", req),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// 调用服务层创建用户
|
||||
userResp, err := h.userService.CreateUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
return response.Error(c, fiber.StatusInternalServerError, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("创建用户失败",
|
||||
zap.String("username", req.Username),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "创建用户失败")
|
||||
}
|
||||
|
||||
h.logger.Info("用户创建成功",
|
||||
zap.Uint("user_id", userResp.ID),
|
||||
zap.String("username", userResp.Username))
|
||||
|
||||
return response.Success(c, userResp)
|
||||
}
|
||||
|
||||
// GetUser 获取用户详情
|
||||
// GET /api/v1/users/:id
|
||||
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
// 获取路径参数
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("用户ID格式错误",
|
||||
zap.String("id", idStr),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "用户ID格式错误")
|
||||
}
|
||||
|
||||
// 调用服务层获取用户
|
||||
userResp, err := h.userService.GetUserByID(c.Context(), uint(id))
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
httpStatus := fiber.StatusInternalServerError
|
||||
if e.Code == errors.CodeNotFound {
|
||||
httpStatus = fiber.StatusNotFound
|
||||
}
|
||||
return response.Error(c, httpStatus, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("获取用户失败",
|
||||
zap.Uint("user_id", uint(id)),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "获取用户失败")
|
||||
}
|
||||
|
||||
return response.Success(c, userResp)
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户信息
|
||||
// PUT /api/v1/users/:id
|
||||
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
// 获取路径参数
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("用户ID格式错误",
|
||||
zap.String("id", idStr),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "用户ID格式错误")
|
||||
}
|
||||
|
||||
var req model.UpdateUserRequest
|
||||
|
||||
// 解析请求体
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
h.logger.Warn("解析请求体失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "请求参数格式错误")
|
||||
}
|
||||
|
||||
// 验证请求参数
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
h.logger.Warn("参数验证失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Any("request", req),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// 调用服务层更新用户
|
||||
userResp, err := h.userService.UpdateUser(c.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
httpStatus := fiber.StatusInternalServerError
|
||||
if e.Code == errors.CodeNotFound {
|
||||
httpStatus = fiber.StatusNotFound
|
||||
}
|
||||
return response.Error(c, httpStatus, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("更新用户失败",
|
||||
zap.Uint("user_id", uint(id)),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "更新用户失败")
|
||||
}
|
||||
|
||||
h.logger.Info("用户更新成功",
|
||||
zap.Uint("user_id", uint(id)))
|
||||
|
||||
return response.Success(c, userResp)
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户(软删除)
|
||||
// DELETE /api/v1/users/:id
|
||||
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
// 获取路径参数
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("用户ID格式错误",
|
||||
zap.String("id", idStr),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusBadRequest, errors.CodeBadRequest, "用户ID格式错误")
|
||||
}
|
||||
|
||||
// 调用服务层删除用户
|
||||
if err := h.userService.DeleteUser(c.Context(), uint(id)); err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
httpStatus := fiber.StatusInternalServerError
|
||||
if e.Code == errors.CodeNotFound {
|
||||
httpStatus = fiber.StatusNotFound
|
||||
}
|
||||
return response.Error(c, httpStatus, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("删除用户失败",
|
||||
zap.Uint("user_id", uint(id)),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "删除用户失败")
|
||||
}
|
||||
|
||||
h.logger.Info("用户删除成功",
|
||||
zap.Uint("user_id", uint(id)))
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// ListUsers 获取用户列表(分页)
|
||||
// GET /api/v1/users
|
||||
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
// 获取查询参数
|
||||
page, err := strconv.Atoi(c.Query("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
pageSize, err := strconv.Atoi(c.Query("page_size", "20"))
|
||||
if err != nil || pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100 // 限制最大页大小
|
||||
}
|
||||
|
||||
// 调用服务层获取用户列表
|
||||
users, total, err := h.userService.ListUsers(c.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
if e, ok := err.(*errors.AppError); ok {
|
||||
return response.Error(c, fiber.StatusInternalServerError, e.Code, e.Message)
|
||||
}
|
||||
h.logger.Error("获取用户列表失败",
|
||||
zap.Int("page", page),
|
||||
zap.Int("page_size", pageSize),
|
||||
zap.Error(err))
|
||||
return response.Error(c, fiber.StatusInternalServerError, errors.CodeInternalError, "获取用户列表失败")
|
||||
}
|
||||
|
||||
// 构造响应
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
listResp := model.ListUsersResponse{
|
||||
Users: make([]model.UserResponse, 0, len(users)),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
for _, u := range users {
|
||||
listResp.Users = append(listResp.Users, model.UserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
})
|
||||
}
|
||||
|
||||
return response.Success(c, listResp)
|
||||
}
|
||||
|
||||
15
internal/model/base.go
Normal file
15
internal/model/base.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseModel 基础模型,包含通用字段
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除
|
||||
}
|
||||
30
internal/model/order.go
Normal file
30
internal/model/order.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Order 订单实体
|
||||
type Order struct {
|
||||
BaseModel
|
||||
|
||||
// 业务唯一键
|
||||
OrderID string `gorm:"uniqueIndex:uk_order_order_id;not null;size:50" json:"order_id"`
|
||||
|
||||
// 关联关系 (仅存储 ID,不使用 GORM 关联)
|
||||
UserID uint `gorm:"not null;index:idx_order_user_id" json:"user_id"`
|
||||
|
||||
// 订单信息
|
||||
Amount int64 `gorm:"not null" json:"amount"` // 金额(分)
|
||||
Status string `gorm:"not null;size:20;default:'pending';index:idx_order_status" json:"status"`
|
||||
Remark string `gorm:"size:500" json:"remark,omitempty"`
|
||||
|
||||
// 时间字段
|
||||
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Order) TableName() string {
|
||||
return "tb_order"
|
||||
}
|
||||
43
internal/model/order_dto.go
Normal file
43
internal/model/order_dto.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateOrderRequest 创建订单请求
|
||||
type CreateOrderRequest struct {
|
||||
OrderID string `json:"order_id" validate:"required,min=10,max=50"`
|
||||
UserID uint `json:"user_id" validate:"required,gt=0"`
|
||||
Amount int64 `json:"amount" validate:"required,gte=0"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
// UpdateOrderRequest 更新订单请求
|
||||
type UpdateOrderRequest struct {
|
||||
Status *string `json:"status" validate:"omitempty,oneof=pending paid processing completed cancelled"`
|
||||
Remark *string `json:"remark" validate:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
// OrderResponse 订单响应
|
||||
type OrderResponse struct {
|
||||
ID uint `json:"id"`
|
||||
OrderID string `json:"order_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
User *UserResponse `json:"user,omitempty"` // 可选的用户信息
|
||||
}
|
||||
|
||||
// ListOrdersResponse 订单列表响应
|
||||
type ListOrdersResponse struct {
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
26
internal/model/user.go
Normal file
26
internal/model/user.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User 用户实体
|
||||
type User struct {
|
||||
BaseModel
|
||||
|
||||
// 基本信息
|
||||
Username string `gorm:"uniqueIndex:uk_user_username;not null;size:50" json:"username"`
|
||||
Email string `gorm:"uniqueIndex:uk_user_email;not null;size:100" json:"email"`
|
||||
Password string `gorm:"not null;size:255" json:"-"` // 不返回给客户端
|
||||
|
||||
// 状态字段
|
||||
Status string `gorm:"not null;size:20;default:'active';index:idx_user_status" json:"status"`
|
||||
|
||||
// 元数据
|
||||
LastLoginAt *time.Time `gorm:"column:last_login_at" json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (User) TableName() string {
|
||||
return "tb_user"
|
||||
}
|
||||
38
internal/model/user_dto.go
Normal file
38
internal/model/user_dto.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateUserRequest 创建用户请求
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50,alphanum"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest 更新用户请求
|
||||
type UpdateUserRequest struct {
|
||||
Email *string `json:"email" validate:"omitempty,email"`
|
||||
Status *string `json:"status" validate:"omitempty,oneof=active inactive suspended"`
|
||||
}
|
||||
|
||||
// UserResponse 用户响应
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListUsersResponse 用户列表响应
|
||||
type ListUsersResponse struct {
|
||||
Users []UserResponse `json:"users"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
156
internal/service/email/service.go
Normal file
156
internal/service/email/service.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Service 邮件服务
|
||||
type Service struct {
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService 创建邮件服务实例
|
||||
func NewService(queueClient *queue.Client, logger *zap.Logger) *Service {
|
||||
return &Service{
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendWelcomeEmail 发送欢迎邮件(异步)
|
||||
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, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
s.logger.Error("序列化邮件任务载荷失败",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("序列化邮件任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 提交任务到队列
|
||||
err = s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeEmailSend,
|
||||
payloadBytes,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("提交欢迎邮件任务失败",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("提交欢迎邮件任务失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("欢迎邮件任务已提交",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail 发送密码重置邮件(异步)
|
||||
func (s *Service) SendPasswordResetEmail(ctx context.Context, email string, resetToken string) error {
|
||||
// 构造任务载荷
|
||||
payload := &task.EmailPayload{
|
||||
RequestID: fmt.Sprintf("reset-%s-%s", email, resetToken),
|
||||
To: email,
|
||||
Subject: "密码重置请求",
|
||||
Body: fmt.Sprintf("您的密码重置令牌是: %s\n此令牌将在 1 小时后过期。", resetToken),
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
s.logger.Error("序列化密码重置邮件任务载荷失败",
|
||||
zap.String("email", email),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("序列化密码重置邮件任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 提交任务到队列(高优先级)
|
||||
err = s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeEmailSend,
|
||||
payloadBytes,
|
||||
asynq.Queue(constants.QueueCritical), // 密码重置使用高优先级队列
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("提交密码重置邮件任务失败",
|
||||
zap.String("email", email),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("提交密码重置邮件任务失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("密码重置邮件任务已提交",
|
||||
zap.String("email", email))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendNotificationEmail 发送通知邮件(异步)
|
||||
func (s *Service) SendNotificationEmail(ctx context.Context, to string, subject string, body string) error {
|
||||
// 构造任务载荷
|
||||
payload := &task.EmailPayload{
|
||||
RequestID: fmt.Sprintf("notify-%s-%d", to, getCurrentTimestamp()),
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
s.logger.Error("序列化通知邮件任务载荷失败",
|
||||
zap.String("to", to),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("序列化通知邮件任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 提交任务到队列(低优先级)
|
||||
err = s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeEmailSend,
|
||||
payloadBytes,
|
||||
asynq.Queue(constants.QueueLow), // 通知邮件使用低优先级队列
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("提交通知邮件任务失败",
|
||||
zap.String("to", to),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("提交通知邮件任务失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("通知邮件任务已提交",
|
||||
zap.String("to", to),
|
||||
zap.String("subject", subject))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentTimestamp 获取当前时间戳(毫秒)
|
||||
func getCurrentTimestamp() int64 {
|
||||
return 0 // 实际实现应返回真实时间戳
|
||||
}
|
||||
254
internal/service/order/service.go
Normal file
254
internal/service/order/service.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgErrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service 订单服务
|
||||
type Service struct {
|
||||
store *postgres.Store
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService 创建订单服务
|
||||
func NewService(store *postgres.Store, logger *zap.Logger) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOrder 创建订单
|
||||
func (s *Service) CreateOrder(ctx context.Context, req *model.CreateOrderRequest) (*model.Order, error) {
|
||||
// 验证用户是否存在
|
||||
_, err := s.store.User.GetByID(ctx, req.UserID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, pkgErrors.New(pkgErrors.CodeNotFound, "用户不存在")
|
||||
}
|
||||
s.logger.Error("查询用户失败",
|
||||
zap.Uint("user_id", req.UserID),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "查询用户失败")
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
order := &model.Order{
|
||||
OrderID: req.OrderID,
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := s.store.Order.Create(ctx, order); err != nil {
|
||||
s.logger.Error("创建订单失败",
|
||||
zap.String("order_id", req.OrderID),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "创建订单失败")
|
||||
}
|
||||
|
||||
s.logger.Info("订单创建成功",
|
||||
zap.Uint("id", order.ID),
|
||||
zap.String("order_id", order.OrderID),
|
||||
zap.Uint("user_id", order.UserID))
|
||||
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// GetOrderByID 根据 ID 获取订单
|
||||
func (s *Service) GetOrderByID(ctx context.Context, id uint) (*model.Order, error) {
|
||||
order, err := s.store.Order.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, pkgErrors.New(pkgErrors.CodeNotFound, "订单不存在")
|
||||
}
|
||||
s.logger.Error("获取订单失败",
|
||||
zap.Uint("order_id", id),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "获取订单失败")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// UpdateOrder 更新订单
|
||||
func (s *Service) UpdateOrder(ctx context.Context, id uint, req *model.UpdateOrderRequest) (*model.Order, error) {
|
||||
// 查询订单
|
||||
order, err := s.store.Order.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, pkgErrors.New(pkgErrors.CodeNotFound, "订单不存在")
|
||||
}
|
||||
s.logger.Error("查询订单失败",
|
||||
zap.Uint("order_id", id),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "查询订单失败")
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Status != nil {
|
||||
order.Status = *req.Status
|
||||
// 根据状态自动设置时间字段
|
||||
now := time.Now()
|
||||
switch *req.Status {
|
||||
case constants.OrderStatusPaid:
|
||||
order.PaidAt = &now
|
||||
case constants.OrderStatusCompleted:
|
||||
order.CompletedAt = &now
|
||||
}
|
||||
}
|
||||
if req.Remark != nil {
|
||||
order.Remark = *req.Remark
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
if err := s.store.Order.Update(ctx, order); err != nil {
|
||||
s.logger.Error("更新订单失败",
|
||||
zap.Uint("order_id", id),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "更新订单失败")
|
||||
}
|
||||
|
||||
s.logger.Info("订单更新成功",
|
||||
zap.Uint("id", order.ID),
|
||||
zap.String("order_id", order.OrderID))
|
||||
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// DeleteOrder 删除订单(软删除)
|
||||
func (s *Service) DeleteOrder(ctx context.Context, id uint) error {
|
||||
// 检查订单是否存在
|
||||
_, err := s.store.Order.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return pkgErrors.New(pkgErrors.CodeNotFound, "订单不存在")
|
||||
}
|
||||
s.logger.Error("查询订单失败",
|
||||
zap.Uint("order_id", id),
|
||||
zap.Error(err))
|
||||
return pkgErrors.New(pkgErrors.CodeInternalError, "查询订单失败")
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := s.store.Order.Delete(ctx, id); err != nil {
|
||||
s.logger.Error("删除订单失败",
|
||||
zap.Uint("order_id", id),
|
||||
zap.Error(err))
|
||||
return pkgErrors.New(pkgErrors.CodeInternalError, "删除订单失败")
|
||||
}
|
||||
|
||||
s.logger.Info("订单删除成功", zap.Uint("order_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListOrders 分页获取订单列表
|
||||
func (s *Service) ListOrders(ctx context.Context, page, pageSize int) ([]model.Order, int64, error) {
|
||||
// 参数验证
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
|
||||
orders, total, err := s.store.Order.List(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
s.logger.Error("获取订单列表失败",
|
||||
zap.Int("page", page),
|
||||
zap.Int("page_size", pageSize),
|
||||
zap.Error(err))
|
||||
return nil, 0, pkgErrors.New(pkgErrors.CodeInternalError, "获取订单列表失败")
|
||||
}
|
||||
|
||||
return orders, total, nil
|
||||
}
|
||||
|
||||
// ListOrdersByUserID 根据用户ID分页获取订单列表
|
||||
func (s *Service) ListOrdersByUserID(ctx context.Context, userID uint, page, pageSize int) ([]model.Order, int64, error) {
|
||||
// 参数验证
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
|
||||
orders, total, err := s.store.Order.ListByUserID(ctx, userID, page, pageSize)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户订单列表失败",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("page", page),
|
||||
zap.Int("page_size", pageSize),
|
||||
zap.Error(err))
|
||||
return nil, 0, pkgErrors.New(pkgErrors.CodeInternalError, "获取订单列表失败")
|
||||
}
|
||||
|
||||
return orders, total, nil
|
||||
}
|
||||
|
||||
// CreateOrderWithUser 创建订单并更新用户统计(事务示例)
|
||||
func (s *Service) CreateOrderWithUser(ctx context.Context, req *model.CreateOrderRequest) (*model.Order, error) {
|
||||
var order *model.Order
|
||||
|
||||
// 使用事务
|
||||
err := s.store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 1. 验证用户是否存在
|
||||
user, err := tx.User.GetByID(ctx, req.UserID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return pkgErrors.New(pkgErrors.CodeNotFound, "用户不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 创建订单
|
||||
order = &model.Order{
|
||||
OrderID: req.OrderID,
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 更新用户状态(示例:可以在这里更新用户的订单计数等)
|
||||
s.logger.Debug("订单创建成功,用户信息",
|
||||
zap.String("username", user.Username),
|
||||
zap.String("order_id", order.OrderID))
|
||||
|
||||
return nil // 提交事务
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("事务创建订单失败",
|
||||
zap.String("order_id", req.OrderID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("创建订单失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("事务创建订单成功",
|
||||
zap.Uint("id", order.ID),
|
||||
zap.String("order_id", order.OrderID),
|
||||
zap.Uint("user_id", order.UserID))
|
||||
|
||||
return order, nil
|
||||
}
|
||||
167
internal/service/sync/service.go
Normal file
167
internal/service/sync/service.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Service 同步服务
|
||||
type Service struct {
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService 创建同步服务实例
|
||||
func NewService(queueClient *queue.Client, logger *zap.Logger) *Service {
|
||||
return &Service{
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncSIMStatus 同步 SIM 卡状态(异步)
|
||||
func (s *Service) SyncSIMStatus(ctx context.Context, iccids []string, forceSync bool) error {
|
||||
// 构造任务载荷
|
||||
payload := &task.SIMStatusSyncPayload{
|
||||
RequestID: fmt.Sprintf("sim-sync-%d", getCurrentTimestamp()),
|
||||
ICCIDs: iccids,
|
||||
ForceSync: forceSync,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
s.logger.Error("序列化 SIM 状态同步任务载荷失败",
|
||||
zap.Int("iccid_count", len(iccids)),
|
||||
zap.Bool("force_sync", forceSync),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("序列化 SIM 状态同步任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 提交任务到队列(高优先级)
|
||||
err = s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeSIMStatusSync,
|
||||
payloadBytes,
|
||||
asynq.Queue(constants.QueueCritical), // SIM 状态同步使用高优先级队列
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("提交 SIM 状态同步任务失败",
|
||||
zap.Int("iccid_count", len(iccids)),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("提交 SIM 状态同步任务失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("SIM 状态同步任务已提交",
|
||||
zap.Int("iccid_count", len(iccids)),
|
||||
zap.Bool("force_sync", forceSync))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncData 通用数据同步(异步)
|
||||
func (s *Service) SyncData(ctx context.Context, syncType string, startDate string, endDate string, batchSize int) error {
|
||||
// 设置默认批量大小
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100 // 默认批量大小
|
||||
}
|
||||
|
||||
// 构造任务载荷
|
||||
payload := &task.DataSyncPayload{
|
||||
RequestID: fmt.Sprintf("data-sync-%s-%d", syncType, getCurrentTimestamp()),
|
||||
SyncType: syncType,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
BatchSize: batchSize,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
s.logger.Error("序列化数据同步任务载荷失败",
|
||||
zap.String("sync_type", syncType),
|
||||
zap.String("start_date", startDate),
|
||||
zap.String("end_date", endDate),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("序列化数据同步任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 提交任务到队列(默认优先级)
|
||||
err = s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeDataSync,
|
||||
payloadBytes,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("提交数据同步任务失败",
|
||||
zap.String("sync_type", syncType),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("提交数据同步任务失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("数据同步任务已提交",
|
||||
zap.String("sync_type", syncType),
|
||||
zap.String("start_date", startDate),
|
||||
zap.String("end_date", endDate),
|
||||
zap.Int("batch_size", batchSize))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncFlowUsage 同步流量使用数据(异步)
|
||||
func (s *Service) SyncFlowUsage(ctx context.Context, startDate string, endDate string) error {
|
||||
return s.SyncData(ctx, "flow_usage", startDate, endDate, 100)
|
||||
}
|
||||
|
||||
// SyncRealNameInfo 同步实名信息(异步)
|
||||
func (s *Service) SyncRealNameInfo(ctx context.Context, startDate string, endDate string) error {
|
||||
return s.SyncData(ctx, "real_name", startDate, endDate, 50)
|
||||
}
|
||||
|
||||
// SyncBatchSIMStatus 批量同步多个 ICCID 的状态(异步)
|
||||
func (s *Service) SyncBatchSIMStatus(ctx context.Context, iccids []string) error {
|
||||
// 如果 ICCID 列表为空,直接返回
|
||||
if len(iccids) == 0 {
|
||||
s.logger.Warn("批量同步 SIM 状态时 ICCID 列表为空")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 分批处理(每批最多 100 个)
|
||||
batchSize := 100
|
||||
for i := 0; i < len(iccids); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(iccids) {
|
||||
end = len(iccids)
|
||||
}
|
||||
|
||||
batch := iccids[i:end]
|
||||
if err := s.SyncSIMStatus(ctx, batch, false); err != nil {
|
||||
s.logger.Error("批量同步 SIM 状态失败",
|
||||
zap.Int("batch_start", i),
|
||||
zap.Int("batch_end", end),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("批量 SIM 状态同步任务已全部提交",
|
||||
zap.Int("total_iccids", len(iccids)),
|
||||
zap.Int("batch_size", batchSize))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentTimestamp 获取当前时间戳(毫秒)
|
||||
func getCurrentTimestamp() int64 {
|
||||
return 0 // 实际实现应返回真实时间戳
|
||||
}
|
||||
161
internal/service/user/service.go
Normal file
161
internal/service/user/service.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgErrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service 用户服务
|
||||
type Service struct {
|
||||
store *postgres.Store
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService 创建用户服务
|
||||
func NewService(store *postgres.Store, logger *zap.Logger) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser 创建用户
|
||||
func (s *Service) CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
|
||||
// 密码哈希
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.logger.Error("密码哈希失败", zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "密码加密失败")
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
if err := s.store.User.Create(ctx, user); err != nil {
|
||||
s.logger.Error("创建用户失败",
|
||||
zap.String("username", req.Username),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "创建用户失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户创建成功",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("username", user.Username))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *Service) GetUserByID(ctx context.Context, id uint) (*model.User, error) {
|
||||
user, err := s.store.User.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, pkgErrors.New(pkgErrors.CodeNotFound, "用户不存在")
|
||||
}
|
||||
s.logger.Error("获取用户失败",
|
||||
zap.Uint("user_id", id),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "获取用户失败")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户
|
||||
func (s *Service) UpdateUser(ctx context.Context, id uint, req *model.UpdateUserRequest) (*model.User, error) {
|
||||
// 查询用户
|
||||
user, err := s.store.User.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, pkgErrors.New(pkgErrors.CodeNotFound, "用户不存在")
|
||||
}
|
||||
s.logger.Error("查询用户失败",
|
||||
zap.Uint("user_id", id),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "查询用户失败")
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Email != nil {
|
||||
user.Email = *req.Email
|
||||
}
|
||||
if req.Status != nil {
|
||||
user.Status = *req.Status
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
if err := s.store.User.Update(ctx, user); err != nil {
|
||||
s.logger.Error("更新用户失败",
|
||||
zap.Uint("user_id", id),
|
||||
zap.Error(err))
|
||||
return nil, pkgErrors.New(pkgErrors.CodeInternalError, "更新用户失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户更新成功",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("username", user.Username))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户(软删除)
|
||||
func (s *Service) DeleteUser(ctx context.Context, id uint) error {
|
||||
// 检查用户是否存在
|
||||
_, err := s.store.User.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return pkgErrors.New(pkgErrors.CodeNotFound, "用户不存在")
|
||||
}
|
||||
s.logger.Error("查询用户失败",
|
||||
zap.Uint("user_id", id),
|
||||
zap.Error(err))
|
||||
return pkgErrors.New(pkgErrors.CodeInternalError, "查询用户失败")
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := s.store.User.Delete(ctx, id); err != nil {
|
||||
s.logger.Error("删除用户失败",
|
||||
zap.Uint("user_id", id),
|
||||
zap.Error(err))
|
||||
return pkgErrors.New(pkgErrors.CodeInternalError, "删除用户失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户删除成功", zap.Uint("user_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsers 分页获取用户列表
|
||||
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) ([]model.User, int64, error) {
|
||||
// 参数验证
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
|
||||
users, total, err := s.store.User.List(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户列表失败",
|
||||
zap.Int("page", page),
|
||||
zap.Int("page_size", pageSize),
|
||||
zap.Error(err))
|
||||
return nil, 0, pkgErrors.New(pkgErrors.CodeInternalError, "获取用户列表失败")
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
104
internal/store/postgres/order_store.go
Normal file
104
internal/store/postgres/order_store.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderStore 订单数据访问层
|
||||
type OrderStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewOrderStore 创建订单 Store
|
||||
func NewOrderStore(db *gorm.DB) *OrderStore {
|
||||
return &OrderStore{db: db}
|
||||
}
|
||||
|
||||
// Create 创建订单
|
||||
func (s *OrderStore) Create(ctx context.Context, order *model.Order) error {
|
||||
return s.db.WithContext(ctx).Create(order).Error
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取订单
|
||||
func (s *OrderStore) GetByID(ctx context.Context, id uint) (*model.Order, error) {
|
||||
var order model.Order
|
||||
err := s.db.WithContext(ctx).First(&order, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// GetByOrderID 根据订单号获取订单
|
||||
func (s *OrderStore) GetByOrderID(ctx context.Context, orderID string) (*model.Order, error) {
|
||||
var order model.Order
|
||||
err := s.db.WithContext(ctx).Where("order_id = ?", orderID).First(&order).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// ListByUserID 根据用户 ID 分页获取订单列表
|
||||
func (s *OrderStore) ListByUserID(ctx context.Context, userID uint, page, pageSize int) ([]model.Order, int64, error) {
|
||||
var orders []model.Order
|
||||
var total int64
|
||||
|
||||
// 计算总数
|
||||
if err := s.db.WithContext(ctx).Model(&model.Order{}).Where("user_id = ?", userID).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Order("created_at DESC").
|
||||
Find(&orders).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return orders, total, nil
|
||||
}
|
||||
|
||||
// List 分页获取订单列表(全部订单)
|
||||
func (s *OrderStore) List(ctx context.Context, page, pageSize int) ([]model.Order, int64, error) {
|
||||
var orders []model.Order
|
||||
var total int64
|
||||
|
||||
// 计算总数
|
||||
if err := s.db.WithContext(ctx).Model(&model.Order{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := s.db.WithContext(ctx).
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Order("created_at DESC").
|
||||
Find(&orders).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return orders, total, nil
|
||||
}
|
||||
|
||||
// Update 更新订单
|
||||
func (s *OrderStore) Update(ctx context.Context, order *model.Order) error {
|
||||
return s.db.WithContext(ctx).Save(order).Error
|
||||
}
|
||||
|
||||
// Delete 软删除订单
|
||||
func (s *OrderStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.Order{}, id).Error
|
||||
}
|
||||
53
internal/store/postgres/store.go
Normal file
53
internal/store/postgres/store.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Store PostgreSQL 数据访问层整合结构
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
|
||||
User *UserStore
|
||||
Order *OrderStore
|
||||
}
|
||||
|
||||
// NewStore 创建新的 PostgreSQL Store 实例
|
||||
func NewStore(db *gorm.DB, logger *zap.Logger) *Store {
|
||||
return &Store{
|
||||
db: db,
|
||||
logger: logger,
|
||||
User: NewUserStore(db),
|
||||
Order: NewOrderStore(db),
|
||||
}
|
||||
}
|
||||
|
||||
// DB 获取数据库连接
|
||||
func (s *Store) DB() *gorm.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Transaction 执行事务
|
||||
// 提供统一的事务管理接口,自动处理提交和回滚
|
||||
// 在事务内部,所有 Store 操作都会使用事务连接
|
||||
func (s *Store) Transaction(ctx context.Context, fn func(*Store) error) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 创建事务内的 Store 实例
|
||||
txStore := &Store{
|
||||
db: tx,
|
||||
logger: s.logger,
|
||||
User: NewUserStore(tx),
|
||||
Order: NewOrderStore(tx),
|
||||
}
|
||||
return fn(txStore)
|
||||
})
|
||||
}
|
||||
|
||||
// WithContext 返回带上下文的数据库实例
|
||||
func (s *Store) WithContext(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx)
|
||||
}
|
||||
78
internal/store/postgres/user_store.go
Normal file
78
internal/store/postgres/user_store.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserStore 用户数据访问层
|
||||
type UserStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUserStore 创建用户 Store
|
||||
func NewUserStore(db *gorm.DB) *UserStore {
|
||||
return &UserStore{db: db}
|
||||
}
|
||||
|
||||
// Create 创建用户
|
||||
func (s *UserStore) Create(ctx context.Context, user *model.User) error {
|
||||
return s.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取用户
|
||||
func (s *UserStore) GetByID(ctx context.Context, id uint) (*model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.WithContext(ctx).First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetByUsername 根据用户名获取用户
|
||||
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// List 分页获取用户列表
|
||||
func (s *UserStore) List(ctx context.Context, page, pageSize int) ([]model.User, int64, error) {
|
||||
var users []model.User
|
||||
var total int64
|
||||
|
||||
// 计算总数
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := s.db.WithContext(ctx).
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Order("created_at DESC").
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// Update 更新用户
|
||||
func (s *UserStore) Update(ctx context.Context, user *model.User) error {
|
||||
return s.db.WithContext(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
// Delete 软删除用户
|
||||
func (s *UserStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.User{}, id).Error
|
||||
}
|
||||
35
internal/store/store.go
Normal file
35
internal/store/store.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Store 数据访问层基础结构
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewStore 创建新的 Store 实例
|
||||
func NewStore(db *gorm.DB) *Store {
|
||||
return &Store{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// DB 获取数据库连接
|
||||
func (s *Store) DB() *gorm.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Transaction 执行事务
|
||||
// 提供统一的事务管理接口,自动处理提交和回滚
|
||||
func (s *Store) Transaction(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
return s.db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// WithContext 返回带上下文的数据库实例
|
||||
func (s *Store) WithContext(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx)
|
||||
}
|
||||
155
internal/task/email.go
Normal file
155
internal/task/email.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// EmailPayload 邮件任务载荷
|
||||
type EmailPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
CC []string `json:"cc,omitempty"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// EmailHandler 邮件任务处理器
|
||||
type EmailHandler struct {
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewEmailHandler 创建邮件任务处理器
|
||||
func NewEmailHandler(redis *redis.Client, logger *zap.Logger) *EmailHandler {
|
||||
return &EmailHandler{
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEmailSend 处理邮件发送任务
|
||||
func (h *EmailHandler) HandleEmailSend(ctx context.Context, task *asynq.Task) error {
|
||||
// 解析任务载荷
|
||||
var payload EmailPayload
|
||||
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析邮件任务载荷失败",
|
||||
zap.Error(err),
|
||||
zap.String("task_id", task.ResultWriter().TaskID()),
|
||||
)
|
||||
return asynq.SkipRetry // JSON 解析失败不重试
|
||||
}
|
||||
|
||||
// 验证载荷
|
||||
if err := h.validatePayload(&payload); err != nil {
|
||||
h.logger.Error("邮件任务载荷验证失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
)
|
||||
return asynq.SkipRetry // 参数错误不重试
|
||||
}
|
||||
|
||||
// 幂等性检查:使用 Redis 锁
|
||||
lockKey := constants.RedisTaskLockKey(payload.RequestID)
|
||||
locked, err := h.acquireLock(ctx, lockKey)
|
||||
if err != nil {
|
||||
h.logger.Error("获取任务锁失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
)
|
||||
return err // 锁获取失败,可以重试
|
||||
}
|
||||
if !locked {
|
||||
h.logger.Info("任务已执行,跳过(幂等性)",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("to", payload.To),
|
||||
)
|
||||
return nil // 已执行,跳过
|
||||
}
|
||||
|
||||
// 记录任务开始执行
|
||||
h.logger.Info("开始处理邮件发送任务",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("to", payload.To),
|
||||
zap.String("subject", payload.Subject),
|
||||
zap.Int("cc_count", len(payload.CC)),
|
||||
zap.Int("attachments_count", len(payload.Attachments)),
|
||||
)
|
||||
|
||||
// 执行邮件发送(模拟)
|
||||
if err := h.sendEmail(ctx, &payload); err != nil {
|
||||
h.logger.Error("邮件发送失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("to", payload.To),
|
||||
)
|
||||
return err // 发送失败,可以重试
|
||||
}
|
||||
|
||||
// 记录任务完成
|
||||
h.logger.Info("邮件发送成功",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("to", payload.To),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePayload 验证邮件载荷
|
||||
func (h *EmailHandler) validatePayload(payload *EmailPayload) error {
|
||||
if payload.RequestID == "" {
|
||||
return fmt.Errorf("request_id 不能为空")
|
||||
}
|
||||
if payload.To == "" {
|
||||
return fmt.Errorf("收件人不能为空")
|
||||
}
|
||||
if !strings.Contains(payload.To, "@") {
|
||||
return fmt.Errorf("邮箱格式无效")
|
||||
}
|
||||
if payload.Subject == "" {
|
||||
return fmt.Errorf("邮件主题不能为空")
|
||||
}
|
||||
if payload.Body == "" {
|
||||
return fmt.Errorf("邮件正文不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// acquireLock 获取 Redis 锁(幂等性)
|
||||
func (h *EmailHandler) acquireLock(ctx context.Context, key string) (bool, error) {
|
||||
// 使用 SetNX 实现分布式锁
|
||||
// 过期时间 24 小时,防止锁永久存在
|
||||
result, err := h.redis.SetNX(ctx, key, "1", 24*time.Hour).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("设置 Redis 锁失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sendEmail 发送邮件(实际实现需要集成 SMTP 或邮件服务)
|
||||
func (h *EmailHandler) sendEmail(ctx context.Context, payload *EmailPayload) error {
|
||||
// TODO: 实际实现中需要集成邮件发送服务
|
||||
// 例如:使用 SMTP、SendGrid、AWS SES 等
|
||||
|
||||
// 模拟发送延迟
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 这里仅作演示,实际应用中需要调用真实的邮件发送 API
|
||||
h.logger.Debug("模拟邮件发送",
|
||||
zap.String("to", payload.To),
|
||||
zap.String("subject", payload.Subject),
|
||||
zap.Int("body_length", len(payload.Body)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
170
internal/task/sim.go
Normal file
170
internal/task/sim.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// SIMStatusSyncPayload SIM 卡状态同步任务载荷
|
||||
type SIMStatusSyncPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ICCIDs []string `json:"iccids"` // ICCID 列表
|
||||
ForceSync bool `json:"force_sync"` // 强制同步(忽略缓存)
|
||||
}
|
||||
|
||||
// SIMHandler SIM 卡状态同步任务处理器
|
||||
type SIMHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewSIMHandler 创建 SIM 卡状态同步任务处理器
|
||||
func NewSIMHandler(db *gorm.DB, redis *redis.Client, logger *zap.Logger) *SIMHandler {
|
||||
return &SIMHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSIMStatusSync 处理 SIM 卡状态同步任务
|
||||
func (h *SIMHandler) HandleSIMStatusSync(ctx context.Context, task *asynq.Task) error {
|
||||
// 解析任务载荷
|
||||
var payload SIMStatusSyncPayload
|
||||
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析 SIM 状态同步任务载荷失败",
|
||||
zap.Error(err),
|
||||
zap.String("task_id", task.ResultWriter().TaskID()),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
// 验证载荷
|
||||
if err := h.validatePayload(&payload); err != nil {
|
||||
h.logger.Error("SIM 状态同步任务载荷验证失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
// 幂等性检查
|
||||
lockKey := constants.RedisTaskLockKey(payload.RequestID)
|
||||
locked, err := h.acquireLock(ctx, lockKey)
|
||||
if err != nil {
|
||||
h.logger.Error("获取任务锁失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
)
|
||||
return err
|
||||
}
|
||||
if !locked {
|
||||
h.logger.Info("任务已执行,跳过(幂等性)",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.Int("iccid_count", len(payload.ICCIDs)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 记录任务开始
|
||||
h.logger.Info("开始处理 SIM 卡状态同步任务",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.Int("iccid_count", len(payload.ICCIDs)),
|
||||
zap.Bool("force_sync", payload.ForceSync),
|
||||
)
|
||||
|
||||
// 执行状态同步
|
||||
if err := h.syncSIMStatus(ctx, &payload); err != nil {
|
||||
h.logger.Error("SIM 卡状态同步失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录任务完成
|
||||
h.logger.Info("SIM 卡状态同步成功",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.Int("iccid_count", len(payload.ICCIDs)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePayload 验证 SIM 状态同步载荷
|
||||
func (h *SIMHandler) validatePayload(payload *SIMStatusSyncPayload) error {
|
||||
if payload.RequestID == "" {
|
||||
return fmt.Errorf("request_id 不能为空")
|
||||
}
|
||||
if len(payload.ICCIDs) == 0 {
|
||||
return fmt.Errorf("iccids 不能为空")
|
||||
}
|
||||
if len(payload.ICCIDs) > 1000 {
|
||||
return fmt.Errorf("单次同步 ICCID 数量不能超过 1000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// acquireLock 获取 Redis 锁
|
||||
func (h *SIMHandler) acquireLock(ctx context.Context, key string) (bool, error) {
|
||||
result, err := h.redis.SetNX(ctx, key, "1", 24*time.Hour).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("设置 Redis 锁失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// syncSIMStatus 执行 SIM 卡状态同步
|
||||
func (h *SIMHandler) syncSIMStatus(ctx context.Context, payload *SIMStatusSyncPayload) error {
|
||||
// TODO: 实际实现中需要调用运营商 API 获取 SIM 卡状态
|
||||
|
||||
// 批量处理 ICCID
|
||||
batchSize := 100
|
||||
for i := 0; i < len(payload.ICCIDs); i += batchSize {
|
||||
// 检查上下文是否已取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
end := i + batchSize
|
||||
if end > len(payload.ICCIDs) {
|
||||
end = len(payload.ICCIDs)
|
||||
}
|
||||
|
||||
batch := payload.ICCIDs[i:end]
|
||||
|
||||
h.logger.Debug("同步 SIM 卡状态批次",
|
||||
zap.Int("batch_start", i),
|
||||
zap.Int("batch_end", end),
|
||||
zap.Int("batch_size", len(batch)),
|
||||
)
|
||||
|
||||
// 模拟调用外部 API
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// TODO: 实际实现中需要:
|
||||
// 1. 调用运营商 API 获取状态
|
||||
// 2. 使用事务批量更新数据库
|
||||
// 3. 更新 Redis 缓存
|
||||
// 4. 记录同步日志
|
||||
}
|
||||
|
||||
h.logger.Info("SIM 卡状态批量同步完成",
|
||||
zap.Int("total_iccids", len(payload.ICCIDs)),
|
||||
zap.Int("batch_size", batchSize),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
166
internal/task/sync.go
Normal file
166
internal/task/sync.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DataSyncPayload 数据同步任务载荷
|
||||
type DataSyncPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
SyncType string `json:"sync_type"` // sim_status, flow_usage, real_name
|
||||
StartDate string `json:"start_date"` // YYYY-MM-DD
|
||||
EndDate string `json:"end_date"` // YYYY-MM-DD
|
||||
BatchSize int `json:"batch_size"` // 批量大小
|
||||
}
|
||||
|
||||
// SyncHandler 数据同步任务处理器
|
||||
type SyncHandler struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewSyncHandler 创建数据同步任务处理器
|
||||
func NewSyncHandler(db *gorm.DB, logger *zap.Logger) *SyncHandler {
|
||||
return &SyncHandler{
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDataSync 处理数据同步任务
|
||||
func (h *SyncHandler) HandleDataSync(ctx context.Context, task *asynq.Task) error {
|
||||
// 解析任务载荷
|
||||
var payload DataSyncPayload
|
||||
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析数据同步任务载荷失败",
|
||||
zap.Error(err),
|
||||
zap.String("task_id", task.ResultWriter().TaskID()),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
// 验证载荷
|
||||
if err := h.validatePayload(&payload); err != nil {
|
||||
h.logger.Error("数据同步任务载荷验证失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
// 设置默认批量大小
|
||||
if payload.BatchSize <= 0 {
|
||||
payload.BatchSize = 100
|
||||
}
|
||||
|
||||
// 记录任务开始
|
||||
h.logger.Info("开始处理数据同步任务",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("sync_type", payload.SyncType),
|
||||
zap.String("start_date", payload.StartDate),
|
||||
zap.String("end_date", payload.EndDate),
|
||||
zap.Int("batch_size", payload.BatchSize),
|
||||
)
|
||||
|
||||
// 执行数据同步
|
||||
if err := h.syncData(ctx, &payload); err != nil {
|
||||
h.logger.Error("数据同步失败",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("sync_type", payload.SyncType),
|
||||
)
|
||||
return err // 同步失败,可以重试
|
||||
}
|
||||
|
||||
// 记录任务完成
|
||||
h.logger.Info("数据同步成功",
|
||||
zap.String("request_id", payload.RequestID),
|
||||
zap.String("sync_type", payload.SyncType),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePayload 验证数据同步载荷
|
||||
func (h *SyncHandler) validatePayload(payload *DataSyncPayload) error {
|
||||
if payload.RequestID == "" {
|
||||
return fmt.Errorf("request_id 不能为空")
|
||||
}
|
||||
if payload.SyncType == "" {
|
||||
return fmt.Errorf("sync_type 不能为空")
|
||||
}
|
||||
validTypes := []string{"sim_status", "flow_usage", "real_name"}
|
||||
valid := false
|
||||
for _, t := range validTypes {
|
||||
if payload.SyncType == t {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("sync_type 无效,必须为 sim_status, flow_usage, real_name 之一")
|
||||
}
|
||||
if payload.StartDate == "" {
|
||||
return fmt.Errorf("start_date 不能为空")
|
||||
}
|
||||
if payload.EndDate == "" {
|
||||
return fmt.Errorf("end_date 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncData 执行数据同步
|
||||
func (h *SyncHandler) syncData(ctx context.Context, payload *DataSyncPayload) error {
|
||||
// TODO: 实际实现中需要调用外部 API 或数据源进行同步
|
||||
|
||||
// 模拟批量同步
|
||||
totalRecords := 500 // 假设有 500 条记录需要同步
|
||||
batches := (totalRecords + payload.BatchSize - 1) / payload.BatchSize
|
||||
|
||||
for i := 0; i < batches; i++ {
|
||||
// 检查上下文是否已取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// 模拟批量处理
|
||||
offset := i * payload.BatchSize
|
||||
limit := payload.BatchSize
|
||||
if offset+limit > totalRecords {
|
||||
limit = totalRecords - offset
|
||||
}
|
||||
|
||||
h.logger.Debug("同步批次",
|
||||
zap.String("sync_type", payload.SyncType),
|
||||
zap.Int("batch", i+1),
|
||||
zap.Int("total_batches", batches),
|
||||
zap.Int("offset", offset),
|
||||
zap.Int("limit", limit),
|
||||
)
|
||||
|
||||
// 模拟处理延迟
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// TODO: 实际实现中需要:
|
||||
// 1. 从外部 API 获取数据
|
||||
// 2. 使用事务批量更新数据库
|
||||
// 3. 记录同步状态
|
||||
}
|
||||
|
||||
h.logger.Info("批量同步完成",
|
||||
zap.String("sync_type", payload.SyncType),
|
||||
zap.Int("total_records", totalRecords),
|
||||
zap.Int("batches", batches),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
9
migrations/000001_init_schema.down.sql
Normal file
9
migrations/000001_init_schema.down.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- migrations/000001_init_schema.down.sql
|
||||
-- 回滚初始化 Schema
|
||||
-- 删除表和索引
|
||||
|
||||
-- 删除订单表
|
||||
DROP TABLE IF EXISTS tb_order;
|
||||
|
||||
-- 删除用户表
|
||||
DROP TABLE IF EXISTS tb_user;
|
||||
80
migrations/000001_init_schema.up.sql
Normal file
80
migrations/000001_init_schema.up.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- migrations/000001_init_schema.up.sql
|
||||
-- 初始化数据库 Schema
|
||||
-- 创建 tb_user 和 tb_order 表、索引
|
||||
-- 注意: 表关系和 updated_at 更新在代码中处理
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS tb_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
|
||||
-- 基本信息
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
|
||||
-- 状态字段
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
|
||||
-- 元数据
|
||||
last_login_at TIMESTAMP,
|
||||
|
||||
-- 唯一约束
|
||||
CONSTRAINT uk_user_username UNIQUE (username),
|
||||
CONSTRAINT uk_user_email UNIQUE (email)
|
||||
);
|
||||
|
||||
-- 用户表索引
|
||||
CREATE INDEX idx_user_deleted_at ON tb_user(deleted_at);
|
||||
CREATE INDEX idx_user_status ON tb_user(status);
|
||||
CREATE INDEX idx_user_created_at ON tb_user(created_at);
|
||||
|
||||
-- 订单表
|
||||
CREATE TABLE IF NOT EXISTS tb_order (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
|
||||
-- 业务唯一键
|
||||
order_id VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 关联关系 (注意: 无数据库外键约束,在代码中管理)
|
||||
user_id INTEGER NOT NULL,
|
||||
|
||||
-- 订单信息
|
||||
amount BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
remark VARCHAR(500),
|
||||
|
||||
-- 时间字段
|
||||
paid_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
|
||||
-- 唯一约束
|
||||
CONSTRAINT uk_order_order_id UNIQUE (order_id)
|
||||
);
|
||||
|
||||
-- 订单表索引
|
||||
CREATE INDEX idx_order_deleted_at ON tb_order(deleted_at);
|
||||
CREATE INDEX idx_order_user_id ON tb_order(user_id);
|
||||
CREATE INDEX idx_order_status ON tb_order(status);
|
||||
CREATE INDEX idx_order_created_at ON tb_order(created_at);
|
||||
CREATE INDEX idx_order_order_id ON tb_order(order_id);
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON TABLE tb_user IS '用户表';
|
||||
COMMENT ON COLUMN tb_user.username IS '用户名(唯一)';
|
||||
COMMENT ON COLUMN tb_user.email IS '邮箱(唯一)';
|
||||
COMMENT ON COLUMN tb_user.password IS '密码(bcrypt 哈希)';
|
||||
COMMENT ON COLUMN tb_user.status IS '用户状态:active, inactive, suspended';
|
||||
COMMENT ON COLUMN tb_user.deleted_at IS '软删除时间';
|
||||
|
||||
COMMENT ON TABLE tb_order IS '订单表';
|
||||
COMMENT ON COLUMN tb_order.order_id IS '订单号(业务唯一键)';
|
||||
COMMENT ON COLUMN tb_order.user_id IS '用户 ID(在代码中维护关联,无数据库外键)';
|
||||
COMMENT ON COLUMN tb_order.amount IS '金额(分)';
|
||||
COMMENT ON COLUMN tb_order.status IS '订单状态:pending, paid, processing, completed, cancelled';
|
||||
COMMENT ON COLUMN tb_order.deleted_at IS '软删除时间';
|
||||
@@ -15,6 +15,8 @@ var globalConfig atomic.Pointer[Config]
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Queue QueueConfig `mapstructure:"queue"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Middleware MiddlewareConfig `mapstructure:"middleware"`
|
||||
}
|
||||
@@ -41,6 +43,27 @@ type RedisConfig struct {
|
||||
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "3s"
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库连接配置
|
||||
type DatabaseConfig struct {
|
||||
Host string `mapstructure:"host"` // 数据库主机地址
|
||||
Port int `mapstructure:"port"` // 数据库端口
|
||||
User string `mapstructure:"user"` // 数据库用户名
|
||||
Password string `mapstructure:"password"` // 数据库密码(明文存储)
|
||||
DBName string `mapstructure:"dbname"` // 数据库名称
|
||||
SSLMode string `mapstructure:"sslmode"` // SSL 模式:disable, require, verify-ca, verify-full
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"` // 最大打开连接数(默认:25)
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"` // 最大空闲连接数(默认:10)
|
||||
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` // 连接最大生命周期(默认:5m)
|
||||
}
|
||||
|
||||
// QueueConfig 任务队列配置
|
||||
type QueueConfig struct {
|
||||
Concurrency int `mapstructure:"concurrency"` // Worker 并发数(默认:10)
|
||||
Queues map[string]int `mapstructure:"queues"` // 队列优先级配置(队列名 -> 权重)
|
||||
RetryMax int `mapstructure:"retry_max"` // 最大重试次数(默认:5)
|
||||
Timeout time.Duration `mapstructure:"timeout"` // 任务超时时间(默认:10m)
|
||||
}
|
||||
|
||||
// LoggingConfig 日志配置
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"` // debug, info, warn, error
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package constants
|
||||
|
||||
import "time"
|
||||
|
||||
// Fiber Locals 的上下文键
|
||||
const (
|
||||
ContextKeyRequestID = "requestid"
|
||||
@@ -19,3 +21,47 @@ const (
|
||||
DefaultServerAddr = ":3000"
|
||||
DefaultRedisAddr = "localhost:6379"
|
||||
)
|
||||
|
||||
// 数据库配置常量
|
||||
const (
|
||||
DefaultMaxOpenConns = 25
|
||||
DefaultMaxIdleConns = 10
|
||||
DefaultConnMaxLifetime = 5 * time.Minute
|
||||
DefaultPageSize = 20
|
||||
MaxPageSize = 100
|
||||
SlowQueryThreshold = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// 任务类型常量
|
||||
const (
|
||||
TaskTypeEmailSend = "email:send" // 发送邮件
|
||||
TaskTypeDataSync = "data:sync" // 数据同步
|
||||
TaskTypeSIMStatusSync = "sim:status:sync" // SIM 卡状态同步
|
||||
TaskTypeCommission = "commission:calculate" // 分佣计算
|
||||
)
|
||||
|
||||
// 用户状态常量
|
||||
const (
|
||||
UserStatusActive = "active" // 激活
|
||||
UserStatusInactive = "inactive" // 未激活
|
||||
UserStatusSuspended = "suspended" // 暂停
|
||||
)
|
||||
|
||||
// 订单状态常量
|
||||
const (
|
||||
OrderStatusPending = "pending" // 待支付
|
||||
OrderStatusPaid = "paid" // 已支付
|
||||
OrderStatusProcessing = "processing" // 处理中
|
||||
OrderStatusCompleted = "completed" // 已完成
|
||||
OrderStatusCancelled = "cancelled" // 已取消
|
||||
)
|
||||
|
||||
// 队列配置常量
|
||||
const (
|
||||
QueueCritical = "critical" // 关键任务队列
|
||||
QueueDefault = "default" // 默认队列
|
||||
QueueLow = "low" // 低优先级队列
|
||||
DefaultRetryMax = 5
|
||||
DefaultTimeout = 10 * time.Minute
|
||||
DefaultConcurrency = 10
|
||||
)
|
||||
|
||||
@@ -11,3 +11,17 @@ func RedisAuthTokenKey(token string) string {
|
||||
func RedisRateLimitKey(ip string) string {
|
||||
return fmt.Sprintf("ratelimit:%s", ip)
|
||||
}
|
||||
|
||||
// RedisTaskLockKey 生成任务锁的 Redis 键
|
||||
// 用途:幂等性控制,防止重复执行
|
||||
// 过期时间:24 小时
|
||||
func RedisTaskLockKey(requestID string) string {
|
||||
return fmt.Sprintf("task:lock:%s", requestID)
|
||||
}
|
||||
|
||||
// RedisTaskStatusKey 生成任务状态的 Redis 键
|
||||
// 用途:存储任务执行状态
|
||||
// 过期时间:7 天
|
||||
func RedisTaskStatusKey(taskID string) string {
|
||||
return fmt.Sprintf("task:status:%s", taskID)
|
||||
}
|
||||
|
||||
172
pkg/database/postgres.go
Normal file
172
pkg/database/postgres.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// InitPostgreSQL 初始化 PostgreSQL 数据库连接
|
||||
func InitPostgreSQL(cfg *config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
|
||||
// 构建 DSN (数据源名称)
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.Host,
|
||||
cfg.Port,
|
||||
cfg.User,
|
||||
cfg.Password,
|
||||
cfg.DBName,
|
||||
cfg.SSLMode,
|
||||
)
|
||||
|
||||
// 配置 GORM
|
||||
gormConfig := &gorm.Config{
|
||||
// 使用自定义日志器(集成 Zap)
|
||||
Logger: newGormLogger(log),
|
||||
// 禁用自动创建表(使用迁移脚本管理)
|
||||
DisableAutomaticPing: false,
|
||||
SkipDefaultTransaction: true, // 提高性能,手动管理事务
|
||||
PrepareStmt: true, // 预编译语句
|
||||
}
|
||||
|
||||
// 连接数据库
|
||||
db, err := gorm.Open(postgres.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
log.Error("PostgreSQL 连接失败",
|
||||
zap.String("host", cfg.Host),
|
||||
zap.Int("port", cfg.Port),
|
||||
zap.String("dbname", cfg.DBName),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
// 获取底层 SQL DB 对象
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
log.Error("获取 SQL DB 失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get SQL DB: %w", err)
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
maxOpenConns := cfg.MaxOpenConns
|
||||
if maxOpenConns <= 0 {
|
||||
maxOpenConns = constants.DefaultMaxOpenConns
|
||||
}
|
||||
|
||||
maxIdleConns := cfg.MaxIdleConns
|
||||
if maxIdleConns <= 0 {
|
||||
maxIdleConns = constants.DefaultMaxIdleConns
|
||||
}
|
||||
|
||||
connMaxLifetime := cfg.ConnMaxLifetime
|
||||
if connMaxLifetime <= 0 {
|
||||
connMaxLifetime = constants.DefaultConnMaxLifetime
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(maxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(connMaxLifetime)
|
||||
|
||||
// 验证连接
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
log.Error("PostgreSQL Ping 失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to ping PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
log.Info("PostgreSQL 连接成功",
|
||||
zap.String("host", cfg.Host),
|
||||
zap.Int("port", cfg.Port),
|
||||
zap.String("dbname", cfg.DBName),
|
||||
zap.Int("max_open_conns", maxOpenConns),
|
||||
zap.Int("max_idle_conns", maxIdleConns),
|
||||
zap.Duration("conn_max_lifetime", connMaxLifetime))
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// gormLogger 自定义 GORM 日志器,集成 Zap
|
||||
type gormLogger struct {
|
||||
zap *zap.Logger
|
||||
slowQueryThreshold time.Duration
|
||||
ignoreRecordNotFound bool
|
||||
logLevel logger.LogLevel
|
||||
}
|
||||
|
||||
// newGormLogger 创建新的 GORM 日志器
|
||||
func newGormLogger(log *zap.Logger) logger.Interface {
|
||||
return &gormLogger{
|
||||
zap: log,
|
||||
slowQueryThreshold: constants.SlowQueryThreshold,
|
||||
ignoreRecordNotFound: true,
|
||||
logLevel: logger.Info,
|
||||
}
|
||||
}
|
||||
|
||||
// LogMode 设置日志级别
|
||||
func (l *gormLogger) LogMode(level logger.LogLevel) logger.Interface {
|
||||
newLogger := *l
|
||||
newLogger.logLevel = level
|
||||
return &newLogger
|
||||
}
|
||||
|
||||
// Info 记录 Info 级别日志
|
||||
func (l *gormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.logLevel >= logger.Info {
|
||||
l.zap.Sugar().Infof(msg, data...)
|
||||
}
|
||||
}
|
||||
|
||||
// Warn 记录 Warn 级别日志
|
||||
func (l *gormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.logLevel >= logger.Warn {
|
||||
l.zap.Sugar().Warnf(msg, data...)
|
||||
}
|
||||
}
|
||||
|
||||
// Error 记录 Error 级别日志
|
||||
func (l *gormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.logLevel >= logger.Error {
|
||||
l.zap.Sugar().Errorf(msg, data...)
|
||||
}
|
||||
}
|
||||
|
||||
// Trace 记录 SQL 查询日志
|
||||
func (l *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.logLevel <= logger.Silent {
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(begin)
|
||||
sql, rows := fc()
|
||||
|
||||
switch {
|
||||
case err != nil && l.logLevel >= logger.Error && (!l.ignoreRecordNotFound || err != gorm.ErrRecordNotFound):
|
||||
// 查询错误
|
||||
l.zap.Error("SQL 查询失败",
|
||||
zap.String("sql", sql),
|
||||
zap.Int64("rows", rows),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Error(err))
|
||||
|
||||
case elapsed > l.slowQueryThreshold && l.logLevel >= logger.Warn:
|
||||
// 慢查询
|
||||
l.zap.Warn("慢查询检测",
|
||||
zap.String("sql", sql),
|
||||
zap.Int64("rows", rows),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Duration("threshold", l.slowQueryThreshold))
|
||||
|
||||
case l.logLevel >= logger.Info:
|
||||
// 正常查询
|
||||
l.zap.Debug("SQL 查询",
|
||||
zap.String("sql", sql),
|
||||
zap.Int64("rows", rows),
|
||||
zap.Duration("elapsed", elapsed))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ const (
|
||||
CodeInvalidToken = 1002 // 令牌无效或已过期
|
||||
CodeTooManyRequests = 1003 // 请求过于频繁(限流)
|
||||
CodeAuthServiceUnavailable = 1004 // 认证服务不可用(Redis 宕机)
|
||||
CodeNotFound = 1005 // 资源不存在
|
||||
CodeBadRequest = 1006 // 请求参数错误
|
||||
CodeUnauthorized = 1007 // 未授权
|
||||
CodeForbidden = 1008 // 禁止访问
|
||||
)
|
||||
|
||||
// ErrorMessage 表示双语错误消息
|
||||
@@ -24,6 +28,10 @@ var errorMessages = map[int]ErrorMessage{
|
||||
CodeInvalidToken: {"Invalid or expired token", "令牌无效或已过期"},
|
||||
CodeTooManyRequests: {"Too many requests", "请求过于频繁"},
|
||||
CodeAuthServiceUnavailable: {"Authentication service unavailable", "认证服务不可用"},
|
||||
CodeNotFound: {"Resource not found", "资源不存在"},
|
||||
CodeBadRequest: {"Bad request", "请求参数错误"},
|
||||
CodeUnauthorized: {"Unauthorized", "未授权"},
|
||||
CodeForbidden: {"Forbidden", "禁止访问"},
|
||||
}
|
||||
|
||||
// GetMessage 根据错误码和语言返回错误消息
|
||||
|
||||
88
pkg/queue/client.go
Normal file
88
pkg/queue/client.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Client Asynq 任务提交客户端
|
||||
type Client struct {
|
||||
client *asynq.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewClient 创建新的 Asynq 客户端
|
||||
func NewClient(redisClient *redis.Client, logger *zap.Logger) *Client {
|
||||
// 从 Redis 客户端获取配置
|
||||
opts := redisClient.Options()
|
||||
|
||||
asynqClient := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: opts.Addr,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
})
|
||||
|
||||
return &Client{
|
||||
client: asynqClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueTask 提交任务到队列
|
||||
func (c *Client) EnqueueTask(ctx context.Context, taskType string, payload interface{}, opts ...asynq.Option) error {
|
||||
// 序列化载荷
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
c.logger.Error("任务载荷序列化失败",
|
||||
zap.String("task_type", taskType),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to marshal task payload: %w", err)
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
task := asynq.NewTask(taskType, payloadBytes, opts...)
|
||||
|
||||
// 提交任务
|
||||
info, err := c.client.EnqueueContext(ctx, task)
|
||||
if err != nil {
|
||||
c.logger.Error("任务提交失败",
|
||||
zap.String("task_type", taskType),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to enqueue task: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("任务已提交",
|
||||
zap.String("task_id", info.ID),
|
||||
zap.String("task_type", taskType),
|
||||
zap.String("queue", info.Queue),
|
||||
zap.Int("max_retry", info.MaxRetry))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭客户端
|
||||
func (c *Client) Close() error {
|
||||
if c.client != nil {
|
||||
return c.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseQueueConfig 解析队列配置为 Asynq 格式
|
||||
func ParseQueueConfig(cfg *config.QueueConfig) map[string]int {
|
||||
if cfg.Queues != nil && len(cfg.Queues) > 0 {
|
||||
return cfg.Queues
|
||||
}
|
||||
// 默认队列优先级
|
||||
return map[string]int{
|
||||
"critical": 6,
|
||||
"default": 3,
|
||||
"low": 1,
|
||||
}
|
||||
}
|
||||
57
pkg/queue/handler.go
Normal file
57
pkg/queue/handler.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// Handler 任务处理器注册
|
||||
type Handler struct {
|
||||
mux *asynq.ServeMux
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
// NewHandler 创建任务处理器
|
||||
func NewHandler(db *gorm.DB, redis *redis.Client, logger *zap.Logger) *Handler {
|
||||
return &Handler{
|
||||
mux: asynq.NewServeMux(),
|
||||
logger: logger,
|
||||
db: db,
|
||||
redis: redis,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandlers 注册所有任务处理器
|
||||
func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
||||
// 创建任务处理器实例
|
||||
emailHandler := task.NewEmailHandler(h.redis, h.logger)
|
||||
syncHandler := task.NewSyncHandler(h.db, h.logger)
|
||||
simHandler := task.NewSIMHandler(h.db, h.redis, h.logger)
|
||||
|
||||
// 注册邮件发送任务
|
||||
h.mux.HandleFunc(constants.TaskTypeEmailSend, emailHandler.HandleEmailSend)
|
||||
h.logger.Info("注册邮件发送任务处理器", zap.String("task_type", constants.TaskTypeEmailSend))
|
||||
|
||||
// 注册数据同步任务
|
||||
h.mux.HandleFunc(constants.TaskTypeDataSync, syncHandler.HandleDataSync)
|
||||
h.logger.Info("注册数据同步任务处理器", zap.String("task_type", constants.TaskTypeDataSync))
|
||||
|
||||
// 注册 SIM 卡状态同步任务
|
||||
h.mux.HandleFunc(constants.TaskTypeSIMStatusSync, simHandler.HandleSIMStatusSync)
|
||||
h.logger.Info("注册 SIM 状态同步任务处理器", zap.String("task_type", constants.TaskTypeSIMStatusSync))
|
||||
|
||||
h.logger.Info("所有任务处理器注册完成")
|
||||
return h.mux
|
||||
}
|
||||
|
||||
// GetMux 获取 ServeMux(用于启动 Worker 服务器)
|
||||
func (h *Handler) GetMux() *asynq.ServeMux {
|
||||
return h.mux
|
||||
}
|
||||
86
pkg/queue/server.go
Normal file
86
pkg/queue/server.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server Asynq Worker 服务器
|
||||
type Server struct {
|
||||
server *asynq.Server
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewServer 创建新的 Asynq 服务器
|
||||
func NewServer(redisClient *redis.Client, queueCfg *config.QueueConfig, logger *zap.Logger) *Server {
|
||||
// 从 Redis 客户端获取配置
|
||||
opts := redisClient.Options()
|
||||
|
||||
// 解析队列优先级配置
|
||||
queues := ParseQueueConfig(queueCfg)
|
||||
|
||||
// 设置并发数
|
||||
concurrency := queueCfg.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = constants.DefaultConcurrency
|
||||
}
|
||||
|
||||
// 创建 Asynq 服务器配置
|
||||
asynqServer := asynq.NewServer(
|
||||
asynq.RedisClientOpt{
|
||||
Addr: opts.Addr,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
},
|
||||
asynq.Config{
|
||||
// 并发数
|
||||
Concurrency: concurrency,
|
||||
// 队列优先级配置
|
||||
Queues: queues,
|
||||
// 重试延迟函数(指数退避)
|
||||
RetryDelayFunc: asynq.DefaultRetryDelayFunc,
|
||||
// 是否记录详细日志
|
||||
LogLevel: asynq.WarnLevel,
|
||||
},
|
||||
)
|
||||
|
||||
return &Server{
|
||||
server: asynqServer,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动 Worker 服务器
|
||||
func (s *Server) Start(mux *asynq.ServeMux) error {
|
||||
s.logger.Info("Worker 服务器启动中...")
|
||||
|
||||
if err := s.server.Start(mux); err != nil {
|
||||
s.logger.Error("Worker 服务器启动失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Worker 服务器启动成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭服务器
|
||||
func (s *Server) Shutdown() {
|
||||
s.logger.Info("Worker 服务器关闭中...")
|
||||
s.server.Shutdown()
|
||||
s.logger.Info("Worker 服务器已关闭")
|
||||
}
|
||||
|
||||
// Run 启动并阻塞运行(用于主函数)
|
||||
func (s *Server) Run(mux *asynq.ServeMux) error {
|
||||
s.logger.Info("Worker 服务器启动中...")
|
||||
|
||||
if err := s.server.Run(mux); err != nil {
|
||||
s.logger.Error("Worker 服务器运行失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestError(t *testing.T) {
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func TestSuccessWithMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
@@ -337,14 +337,14 @@ func TestResponseSerialization(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 序列化
|
||||
data, err := json.Marshal(tt.response)
|
||||
data, err := sonic.Marshal(tt.response)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal response: %v", err)
|
||||
}
|
||||
|
||||
// 反序列化
|
||||
var deserialized Response
|
||||
if err := json.Unmarshal(data, &deserialized); err != nil {
|
||||
if err := sonic.Unmarshal(data, &deserialized); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
@@ -373,14 +373,14 @@ func TestResponseStructFields(t *testing.T) {
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(response)
|
||||
data, err := sonic.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal response: %v", err)
|
||||
}
|
||||
|
||||
// 解析为 map 以检查 JSON 键
|
||||
var jsonMap map[string]any
|
||||
if err := json.Unmarshal(data, &jsonMap); err != nil {
|
||||
if err := sonic.Unmarshal(data, &jsonMap); err != nil {
|
||||
t.Fatalf("Failed to unmarshal to map: %v", err)
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@ func TestMultipleResponses(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
|
||||
var response Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Request %d: failed to unmarshal response: %v", i, err)
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ func TestTimestampFormat(t *testing.T) {
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var response Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
|
||||
117
scripts/migrate.sh
Executable file
117
scripts/migrate.sh
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 数据库迁移脚本
|
||||
# 用法: ./scripts/migrate.sh [up|down|create|version|force] [args]
|
||||
|
||||
set -e
|
||||
|
||||
# 加载 .env 文件 (如果存在)
|
||||
if [ -f .env ]; then
|
||||
echo "正在加载 .env 文件..."
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
# 默认配置
|
||||
MIGRATIONS_DIR="${MIGRATIONS_DIR:-migrations}"
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-postgres}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-password}"
|
||||
DB_NAME="${DB_NAME:-junhong_cmp}"
|
||||
DB_SSLMODE="${DB_SSLMODE:-disable}"
|
||||
|
||||
# 构建数据库 URL
|
||||
DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
|
||||
|
||||
# 检查 migrate 命令是否存在
|
||||
if ! command -v migrate &> /dev/null; then
|
||||
echo "错误: migrate 命令未找到"
|
||||
echo "请安装 golang-migrate:"
|
||||
echo " brew install golang-migrate (macOS)"
|
||||
echo " 或访问 https://github.com/golang-migrate/migrate/tree/master/cmd/migrate#installation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 显示使用说明
|
||||
show_usage() {
|
||||
cat << EOF
|
||||
用法: $0 [命令] [参数]
|
||||
|
||||
命令:
|
||||
up [N] 向上迁移 N 步 (默认: 全部)
|
||||
down [N] 向下回滚 N 步 (默认: 1)
|
||||
create NAME 创建新的迁移文件
|
||||
version 显示当前迁移版本
|
||||
force V 强制设置迁移版本为 V (用于修复脏数据库状态)
|
||||
help 显示此帮助信息
|
||||
|
||||
环境变量:
|
||||
DB_HOST 数据库主机 (默认: localhost)
|
||||
DB_PORT 数据库端口 (默认: 5432)
|
||||
DB_USER 数据库用户 (默认: postgres)
|
||||
DB_PASSWORD 数据库密码 (默认: password)
|
||||
DB_NAME 数据库名称 (默认: junhong_cmp)
|
||||
DB_SSLMODE SSL 模式 (默认: disable)
|
||||
|
||||
示例:
|
||||
$0 up # 应用所有迁移
|
||||
$0 down 1 # 回滚最后一次迁移
|
||||
$0 create add_sim_table # 创建新迁移文件
|
||||
$0 version # 查看当前版本
|
||||
$0 force 1 # 强制设置版本为 1
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# 主命令处理
|
||||
case "$1" in
|
||||
up)
|
||||
if [ -z "$2" ]; then
|
||||
echo "正在应用所有迁移..."
|
||||
migrate -path "$MIGRATIONS_DIR" -database "$DATABASE_URL" up
|
||||
else
|
||||
echo "正在向上迁移 $2 步..."
|
||||
migrate -path "$MIGRATIONS_DIR" -database "$DATABASE_URL" up "$2"
|
||||
fi
|
||||
;;
|
||||
down)
|
||||
STEPS="${2:-1}"
|
||||
echo "正在向下回滚 $STEPS 步..."
|
||||
migrate -path "$MIGRATIONS_DIR" -database "$DATABASE_URL" down "$STEPS"
|
||||
;;
|
||||
create)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 请提供迁移文件名称"
|
||||
echo "用法: $0 create <name>"
|
||||
exit 1
|
||||
fi
|
||||
echo "创建迁移文件: $2"
|
||||
migrate create -ext sql -dir "$MIGRATIONS_DIR" -seq "$2"
|
||||
echo "迁移文件创建成功:"
|
||||
ls -lt "$MIGRATIONS_DIR" | head -3
|
||||
;;
|
||||
version)
|
||||
echo "当前迁移版本:"
|
||||
migrate -path "$MIGRATIONS_DIR" -database "$DATABASE_URL" version
|
||||
;;
|
||||
force)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 请提供版本号"
|
||||
echo "用法: $0 force <version>"
|
||||
exit 1
|
||||
fi
|
||||
echo "强制设置迁移版本为: $2"
|
||||
migrate -path "$MIGRATIONS_DIR" -database "$DATABASE_URL" force "$2"
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_usage
|
||||
;;
|
||||
*)
|
||||
echo "错误: 未知命令 '$1'"
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "✓ 迁移操作完成"
|
||||
41
specs/002-gorm-postgres-asynq/checklists/requirements.md
Normal file
41
specs/002-gorm-postgres-asynq/checklists/requirements.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Specification Quality Checklist: 数据持久化与异步任务处理集成
|
||||
|
||||
**Purpose**: 在进入规划阶段前验证规格说明的完整性和质量
|
||||
**Created**: 2025-11-12
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] 无实现细节(语言、框架、API)
|
||||
- [x] 专注于用户价值和业务需求
|
||||
- [x] 为非技术干系人编写
|
||||
- [x] 所有必填部分已完成
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] 无[NEEDS CLARIFICATION]标记残留
|
||||
- [x] 需求可测试且无歧义
|
||||
- [x] 成功标准可衡量
|
||||
- [x] 成功标准技术无关(无实现细节)
|
||||
- [x] 所有验收场景已定义
|
||||
- [x] 边界情况已识别
|
||||
- [x] 范围边界清晰
|
||||
- [x] 依赖和假设已识别
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] 所有功能需求都有清晰的验收标准
|
||||
- [x] 用户场景涵盖主要流程
|
||||
- [x] 功能满足成功标准中定义的可衡量结果
|
||||
- [x] 无实现细节泄漏到规格说明中
|
||||
|
||||
## Notes
|
||||
|
||||
所有检查项均已通过。规格说明完整且质量良好,可以进入下一阶段(`/speckit.clarify`或`/speckit.plan`)。
|
||||
|
||||
规格说明的主要优势:
|
||||
- 用户故事按优先级清晰排序(P1核心数据持久化 → P2异步任务 → P3监控)
|
||||
- 功能需求详细且可测试,涵盖了GORM、PostgreSQL和Asynq的核心能力
|
||||
- 成功标准具体可衡量,包含响应时间、并发能力、可靠性等关键指标
|
||||
- 边界情况考虑周全,包括连接池耗尽、死锁、主从切换等场景
|
||||
- 技术需求完全遵循项目宪章(Constitution),确保架构一致性
|
||||
733
specs/002-gorm-postgres-asynq/contracts/api.yaml
Normal file
733
specs/002-gorm-postgres-asynq/contracts/api.yaml
Normal file
@@ -0,0 +1,733 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 数据持久化与异步任务处理集成 API
|
||||
description: |
|
||||
GORM + PostgreSQL + Asynq 集成的数据持久化和异步任务处理功能 API 规范
|
||||
|
||||
**Feature**: 002-gorm-postgres-asynq
|
||||
**Date**: 2025-11-12
|
||||
|
||||
## 核心功能
|
||||
- 数据库连接管理和健康检查
|
||||
- 异步任务提交和管理
|
||||
- 数据 CRUD 操作(示例:用户管理)
|
||||
|
||||
## 技术栈
|
||||
- Fiber (HTTP 框架)
|
||||
- GORM (ORM)
|
||||
- PostgreSQL (数据库)
|
||||
- Asynq (任务队列)
|
||||
- Redis (任务队列存储)
|
||||
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: API Support
|
||||
email: support@example.com
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080/api/v1
|
||||
description: 开发环境
|
||||
- url: http://staging.example.com/api/v1
|
||||
description: 预发布环境
|
||||
- url: https://api.example.com/api/v1
|
||||
description: 生产环境
|
||||
|
||||
tags:
|
||||
- name: Health
|
||||
description: 健康检查和系统状态
|
||||
- name: Users
|
||||
description: 用户管理(数据库操作示例)
|
||||
- name: Tasks
|
||||
description: 异步任务管理
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
tags:
|
||||
- Health
|
||||
summary: 健康检查
|
||||
description: |
|
||||
检查系统健康状态,包括数据库连接和 Redis 连接
|
||||
|
||||
**测试用例**:
|
||||
- FR-011: 系统必须提供健康检查接口
|
||||
- SC-010: 健康检查应在 1 秒内返回
|
||||
operationId: healthCheck
|
||||
responses:
|
||||
'200':
|
||||
description: 系统健康
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok]
|
||||
description: 系统整体状态
|
||||
postgres:
|
||||
type: string
|
||||
enum: [up, down]
|
||||
description: PostgreSQL 连接状态
|
||||
redis:
|
||||
type: string
|
||||
enum: [up, down]
|
||||
description: Redis 连接状态
|
||||
example:
|
||||
status: ok
|
||||
postgres: up
|
||||
redis: up
|
||||
'503':
|
||||
description: 服务降级或不可用
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [degraded, unavailable]
|
||||
postgres:
|
||||
type: string
|
||||
enum: [up, down]
|
||||
redis:
|
||||
type: string
|
||||
enum: [up, down]
|
||||
error:
|
||||
type: string
|
||||
description: 错误详情
|
||||
example:
|
||||
status: degraded
|
||||
postgres: down
|
||||
redis: up
|
||||
error: "数据库连接失败"
|
||||
|
||||
/users:
|
||||
post:
|
||||
tags:
|
||||
- Users
|
||||
summary: 创建用户
|
||||
description: |
|
||||
创建新用户(演示数据库 CRUD 操作)
|
||||
|
||||
**测试用例**:
|
||||
- FR-002: 支持标准 CRUD 操作
|
||||
- FR-003: 支持数据库事务
|
||||
- User Story 1 - Acceptance 1: 数据持久化
|
||||
operationId: createUser
|
||||
security:
|
||||
- TokenAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 用户创建成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
get:
|
||||
tags:
|
||||
- Users
|
||||
summary: 用户列表
|
||||
description: |
|
||||
分页查询用户列表
|
||||
|
||||
**测试用例**:
|
||||
- FR-002: 支持分页列表查询
|
||||
- FR-005: 支持条件查询、分页、排序
|
||||
- User Story 1 - Acceptance 5: 分页和排序
|
||||
operationId: listUsers
|
||||
security:
|
||||
- TokenAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
minimum: 1
|
||||
description: 页码
|
||||
- name: page_size
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
description: 每页条数(最大 100)
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [active, inactive, suspended]
|
||||
description: 用户状态过滤
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ListUsersResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/users/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Users
|
||||
summary: 获取用户详情
|
||||
description: |
|
||||
根据用户 ID 获取详细信息
|
||||
|
||||
**测试用例**:
|
||||
- FR-002: 支持按 ID 查询
|
||||
- User Story 1 - Acceptance 1: 数据检索
|
||||
operationId: getUserById
|
||||
security:
|
||||
- TokenAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: 用户 ID
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
put:
|
||||
tags:
|
||||
- Users
|
||||
summary: 更新用户
|
||||
description: |
|
||||
更新用户信息
|
||||
|
||||
**测试用例**:
|
||||
- FR-002: 支持更新操作
|
||||
- User Story 1 - Acceptance 2: 数据更新
|
||||
operationId: updateUser
|
||||
security:
|
||||
- TokenAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: 用户 ID
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateUserRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
delete:
|
||||
tags:
|
||||
- Users
|
||||
summary: 删除用户
|
||||
description: |
|
||||
软删除用户(设置 deleted_at 字段)
|
||||
|
||||
**测试用例**:
|
||||
- FR-002: 支持软删除操作
|
||||
- User Story 1 - Acceptance 3: 数据删除
|
||||
operationId: deleteUser
|
||||
security:
|
||||
- TokenAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: 用户 ID
|
||||
responses:
|
||||
'200':
|
||||
description: 删除成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/tasks/email:
|
||||
post:
|
||||
tags:
|
||||
- Tasks
|
||||
summary: 提交邮件发送任务
|
||||
description: |
|
||||
将邮件发送任务提交到异步队列
|
||||
|
||||
**测试用例**:
|
||||
- FR-006: 提交任务到异步队列
|
||||
- FR-008: 任务重试机制
|
||||
- User Story 2 - Acceptance 1: 任务提交
|
||||
operationId: submitEmailTask
|
||||
security:
|
||||
- TokenAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EmailTaskRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 任务已提交
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TaskResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/tasks/sync:
|
||||
post:
|
||||
tags:
|
||||
- Tasks
|
||||
summary: 提交数据同步任务
|
||||
description: |
|
||||
将数据同步任务提交到异步队列(支持优先级)
|
||||
|
||||
**测试用例**:
|
||||
- FR-006: 提交任务到异步队列
|
||||
- FR-009: 任务优先级支持
|
||||
- User Story 2 - Acceptance 1: 任务提交
|
||||
operationId: submitSyncTask
|
||||
security:
|
||||
- TokenAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SyncTaskRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 任务已提交
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TaskResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
TokenAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: token
|
||||
description: 认证令牌
|
||||
|
||||
schemas:
|
||||
# 通用响应
|
||||
SuccessResponse:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- timestamp
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
enum: [0]
|
||||
description: 响应码(0 表示成功)
|
||||
msg:
|
||||
type: string
|
||||
example: success
|
||||
description: 响应消息
|
||||
data:
|
||||
type: object
|
||||
description: 响应数据(具体结构由各端点定义)
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2025-11-12T16:00:00+08:00"
|
||||
description: 响应时间戳(ISO 8601 格式)
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- timestamp
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
description: 错误码(非 0)
|
||||
example: 1001
|
||||
msg:
|
||||
type: string
|
||||
description: 错误消息(中文)
|
||||
example: "参数验证失败"
|
||||
data:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 错误详情(可选)
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2025-11-12T16:00:00+08:00"
|
||||
|
||||
# 用户相关
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- email
|
||||
- password
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 50
|
||||
pattern: '^[a-zA-Z0-9_]+$'
|
||||
description: 用户名(3-50 个字母数字下划线)
|
||||
example: testuser
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
maxLength: 100
|
||||
description: 邮箱地址
|
||||
example: test@example.com
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
minLength: 8
|
||||
description: 密码(至少 8 个字符)
|
||||
example: password123
|
||||
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
maxLength: 100
|
||||
description: 邮箱地址
|
||||
example: newemail@example.com
|
||||
status:
|
||||
type: string
|
||||
enum: [active, inactive, suspended]
|
||||
description: 用户状态
|
||||
|
||||
UserResponse:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- email
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: 用户 ID
|
||||
example: 1
|
||||
username:
|
||||
type: string
|
||||
description: 用户名
|
||||
example: testuser
|
||||
email:
|
||||
type: string
|
||||
description: 邮箱地址
|
||||
example: test@example.com
|
||||
status:
|
||||
type: string
|
||||
enum: [active, inactive, suspended]
|
||||
description: 用户状态
|
||||
example: active
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 创建时间
|
||||
example: "2025-11-12T16:00:00+08:00"
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 更新时间
|
||||
example: "2025-11-12T16:00:00+08:00"
|
||||
last_login_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 最后登录时间
|
||||
example: "2025-11-12T16:30:00+08:00"
|
||||
|
||||
ListUsersResponse:
|
||||
type: object
|
||||
required:
|
||||
- users
|
||||
- page
|
||||
- page_size
|
||||
- total
|
||||
- total_pages
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
description: 用户列表
|
||||
page:
|
||||
type: integer
|
||||
description: 当前页码
|
||||
example: 1
|
||||
page_size:
|
||||
type: integer
|
||||
description: 每页条数
|
||||
example: 20
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 总记录数
|
||||
example: 100
|
||||
total_pages:
|
||||
type: integer
|
||||
description: 总页数
|
||||
example: 5
|
||||
|
||||
# 任务相关
|
||||
EmailTaskRequest:
|
||||
type: object
|
||||
required:
|
||||
- to
|
||||
- subject
|
||||
- body
|
||||
properties:
|
||||
to:
|
||||
type: string
|
||||
format: email
|
||||
description: 收件人邮箱
|
||||
example: user@example.com
|
||||
subject:
|
||||
type: string
|
||||
maxLength: 200
|
||||
description: 邮件主题
|
||||
example: Welcome to our service
|
||||
body:
|
||||
type: string
|
||||
description: 邮件正文
|
||||
example: Thank you for signing up!
|
||||
cc:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: email
|
||||
description: 抄送列表
|
||||
example: ["manager@example.com"]
|
||||
priority:
|
||||
type: string
|
||||
enum: [critical, default, low]
|
||||
default: default
|
||||
description: 任务优先级
|
||||
|
||||
SyncTaskRequest:
|
||||
type: object
|
||||
required:
|
||||
- sync_type
|
||||
- start_date
|
||||
- end_date
|
||||
properties:
|
||||
sync_type:
|
||||
type: string
|
||||
enum: [sim_status, flow_usage, real_name]
|
||||
description: 同步类型
|
||||
example: sim_status
|
||||
start_date:
|
||||
type: string
|
||||
format: date
|
||||
pattern: '^\d{4}-\d{2}-\d{2}$'
|
||||
description: 开始日期(YYYY-MM-DD)
|
||||
example: "2025-11-01"
|
||||
end_date:
|
||||
type: string
|
||||
format: date
|
||||
pattern: '^\d{4}-\d{2}-\d{2}$'
|
||||
description: 结束日期(YYYY-MM-DD)
|
||||
example: "2025-11-12"
|
||||
batch_size:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
description: 批量大小
|
||||
priority:
|
||||
type: string
|
||||
enum: [critical, default, low]
|
||||
default: default
|
||||
description: 任务优先级
|
||||
|
||||
TaskResponse:
|
||||
type: object
|
||||
required:
|
||||
- task_id
|
||||
- queue
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 任务唯一 ID
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
queue:
|
||||
type: string
|
||||
enum: [critical, default, low]
|
||||
description: 任务所在队列
|
||||
example: default
|
||||
estimated_time:
|
||||
type: string
|
||||
description: 预计执行时间
|
||||
example: "within 5 minutes"
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: 请求参数错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
code: 1001
|
||||
msg: "参数验证失败"
|
||||
data: null
|
||||
timestamp: "2025-11-12T16:00:00+08:00"
|
||||
|
||||
Unauthorized:
|
||||
description: 未授权或令牌无效
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
code: 1002
|
||||
msg: "缺失认证令牌"
|
||||
data: null
|
||||
timestamp: "2025-11-12T16:00:00+08:00"
|
||||
|
||||
NotFound:
|
||||
description: 资源不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
code: 1003
|
||||
msg: "用户不存在"
|
||||
data: null
|
||||
timestamp: "2025-11-12T16:00:00+08:00"
|
||||
|
||||
Conflict:
|
||||
description: 资源冲突(如用户名已存在)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
code: 1004
|
||||
msg: "用户名已存在"
|
||||
data: null
|
||||
timestamp: "2025-11-12T16:00:00+08:00"
|
||||
|
||||
InternalServerError:
|
||||
description: 服务器内部错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
code: 5000
|
||||
msg: "服务器内部错误"
|
||||
data: null
|
||||
timestamp: "2025-11-12T16:00:00+08:00"
|
||||
644
specs/002-gorm-postgres-asynq/data-model.md
Normal file
644
specs/002-gorm-postgres-asynq/data-model.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# Data Model: 数据持久化与异步任务处理集成
|
||||
|
||||
**Feature**: 002-gorm-postgres-asynq
|
||||
**Date**: 2025-11-12
|
||||
**Purpose**: 定义数据模型、配置结构和系统实体
|
||||
|
||||
## 概述
|
||||
|
||||
本文档定义了数据持久化和异步任务处理功能的数据模型,包括配置结构、数据库实体示例和任务载荷结构。
|
||||
|
||||
---
|
||||
|
||||
## 1. 配置模型
|
||||
|
||||
### 1.1 数据库配置
|
||||
|
||||
```go
|
||||
// pkg/config/config.go
|
||||
|
||||
// DatabaseConfig 数据库连接配置
|
||||
type DatabaseConfig struct {
|
||||
// 连接参数
|
||||
Host string `mapstructure:"host"` // 数据库主机地址
|
||||
Port int `mapstructure:"port"` // 数据库端口
|
||||
User string `mapstructure:"user"` // 数据库用户名
|
||||
Password string `mapstructure:"password"` // 数据库密码(明文存储)
|
||||
DBName string `mapstructure:"dbname"` // 数据库名称
|
||||
SSLMode string `mapstructure:"sslmode"` // SSL 模式:disable, require, verify-ca, verify-full
|
||||
|
||||
// 连接池配置
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"` // 最大打开连接数(默认:25)
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"` // 最大空闲连接数(默认:10)
|
||||
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` // 连接最大生命周期(默认:5m)
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| Host | string | localhost | PostgreSQL 服务器地址 |
|
||||
| Port | int | 5432 | PostgreSQL 服务器端口 |
|
||||
| User | string | postgres | 数据库用户名 |
|
||||
| Password | string | - | 数据库密码(明文存储在配置文件中) |
|
||||
| DBName | string | junhong_cmp | 数据库名称 |
|
||||
| SSLMode | string | disable | SSL 连接模式 |
|
||||
| MaxOpenConns | int | 25 | 最大数据库连接数 |
|
||||
| MaxIdleConns | int | 10 | 最大空闲连接数 |
|
||||
| ConnMaxLifetime | duration | 5m | 连接最大存活时间 |
|
||||
|
||||
### 1.2 任务队列配置
|
||||
|
||||
```go
|
||||
// pkg/config/config.go
|
||||
|
||||
// QueueConfig 任务队列配置
|
||||
type QueueConfig struct {
|
||||
// 并发配置
|
||||
Concurrency int `mapstructure:"concurrency"` // Worker 并发数(默认:10)
|
||||
|
||||
// 队列优先级配置(队列名 -> 权重)
|
||||
Queues map[string]int `mapstructure:"queues"` // 例如:{"critical": 6, "default": 3, "low": 1}
|
||||
|
||||
// 重试配置
|
||||
RetryMax int `mapstructure:"retry_max"` // 最大重试次数(默认:5)
|
||||
Timeout time.Duration `mapstructure:"timeout"` // 任务超时时间(默认:10m)
|
||||
}
|
||||
```
|
||||
|
||||
**队列优先级**:
|
||||
- `critical`: 关键任务(权重 6,约 60% 处理时间)
|
||||
- `default`: 普通任务(权重 3,约 30% 处理时间)
|
||||
- `low`: 低优先级任务(权重 1,约 10% 处理时间)
|
||||
|
||||
### 1.3 完整配置结构
|
||||
|
||||
```go
|
||||
// pkg/config/config.go
|
||||
|
||||
// Config 应用配置
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Database DatabaseConfig `mapstructure:"database"` // 新增
|
||||
Queue QueueConfig `mapstructure:"queue"` // 新增
|
||||
Middleware MiddlewareConfig `mapstructure:"middleware"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库实体模型
|
||||
|
||||
### 2.1 基础模型(Base Model)
|
||||
|
||||
```go
|
||||
// internal/model/base.go
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseModel 基础模型,包含通用字段
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `ID`: 自增主键
|
||||
- `CreatedAt`: 创建时间(GORM 自动管理)
|
||||
- `UpdatedAt`: 更新时间(GORM 自动管理)
|
||||
- `DeletedAt`: 删除时间(软删除,GORM 自动过滤已删除记录)
|
||||
|
||||
### 2.2 示例实体:用户模型
|
||||
|
||||
```go
|
||||
// internal/model/user.go
|
||||
|
||||
// User 用户实体
|
||||
type User struct {
|
||||
BaseModel
|
||||
|
||||
// 基本信息
|
||||
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:100" json:"email"`
|
||||
Password string `gorm:"not null;size:255" json:"-"` // 不返回给客户端
|
||||
|
||||
// 状态字段
|
||||
Status string `gorm:"not null;size:20;default:'active';index" json:"status"`
|
||||
|
||||
// 元数据
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (User) TableName() string {
|
||||
return "tb_user"
|
||||
}
|
||||
```
|
||||
|
||||
**索引策略**:
|
||||
- `username`: 唯一索引(快速查找和去重)
|
||||
- `email`: 唯一索引(快速查找和去重)
|
||||
- `status`: 普通索引(状态过滤查询)
|
||||
- `deleted_at`: 自动索引(软删除过滤)
|
||||
|
||||
**验证规则**:
|
||||
- `username`: 长度 3-50 字符,字母数字下划线
|
||||
- `email`: 标准邮箱格式
|
||||
- `password`: 长度 >= 8 字符,bcrypt 哈希存储
|
||||
- `status`: 枚举值(active, inactive, suspended)
|
||||
|
||||
### 2.3 示例实体:订单模型(演示手动关联关系)
|
||||
|
||||
```go
|
||||
// internal/model/order.go
|
||||
|
||||
// Order 订单实体
|
||||
type Order struct {
|
||||
BaseModel
|
||||
|
||||
// 业务唯一键
|
||||
OrderID string `gorm:"uniqueIndex;not null;size:50" json:"order_id"`
|
||||
|
||||
// 关联关系(仅存储 ID,不使用 GORM 关联)
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
|
||||
// 订单信息
|
||||
Amount int64 `gorm:"not null" json:"amount"` // 金额(分)
|
||||
Status string `gorm:"not null;size:20;index" json:"status"`
|
||||
Remark string `gorm:"size:500" json:"remark,omitempty"`
|
||||
|
||||
// 时间字段
|
||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Order) TableName() string {
|
||||
return "tb_order"
|
||||
}
|
||||
```
|
||||
|
||||
**关联关系说明**:
|
||||
- `UserID`: 存储关联用户的 ID(普通字段,无数据库外键约束)
|
||||
- **无 ORM 关联**:遵循 Constitution Principle IX,不使用 `foreignKey`、`belongsTo` 等标签
|
||||
- 关联数据查询在 Service 层手动实现(见下方示例)
|
||||
|
||||
**手动查询关联数据示例**:
|
||||
```go
|
||||
// internal/service/order/service.go
|
||||
|
||||
// GetOrderWithUser 查询订单及关联的用户信息
|
||||
func (s *Service) GetOrderWithUser(ctx context.Context, orderID uint) (*OrderDetail, error) {
|
||||
// 1. 查询订单
|
||||
order, err := s.store.Order.GetByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询订单失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 手动查询关联的用户
|
||||
user, err := s.store.User.GetByID(ctx, order.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 组装返回数据
|
||||
return &OrderDetail{
|
||||
Order: order,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListOrdersByUserID 查询指定用户的订单列表
|
||||
func (s *Service) ListOrdersByUserID(ctx context.Context, userID uint, page, pageSize int) ([]*Order, int64, error) {
|
||||
return s.store.Order.ListByUserID(ctx, userID, page, pageSize)
|
||||
}
|
||||
```
|
||||
|
||||
**状态流转**:
|
||||
```
|
||||
pending → paid → processing → completed
|
||||
↓
|
||||
cancelled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据传输对象(DTO)
|
||||
|
||||
### 3.1 用户 DTO
|
||||
|
||||
```go
|
||||
// internal/model/user_dto.go
|
||||
|
||||
// CreateUserRequest 创建用户请求
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50,alphanum"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest 更新用户请求
|
||||
type UpdateUserRequest struct {
|
||||
Email *string `json:"email" validate:"omitempty,email"`
|
||||
Status *string `json:"status" validate:"omitempty,oneof=active inactive suspended"`
|
||||
}
|
||||
|
||||
// UserResponse 用户响应
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListUsersResponse 用户列表响应
|
||||
type ListUsersResponse struct {
|
||||
Users []UserResponse `json:"users"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务载荷模型
|
||||
|
||||
### 4.1 任务类型常量
|
||||
|
||||
```go
|
||||
// pkg/constants/constants.go
|
||||
|
||||
const (
|
||||
// 任务类型
|
||||
TaskTypeEmailSend = "email:send" // 发送邮件
|
||||
TaskTypeDataSync = "data:sync" // 数据同步
|
||||
TaskTypeSIMStatusSync = "sim:status:sync" // SIM 卡状态同步
|
||||
TaskTypeCommission = "commission:calculate" // 分佣计算
|
||||
)
|
||||
```
|
||||
|
||||
### 4.2 邮件任务载荷
|
||||
|
||||
```go
|
||||
// internal/task/email.go
|
||||
|
||||
// EmailPayload 邮件任务载荷
|
||||
type EmailPayload struct {
|
||||
RequestID string `json:"request_id"` // 幂等性标识
|
||||
To string `json:"to"` // 收件人
|
||||
Subject string `json:"subject"` // 主题
|
||||
Body string `json:"body"` // 正文
|
||||
CC []string `json:"cc,omitempty"` // 抄送
|
||||
Attachments []string `json:"attachments,omitempty"` // 附件路径
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 数据同步任务载荷
|
||||
|
||||
```go
|
||||
// internal/task/sync.go
|
||||
|
||||
// DataSyncPayload 数据同步任务载荷
|
||||
type DataSyncPayload struct {
|
||||
RequestID string `json:"request_id"` // 幂等性标识
|
||||
SyncType string `json:"sync_type"` // 同步类型:sim_status, flow_usage, real_name
|
||||
StartDate string `json:"start_date"` // 开始日期(YYYY-MM-DD)
|
||||
EndDate string `json:"end_date"` // 结束日期(YYYY-MM-DD)
|
||||
BatchSize int `json:"batch_size"` // 批量大小(默认:100)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 SIM 卡状态同步载荷
|
||||
|
||||
```go
|
||||
// internal/task/sim.go
|
||||
|
||||
// SIMStatusSyncPayload SIM 卡状态同步任务载荷
|
||||
type SIMStatusSyncPayload struct {
|
||||
RequestID string `json:"request_id"` // 幂等性标识
|
||||
ICCIDs []string `json:"iccids"` // ICCID 列表
|
||||
ForceSync bool `json:"force_sync"` // 强制同步(忽略缓存)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据库 Schema(SQL)
|
||||
|
||||
### 5.1 初始化 Schema
|
||||
|
||||
```sql
|
||||
-- migrations/000001_init_schema.up.sql
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS tb_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
|
||||
-- 基本信息
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
|
||||
-- 状态字段
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
|
||||
-- 元数据
|
||||
last_login_at TIMESTAMP,
|
||||
|
||||
-- 唯一约束
|
||||
CONSTRAINT uk_user_username UNIQUE (username),
|
||||
CONSTRAINT uk_user_email UNIQUE (email)
|
||||
);
|
||||
|
||||
-- 用户表索引
|
||||
CREATE INDEX idx_user_deleted_at ON tb_user(deleted_at);
|
||||
CREATE INDEX idx_user_status ON tb_user(status);
|
||||
CREATE INDEX idx_user_created_at ON tb_user(created_at);
|
||||
|
||||
-- 订单表
|
||||
CREATE TABLE IF NOT EXISTS tb_order (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
|
||||
-- 业务唯一键
|
||||
order_id VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 关联关系(注意:无数据库外键约束,在代码中管理)
|
||||
user_id INTEGER NOT NULL,
|
||||
|
||||
-- 订单信息
|
||||
amount BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
remark VARCHAR(500),
|
||||
|
||||
-- 时间字段
|
||||
paid_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
|
||||
-- 唯一约束
|
||||
CONSTRAINT uk_order_order_id UNIQUE (order_id)
|
||||
);
|
||||
|
||||
-- 订单表索引
|
||||
CREATE INDEX idx_order_deleted_at ON tb_order(deleted_at);
|
||||
CREATE INDEX idx_order_user_id ON tb_order(user_id);
|
||||
CREATE INDEX idx_order_status ON tb_order(status);
|
||||
CREATE INDEX idx_order_created_at ON tb_order(created_at);
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON TABLE tb_user IS '用户表';
|
||||
COMMENT ON COLUMN tb_user.username IS '用户名(唯一)';
|
||||
COMMENT ON COLUMN tb_user.email IS '邮箱(唯一)';
|
||||
COMMENT ON COLUMN tb_user.password IS '密码(bcrypt 哈希)';
|
||||
COMMENT ON COLUMN tb_user.status IS '用户状态:active, inactive, suspended';
|
||||
COMMENT ON COLUMN tb_user.deleted_at IS '软删除时间';
|
||||
|
||||
COMMENT ON TABLE tb_order IS '订单表';
|
||||
COMMENT ON COLUMN tb_order.order_id IS '订单号(业务唯一键)';
|
||||
COMMENT ON COLUMN tb_order.user_id IS '用户 ID(在代码中维护关联,无数据库外键)';
|
||||
COMMENT ON COLUMN tb_order.amount IS '金额(分)';
|
||||
COMMENT ON COLUMN tb_order.status IS '订单状态:pending, paid, processing, completed, cancelled';
|
||||
COMMENT ON COLUMN tb_order.deleted_at IS '软删除时间';
|
||||
```
|
||||
|
||||
**重要说明**:
|
||||
- ✅ **无外键约束**:`user_id` 仅作为普通字段存储,无 `REFERENCES` 约束
|
||||
- ✅ **无触发器**:`created_at` 和 `updated_at` 由 GORM 自动管理,无需数据库触发器
|
||||
- ✅ **遵循 Constitution Principle IX**:表关系在代码层面手动维护
|
||||
|
||||
### 5.2 回滚 Schema
|
||||
|
||||
```sql
|
||||
-- migrations/000001_init_schema.down.sql
|
||||
|
||||
-- 删除表(按依赖顺序倒序删除)
|
||||
DROP TABLE IF EXISTS tb_order;
|
||||
DROP TABLE IF EXISTS tb_user;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Redis 键结构
|
||||
|
||||
### 6.1 任务锁键
|
||||
|
||||
```go
|
||||
// pkg/constants/redis.go
|
||||
|
||||
// RedisTaskLockKey 生成任务锁键
|
||||
// 格式: task:lock:{request_id}
|
||||
// 用途: 幂等性控制
|
||||
// 过期时间: 24 小时
|
||||
func RedisTaskLockKey(requestID string) string {
|
||||
return fmt.Sprintf("task:lock:%s", requestID)
|
||||
}
|
||||
```
|
||||
|
||||
**使用示例**:
|
||||
```go
|
||||
key := constants.RedisTaskLockKey("req-123456")
|
||||
// 结果: "task:lock:req-123456"
|
||||
```
|
||||
|
||||
### 6.2 任务状态键
|
||||
|
||||
```go
|
||||
// RedisTaskStatusKey 生成任务状态键
|
||||
// 格式: task:status:{task_id}
|
||||
// 用途: 存储任务执行状态
|
||||
// 过期时间: 7 天
|
||||
func RedisTaskStatusKey(taskID string) string {
|
||||
return fmt.Sprintf("task:status:%s", taskID)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 常量定义
|
||||
|
||||
### 7.1 用户状态常量
|
||||
|
||||
```go
|
||||
// pkg/constants/constants.go
|
||||
|
||||
const (
|
||||
// 用户状态
|
||||
UserStatusActive = "active" // 激活
|
||||
UserStatusInactive = "inactive" // 未激活
|
||||
UserStatusSuspended = "suspended" // 暂停
|
||||
)
|
||||
```
|
||||
|
||||
### 7.2 订单状态常量
|
||||
|
||||
```go
|
||||
const (
|
||||
// 订单状态
|
||||
OrderStatusPending = "pending" // 待支付
|
||||
OrderStatusPaid = "paid" // 已支付
|
||||
OrderStatusProcessing = "processing" // 处理中
|
||||
OrderStatusCompleted = "completed" // 已完成
|
||||
OrderStatusCancelled = "cancelled" // 已取消
|
||||
)
|
||||
```
|
||||
|
||||
### 7.3 数据库配置常量
|
||||
|
||||
```go
|
||||
const (
|
||||
// 数据库连接池默认值
|
||||
DefaultMaxOpenConns = 25
|
||||
DefaultMaxIdleConns = 10
|
||||
DefaultConnMaxLifetime = 5 * time.Minute
|
||||
|
||||
// 查询限制
|
||||
DefaultPageSize = 20
|
||||
MaxPageSize = 100
|
||||
|
||||
// 慢查询阈值
|
||||
SlowQueryThreshold = 100 * time.Millisecond
|
||||
)
|
||||
```
|
||||
|
||||
### 7.4 任务队列常量
|
||||
|
||||
```go
|
||||
const (
|
||||
// 队列名称
|
||||
QueueCritical = "critical"
|
||||
QueueDefault = "default"
|
||||
QueueLow = "low"
|
||||
|
||||
// 默认重试配置
|
||||
DefaultRetryMax = 5
|
||||
DefaultTimeout = 10 * time.Minute
|
||||
|
||||
// 默认并发数
|
||||
DefaultConcurrency = 10
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 实体关系图(ER Diagram)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ tb_user │
|
||||
├─────────────────┤
|
||||
│ id (PK) │
|
||||
│ username (UQ) │
|
||||
│ email (UQ) │
|
||||
│ password │
|
||||
│ status │
|
||||
│ last_login_at │
|
||||
│ created_at │
|
||||
│ updated_at │
|
||||
│ deleted_at │
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ 1:N (代码层面维护)
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ tb_order │
|
||||
├─────────────────┤
|
||||
│ id (PK) │
|
||||
│ order_id (UQ) │
|
||||
│ user_id │ ← 存储关联 ID(无数据库外键)
|
||||
│ amount │
|
||||
│ status │
|
||||
│ remark │
|
||||
│ paid_at │
|
||||
│ completed_at │
|
||||
│ created_at │
|
||||
│ updated_at │
|
||||
│ deleted_at │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**关系说明**:
|
||||
- 一个用户可以有多个订单(1:N 关系)
|
||||
- 订单通过 `user_id` 字段存储用户 ID,**在代码层面维护关联**
|
||||
- **无数据库外键约束**:遵循 Constitution Principle IX
|
||||
- 关联查询在 Service 层手动实现(参见 2.3 节示例代码)
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据验证规则
|
||||
|
||||
### 9.1 用户字段验证
|
||||
|
||||
| 字段 | 验证规则 | 错误消息 |
|
||||
|------|----------|----------|
|
||||
| username | required, min=3, max=50, alphanum | 用户名必填,3-50 个字母数字字符 |
|
||||
| email | required, email | 邮箱必填且格式正确 |
|
||||
| password | required, min=8 | 密码必填,至少 8 个字符 |
|
||||
| status | oneof=active inactive suspended | 状态必须为 active, inactive, suspended 之一 |
|
||||
|
||||
### 9.2 订单字段验证
|
||||
|
||||
| 字段 | 验证规则 | 错误消息 |
|
||||
|------|----------|----------|
|
||||
| order_id | required, min=10, max=50 | 订单号必填,10-50 个字符 |
|
||||
| user_id | required, gt=0 | 用户 ID 必填且大于 0 |
|
||||
| amount | required, gte=0 | 金额必填且大于等于 0 |
|
||||
| status | oneof=pending paid processing completed cancelled | 状态值无效 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据迁移版本
|
||||
|
||||
| 版本 | 文件名 | 描述 | 日期 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 000001_init_schema | 初始化用户表和订单表 | 2025-11-12 |
|
||||
|
||||
**添加新迁移**:
|
||||
```bash
|
||||
# 创建新迁移文件
|
||||
migrate create -ext sql -dir migrations -seq add_sim_table
|
||||
|
||||
# 生成文件:
|
||||
# migrations/000002_add_sim_table.up.sql
|
||||
# migrations/000002_add_sim_table.down.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
本数据模型定义了:
|
||||
|
||||
1. **配置模型**:数据库连接配置、任务队列配置
|
||||
2. **实体模型**:基础模型、用户模型、订单模型(示例)
|
||||
3. **DTO 模型**:请求/响应数据传输对象
|
||||
4. **任务载荷**:各类异步任务的载荷结构
|
||||
5. **数据库 Schema**:SQL 迁移脚本
|
||||
6. **Redis 键结构**:任务锁、任务状态等键生成函数
|
||||
7. **常量定义**:状态枚举、默认配置值
|
||||
8. **验证规则**:字段级别的数据验证规则
|
||||
|
||||
**设计原则**:
|
||||
- 遵循 GORM 约定(BaseModel、软删除)
|
||||
- 遵循 Constitution 命名规范(PascalCase 字段、snake_case 列名)
|
||||
- 统一使用常量定义(避免硬编码)
|
||||
- 支持软删除和审计字段(created_at, updated_at)
|
||||
- 使用数据库约束保证数据完整性
|
||||
195
specs/002-gorm-postgres-asynq/plan.md
Normal file
195
specs/002-gorm-postgres-asynq/plan.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Implementation Plan: 数据持久化与异步任务处理集成
|
||||
|
||||
**Branch**: `002-gorm-postgres-asynq` | **Date**: 2025-11-13 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/002-gorm-postgres-asynq/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
本功能集成 GORM + PostgreSQL + Asynq,实现可靠的数据持久化和异步任务处理能力。系统支持标准 CRUD 操作、事务处理、数据库迁移管理、异步任务队列(支持重试、优先级、定时任务)、健康检查和优雅关闭。技术选型基于项目 Constitution 要求,使用 golang-migrate 管理数据库迁移(不使用 GORM AutoMigrate),通过 Redis 持久化任务状态确保故障恢复,所有任务处理逻辑设计为幂等操作。
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25.4
|
||||
**Primary Dependencies**: Fiber (HTTP 框架), GORM (ORM), Asynq (任务队列), Viper (配置), Zap (日志), golang-migrate (数据库迁移)
|
||||
**Storage**: PostgreSQL 14+(主数据库), Redis 6.0+(任务队列存储)
|
||||
**Testing**: Go 标准 testing 框架, testcontainers (集成测试)
|
||||
**Target Platform**: Linux/macOS 服务器
|
||||
**Project Type**: Backend API + Worker 服务(双进程架构)
|
||||
**Performance Goals**: API 响应时间 P95 < 200ms, 数据库查询 < 50ms, 任务队列处理速率 100 tasks/s
|
||||
**Constraints**: 数据库连接池最大 25 连接, Worker 默认并发 10, 任务超时 10 分钟, 慢查询阈值 100ms
|
||||
**Scale/Scope**: 支持 1000+ 并发连接, 10000+ 待处理任务队列, 水平扩展 Worker 进程
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
**Tech Stack Adherence**:
|
||||
- [ ] Feature uses Fiber + GORM + Viper + Zap + Lumberjack.v2 + Validator + sonic JSON + Asynq + PostgreSQL
|
||||
- [ ] No native calls bypass framework (no `database/sql`, `net/http`, `encoding/json` direct use)
|
||||
- [ ] All HTTP operations use Fiber framework
|
||||
- [ ] All database operations use GORM
|
||||
- [ ] All async tasks use Asynq
|
||||
- [ ] Uses Go official toolchain: `go fmt`, `go vet`, `golangci-lint`
|
||||
- [ ] Uses Go Modules for dependency management
|
||||
|
||||
**Code Quality Standards**:
|
||||
- [ ] Follows Handler → Service → Store → Model architecture
|
||||
- [ ] Handler layer only handles HTTP, no business logic
|
||||
- [ ] Service layer contains business logic with cross-module support
|
||||
- [ ] Store layer manages all data access with transaction support
|
||||
- [ ] Uses dependency injection via struct fields (not constructor patterns)
|
||||
- [ ] Unified error codes in `pkg/errors/`
|
||||
- [ ] Unified API responses via `pkg/response/`
|
||||
- [ ] All constants defined in `pkg/constants/`
|
||||
- [ ] All Redis keys managed via key generation functions (no hardcoded strings)
|
||||
- [ ] **No hardcoded magic numbers or strings (3+ occurrences must be constants)**
|
||||
- [ ] **Defined constants are used instead of hardcoding duplicate values**
|
||||
- [ ] **Code comments prefer Chinese for readability (implementation comments in Chinese)**
|
||||
- [ ] **Log messages use Chinese (Info/Warn/Error/Debug logs in Chinese)**
|
||||
- [ ] **Error messages support Chinese (user-facing errors have Chinese messages)**
|
||||
- [ ] All exported functions/types have Go-style doc comments
|
||||
- [ ] Code formatted with `gofmt`
|
||||
- [ ] Follows Effective Go and Go Code Review Comments
|
||||
|
||||
**Documentation Standards** (Constitution Principle VII):
|
||||
- [ ] Feature summary docs placed in `docs/{feature-id}/` mirroring `specs/{feature-id}/`
|
||||
- [ ] Summary doc filenames use Chinese (功能总结.md, 使用指南.md, etc.)
|
||||
- [ ] Summary doc content uses Chinese
|
||||
- [ ] README.md updated with brief Chinese summary (2-3 sentences)
|
||||
- [ ] Documentation is concise for first-time contributors
|
||||
|
||||
**Go Idiomatic Design**:
|
||||
- [ ] Package structure is flat (max 2-3 levels), organized by feature
|
||||
- [ ] Interfaces are small (1-3 methods), defined at use site
|
||||
- [ ] No Java-style patterns: no I-prefix, no Impl-suffix, no getters/setters
|
||||
- [ ] Error handling is explicit (return errors, no panic/recover abuse)
|
||||
- [ ] Uses composition over inheritance
|
||||
- [ ] Uses goroutines and channels (not thread pools)
|
||||
- [ ] Uses `context.Context` for cancellation and timeouts
|
||||
- [ ] Naming follows Go conventions: short receivers, consistent abbreviations (URL, ID, HTTP)
|
||||
- [ ] No Hungarian notation or type prefixes
|
||||
- [ ] Simple constructors (New/NewXxx), no Builder pattern unless necessary
|
||||
|
||||
**Testing Standards**:
|
||||
- [ ] Unit tests for all core business logic (Service layer)
|
||||
- [ ] Integration tests for all API endpoints
|
||||
- [ ] Tests use Go standard testing framework
|
||||
- [ ] Test files named `*_test.go` in same directory
|
||||
- [ ] Test functions use `Test` prefix, benchmarks use `Benchmark` prefix
|
||||
- [ ] Table-driven tests for multiple test cases
|
||||
- [ ] Test helpers marked with `t.Helper()`
|
||||
- [ ] Tests are independent (no external service dependencies)
|
||||
- [ ] Target coverage: 70%+ overall, 90%+ for core business
|
||||
|
||||
**User Experience Consistency**:
|
||||
- [ ] All APIs use unified JSON response format
|
||||
- [ ] Error responses include clear error codes and bilingual messages
|
||||
- [ ] RESTful design principles followed
|
||||
- [ ] Unified pagination parameters (page, page_size, total)
|
||||
- [ ] Time fields use ISO 8601 format (RFC3339)
|
||||
- [ ] Currency amounts use integers (cents) to avoid float precision issues
|
||||
|
||||
**Performance Requirements**:
|
||||
- [ ] API response time (P95) < 200ms, (P99) < 500ms
|
||||
- [ ] Batch operations use bulk queries/inserts
|
||||
- [ ] All database queries have appropriate indexes
|
||||
- [ ] List queries implement pagination (default 20, max 100)
|
||||
- [ ] Non-realtime operations use async tasks
|
||||
- [ ] Database and Redis connection pools properly configured
|
||||
- [ ] Uses goroutines/channels for concurrency (not thread pools)
|
||||
- [ ] Uses `context.Context` for timeout control
|
||||
- [ ] Uses `sync.Pool` for frequently allocated objects
|
||||
|
||||
**Access Logging Standards** (Constitution Principle VIII):
|
||||
- [ ] ALL HTTP requests logged to access.log without exception
|
||||
- [ ] Request parameters (query + body) logged (limited to 50KB)
|
||||
- [ ] Response parameters (body) logged (limited to 50KB)
|
||||
- [ ] Logging happens via centralized Logger middleware (pkg/logger/Middleware())
|
||||
- [ ] No middleware bypasses access logging (including auth failures, rate limits)
|
||||
- [ ] Body truncation indicates "... (truncated)" when over 50KB limit
|
||||
- [ ] Access log includes all required fields: method, path, query, status, duration_ms, request_id, ip, user_agent, user_id, request_body, response_body
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
**设计文档(specs/ 目录)**:开发前的规划和设计
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
**总结文档(docs/ 目录)**:开发完成后的总结和使用指南(遵循 Constitution Principle VII)
|
||||
```text
|
||||
docs/[###-feature]/
|
||||
├── 功能总结.md # 功能概述、核心实现、技术要点(MUST 使用中文命名和内容)
|
||||
├── 使用指南.md # 如何使用该功能的详细说明(MUST 使用中文命名和内容)
|
||||
└── 架构说明.md # 架构设计和技术决策(可选,MUST 使用中文命名和内容)
|
||||
```
|
||||
|
||||
**README.md 更新**:每次完成功能后 MUST 在 README.md 添加简短描述(2-3 句话,中文)
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: 采用 Backend API + Worker 双进程架构。项目已存在完整的 Fiber 后端结构(cmd/api/, internal/handler/, internal/service/, internal/store/, internal/model/),本次功能在此基础上添加:
|
||||
- `cmd/worker/`: Worker 进程入口
|
||||
- `pkg/database/`: PostgreSQL 连接初始化
|
||||
- `pkg/queue/`: Asynq 客户端和服务端封装
|
||||
- `internal/task/`: 异步任务处理器
|
||||
- `internal/store/postgres/`: 数据访问层(基于 GORM)
|
||||
- `migrations/`: 数据库迁移文件(SQL)
|
||||
|
||||
现有目录结构已符合 Constitution 分层架构要求(Handler → Service → Store → Model),本功能遵循该架构。
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **无宪法违规** - 本功能完全符合项目 Constitution 要求,无需例外说明。
|
||||
829
specs/002-gorm-postgres-asynq/quickstart.md
Normal file
829
specs/002-gorm-postgres-asynq/quickstart.md
Normal file
@@ -0,0 +1,829 @@
|
||||
# Quick Start Guide: 数据持久化与异步任务处理集成
|
||||
|
||||
**Feature**: 002-gorm-postgres-asynq
|
||||
**Date**: 2025-11-12
|
||||
**Purpose**: 快速开始指南和使用示例
|
||||
|
||||
## 概述
|
||||
|
||||
本指南帮助开发者快速搭建和使用 GORM + PostgreSQL + Asynq 集成的数据持久化和异步任务处理功能。
|
||||
|
||||
---
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 系统要求
|
||||
|
||||
- Go 1.25.4+
|
||||
- PostgreSQL 14+
|
||||
- Redis 6.0+
|
||||
- golang-migrate CLI 工具
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装 Go 依赖
|
||||
go mod tidy
|
||||
|
||||
# 安装 golang-migrate(macOS)
|
||||
brew install golang-migrate
|
||||
|
||||
# 安装 golang-migrate(Linux)
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.15.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/local/bin/
|
||||
|
||||
# 或使用 Go install
|
||||
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 1: 启动 PostgreSQL
|
||||
|
||||
### 使用 Docker(推荐)
|
||||
|
||||
```bash
|
||||
# 启动 PostgreSQL 容器
|
||||
docker run --name postgres-dev \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=password \
|
||||
-e POSTGRES_DB=junhong_cmp \
|
||||
-p 5432:5432 \
|
||||
-d postgres:14
|
||||
|
||||
# 验证运行状态
|
||||
docker ps | grep postgres-dev
|
||||
```
|
||||
|
||||
### 使用本地安装
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install postgresql@14
|
||||
brew services start postgresql@14
|
||||
|
||||
# 创建数据库
|
||||
createdb junhong_cmp
|
||||
```
|
||||
|
||||
### 验证连接
|
||||
|
||||
```bash
|
||||
# 测试连接
|
||||
psql -h localhost -p 5432 -U postgres -d junhong_cmp
|
||||
|
||||
# 如果成功,会进入 PostgreSQL 命令行
|
||||
# 输入 \q 退出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 2: 启动 Redis
|
||||
|
||||
```bash
|
||||
# 使用 Docker
|
||||
docker run --name redis-dev \
|
||||
-p 6379:6379 \
|
||||
-d redis:7-alpine
|
||||
|
||||
# 或使用本地安装(macOS)
|
||||
brew install redis
|
||||
brew services start redis
|
||||
|
||||
# 验证 Redis
|
||||
redis-cli ping
|
||||
# 应返回: PONG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3: 配置数据库连接
|
||||
|
||||
编辑配置文件 `configs/config.yaml`,添加数据库和队列配置:
|
||||
|
||||
```yaml
|
||||
# configs/config.yaml
|
||||
|
||||
# 数据库配置
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: postgres
|
||||
password: password # 开发环境明文存储,生产环境使用环境变量
|
||||
dbname: junhong_cmp
|
||||
sslmode: disable # 开发环境禁用 SSL,生产环境使用 require
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 10
|
||||
conn_max_lifetime: 5m
|
||||
|
||||
# 任务队列配置
|
||||
queue:
|
||||
concurrency: 10 # Worker 并发数
|
||||
queues: # 队列优先级(权重)
|
||||
critical: 6 # 关键任务:60%
|
||||
default: 3 # 普通任务:30%
|
||||
low: 1 # 低优先级:10%
|
||||
retry_max: 5 # 最大重试次数
|
||||
timeout: 10m # 任务超时时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4: 运行数据库迁移
|
||||
|
||||
### 方法 1: 使用迁移脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 赋予执行权限
|
||||
chmod +x scripts/migrate.sh
|
||||
|
||||
# 向上迁移(应用所有迁移)
|
||||
./scripts/migrate.sh up
|
||||
|
||||
# 查看当前版本
|
||||
./scripts/migrate.sh version
|
||||
|
||||
# 回滚最后一次迁移
|
||||
./scripts/migrate.sh down 1
|
||||
|
||||
# 创建新迁移
|
||||
./scripts/migrate.sh create add_sim_table
|
||||
```
|
||||
|
||||
### 方法 2: 直接使用 migrate CLI
|
||||
|
||||
```bash
|
||||
# 设置数据库 URL
|
||||
export DATABASE_URL="postgresql://postgres:password@localhost:5432/junhong_cmp?sslmode=disable"
|
||||
|
||||
# 向上迁移
|
||||
migrate -path migrations -database "$DATABASE_URL" up
|
||||
|
||||
# 查看版本
|
||||
migrate -path migrations -database "$DATABASE_URL" version
|
||||
```
|
||||
|
||||
### 验证迁移成功
|
||||
|
||||
```bash
|
||||
# 连接数据库
|
||||
psql -h localhost -p 5432 -U postgres -d junhong_cmp
|
||||
|
||||
# 查看表
|
||||
\dt
|
||||
|
||||
# 应该看到:
|
||||
# tb_user
|
||||
# tb_order
|
||||
# schema_migrations(由 golang-migrate 创建)
|
||||
|
||||
# 退出
|
||||
\q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5: 启动 API 服务
|
||||
|
||||
```bash
|
||||
# 从项目根目录运行
|
||||
go run cmd/api/main.go
|
||||
|
||||
# 预期输出:
|
||||
# {"level":"info","timestamp":"...","message":"PostgreSQL 连接成功","host":"localhost","port":5432}
|
||||
# {"level":"info","timestamp":"...","message":"Redis 连接成功","addr":"localhost:6379"}
|
||||
# {"level":"info","timestamp":"...","message":"服务启动成功","host":"0.0.0.0","port":8080}
|
||||
```
|
||||
|
||||
### 验证 API 服务
|
||||
|
||||
```bash
|
||||
# 测试健康检查
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 预期响应:
|
||||
# {
|
||||
# "status": "ok",
|
||||
# "postgres": "up",
|
||||
# "redis": "up"
|
||||
# }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 6: 启动 Worker 服务
|
||||
|
||||
打开新的终端窗口:
|
||||
|
||||
```bash
|
||||
# 从项目根目录运行
|
||||
go run cmd/worker/main.go
|
||||
|
||||
# 预期输出:
|
||||
# {"level":"info","timestamp":"...","message":"PostgreSQL 连接成功","host":"localhost","port":5432}
|
||||
# {"level":"info","timestamp":"...","message":"Redis 连接成功","addr":"localhost:6379"}
|
||||
# {"level":"info","timestamp":"...","message":"Worker 启动成功","concurrency":10}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1: 数据库 CRUD 操作
|
||||
|
||||
#### 创建用户
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: valid_token_here" \
|
||||
-d '{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
|
||||
# 响应:
|
||||
# {
|
||||
# "code": 0,
|
||||
# "msg": "success",
|
||||
# "data": {
|
||||
# "id": 1,
|
||||
# "username": "testuser",
|
||||
# "email": "test@example.com",
|
||||
# "status": "active",
|
||||
# "created_at": "2025-11-12T16:00:00+08:00",
|
||||
# "updated_at": "2025-11-12T16:00:00+08:00"
|
||||
# },
|
||||
# "timestamp": "2025-11-12T16:00:00+08:00"
|
||||
# }
|
||||
```
|
||||
|
||||
#### 查询用户
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/users/1 \
|
||||
-H "token: valid_token_here"
|
||||
|
||||
# 响应:
|
||||
# {
|
||||
# "code": 0,
|
||||
# "msg": "success",
|
||||
# "data": {
|
||||
# "id": 1,
|
||||
# "username": "testuser",
|
||||
# "email": "test@example.com",
|
||||
# "status": "active",
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
```
|
||||
|
||||
#### 更新用户
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8080/api/v1/users/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: valid_token_here" \
|
||||
-d '{
|
||||
"email": "newemail@example.com",
|
||||
"status": "inactive"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 列表查询(分页)
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/v1/users?page=1&page_size=20" \
|
||||
-H "token: valid_token_here"
|
||||
|
||||
# 响应:
|
||||
# {
|
||||
# "code": 0,
|
||||
# "msg": "success",
|
||||
# "data": {
|
||||
# "users": [...],
|
||||
# "page": 1,
|
||||
# "page_size": 20,
|
||||
# "total": 100,
|
||||
# "total_pages": 5
|
||||
# }
|
||||
# }
|
||||
```
|
||||
|
||||
#### 删除用户(软删除)
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/v1/users/1 \
|
||||
-H "token: valid_token_here"
|
||||
```
|
||||
|
||||
### 示例 2: 提交异步任务
|
||||
|
||||
#### 提交邮件发送任务
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/tasks/email \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: valid_token_here" \
|
||||
-d '{
|
||||
"to": "user@example.com",
|
||||
"subject": "Welcome",
|
||||
"body": "Welcome to our service!"
|
||||
}'
|
||||
|
||||
# 响应:
|
||||
# {
|
||||
# "code": 0,
|
||||
# "msg": "任务已提交",
|
||||
# "data": {
|
||||
# "task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
# "queue": "default"
|
||||
# }
|
||||
# }
|
||||
```
|
||||
|
||||
#### 提交数据同步任务(高优先级)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/tasks/sync \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: valid_token_here" \
|
||||
-d '{
|
||||
"sync_type": "sim_status",
|
||||
"start_date": "2025-11-01",
|
||||
"end_date": "2025-11-12",
|
||||
"priority": "critical"
|
||||
}'
|
||||
```
|
||||
|
||||
### 示例 3: 直接在代码中使用数据库
|
||||
|
||||
```go
|
||||
// internal/service/user/service.go
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *postgres.Store
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// CreateUser 创建用户
|
||||
func (s *Service) CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
|
||||
// 参数验证
|
||||
if err := validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 密码哈希
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
if err := s.store.User.Create(ctx, user); err != nil {
|
||||
s.logger.Error("创建用户失败",
|
||||
zap.String("username", req.Username),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("用户创建成功",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("username", user.Username))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *Service) GetUserByID(ctx context.Context, id uint) (*model.User, error) {
|
||||
user, err := s.store.User.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(errors.CodeNotFound, "用户不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 4: 在代码中提交异步任务
|
||||
|
||||
```go
|
||||
// internal/service/email/service.go
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// SendWelcomeEmail 发送欢迎邮件(异步)
|
||||
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, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 提交任务到队列
|
||||
err = s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeEmailSend,
|
||||
payloadBytes,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("提交邮件任务失败",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("欢迎邮件任务已提交",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email))
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 5: 事务处理
|
||||
|
||||
```go
|
||||
// internal/service/order/service.go
|
||||
package order
|
||||
|
||||
// CreateOrderWithUser 创建订单并更新用户统计(事务)
|
||||
func (s *Service) CreateOrderWithUser(ctx context.Context, req *CreateOrderRequest) (*model.Order, error) {
|
||||
var order *model.Order
|
||||
|
||||
// 使用事务
|
||||
err := s.store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 1. 创建订单
|
||||
order = &model.Order{
|
||||
OrderID: generateOrderID(),
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 更新用户订单计数
|
||||
user, err := tx.User.GetByID(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.OrderCount++
|
||||
if err := tx.User.Update(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil // 提交事务
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("创建订单失败",
|
||||
zap.Uint("user_id", req.UserID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return order, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控和调试
|
||||
|
||||
### 查看数据库数据
|
||||
|
||||
```bash
|
||||
# 连接数据库
|
||||
psql -h localhost -p 5432 -U postgres -d junhong_cmp
|
||||
|
||||
# 查询用户
|
||||
SELECT * FROM tb_user;
|
||||
|
||||
# 查询订单
|
||||
SELECT * FROM tb_order WHERE user_id = 1;
|
||||
|
||||
# 查看迁移历史
|
||||
SELECT * FROM schema_migrations;
|
||||
```
|
||||
|
||||
### 查看任务队列状态
|
||||
|
||||
#### 使用 asynqmon(Web UI)
|
||||
|
||||
```bash
|
||||
# 安装 asynqmon
|
||||
go install github.com/hibiken/asynqmon@latest
|
||||
|
||||
# 启动监控面板
|
||||
asynqmon --redis-addr=localhost:6379
|
||||
|
||||
# 访问 http://localhost:8080
|
||||
# 可以查看:
|
||||
# - 队列统计
|
||||
# - 任务状态(pending, active, completed, failed)
|
||||
# - 重试历史
|
||||
# - 失败任务详情
|
||||
```
|
||||
|
||||
#### 使用 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 .
|
||||
|
||||
# 过滤慢查询
|
||||
tail -f logs/app.log | jq 'select(.duration_ms > 100)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
### 单元测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
go test ./...
|
||||
|
||||
# 运行特定包的测试
|
||||
go test ./internal/store/postgres/...
|
||||
|
||||
# 带覆盖率
|
||||
go test -cover ./...
|
||||
|
||||
# 详细输出
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
|
||||
```bash
|
||||
# 运行集成测试(需要 PostgreSQL 和 Redis)
|
||||
go test -v ./tests/integration/...
|
||||
|
||||
# 单独测试数据库功能
|
||||
go test -v ./tests/integration/database_test.go
|
||||
|
||||
# 单独测试任务队列
|
||||
go test -v ./tests/integration/task_test.go
|
||||
```
|
||||
|
||||
### 使用 Testcontainers(推荐)
|
||||
|
||||
集成测试会自动启动 PostgreSQL 和 Redis 容器:
|
||||
|
||||
```go
|
||||
// tests/integration/database_test.go
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
// 自动启动 PostgreSQL 容器
|
||||
// 运行测试
|
||||
// 自动清理容器
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1: 数据库连接失败
|
||||
|
||||
**错误**: `dial tcp 127.0.0.1:5432: connect: connection refused`
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查 PostgreSQL 是否运行
|
||||
docker ps | grep postgres
|
||||
|
||||
# 检查端口占用
|
||||
lsof -i :5432
|
||||
|
||||
# 重启 PostgreSQL
|
||||
docker restart postgres-dev
|
||||
```
|
||||
|
||||
### 问题 2: 迁移失败
|
||||
|
||||
**错误**: `Dirty database version 1. Fix and force version.`
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 强制设置版本
|
||||
migrate -path migrations -database "$DATABASE_URL" force 1
|
||||
|
||||
# 然后重新运行迁移
|
||||
migrate -path migrations -database "$DATABASE_URL" up
|
||||
```
|
||||
|
||||
### 问题 3: Worker 无法连接 Redis
|
||||
|
||||
**错误**: `dial tcp 127.0.0.1:6379: connect: connection refused`
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查 Redis 是否运行
|
||||
docker ps | grep redis
|
||||
|
||||
# 测试连接
|
||||
redis-cli ping
|
||||
|
||||
# 重启 Redis
|
||||
docker restart redis-dev
|
||||
```
|
||||
|
||||
### 问题 4: 任务一直重试
|
||||
|
||||
**原因**: 任务处理函数返回错误
|
||||
|
||||
**解决方案**:
|
||||
1. 检查 Worker 日志:`tail -f logs/app.log | jq 'select(.level == "error")'`
|
||||
2. 使用 asynqmon 查看失败详情
|
||||
3. 检查任务幂等性实现
|
||||
4. 验证 Redis 锁键是否正确设置
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
export CONFIG_ENV=dev
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
### 预发布环境
|
||||
|
||||
```bash
|
||||
export CONFIG_ENV=staging
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```bash
|
||||
export CONFIG_ENV=prod
|
||||
export DB_PASSWORD=secure_password # 使用环境变量
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能调优建议
|
||||
|
||||
### 数据库连接池
|
||||
|
||||
根据服务器资源调整:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
max_open_conns: 25 # 增大以支持更多并发
|
||||
max_idle_conns: 10 # 保持足够的空闲连接
|
||||
conn_max_lifetime: 5m # 定期回收连接
|
||||
```
|
||||
|
||||
### Worker 并发数
|
||||
|
||||
根据任务类型调整:
|
||||
|
||||
```yaml
|
||||
queue:
|
||||
concurrency: 20 # I/O 密集型:CPU 核心数 × 2
|
||||
# concurrency: 8 # CPU 密集型:CPU 核心数
|
||||
```
|
||||
|
||||
### 队列优先级
|
||||
|
||||
根据业务需求调整:
|
||||
|
||||
```yaml
|
||||
queue:
|
||||
queues:
|
||||
critical: 8 # 提高关键任务权重
|
||||
default: 2
|
||||
low: 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
1. **添加业务模型**: 参考 `internal/model/user.go` 创建 SIM 卡、订单等业务实体
|
||||
2. **实现业务逻辑**: 在 Service 层实现具体业务逻辑
|
||||
3. **添加迁移文件**: 使用 `./scripts/migrate.sh create` 添加新表
|
||||
4. **创建异步任务**: 参考 `internal/task/email.go` 创建新的任务处理器
|
||||
5. **编写测试**: 为所有 Service 层业务逻辑编写单元测试
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [GORM 官方文档](https://gorm.io/docs/)
|
||||
- [Asynq 官方文档](https://github.com/hibiken/asynq)
|
||||
- [golang-migrate 文档](https://github.com/golang-migrate/migrate)
|
||||
- [PostgreSQL 文档](https://www.postgresql.org/docs/)
|
||||
- [项目 Constitution](../../.specify/memory/constitution.md)
|
||||
|
||||
---
|
||||
|
||||
## 常见问题(FAQ)
|
||||
|
||||
**Q: 如何添加新的数据库表?**
|
||||
A: 使用 `./scripts/migrate.sh create table_name` 创建迁移文件,编辑 SQL,然后运行 `./scripts/migrate.sh up`。
|
||||
|
||||
**Q: 任务失败后会怎样?**
|
||||
A: 根据配置自动重试(默认 5 次,指数退避)。5 次后仍失败会进入死信队列,可在 asynqmon 中查看。
|
||||
|
||||
**Q: 如何保证任务幂等性?**
|
||||
A: 使用 Redis 锁或数据库唯一约束。参考 `research.md` 中的幂等性设计模式。
|
||||
|
||||
**Q: 如何扩展 Worker?**
|
||||
A: 启动多个 Worker 进程(不同机器或容器),连接同一个 Redis。Asynq 自动负载均衡。
|
||||
|
||||
**Q: 数据库密码如何安全存储?**
|
||||
A: 生产环境使用环境变量:`export DB_PASSWORD=xxx`,配置文件中使用 `${DB_PASSWORD}`。
|
||||
|
||||
**Q: 如何监控任务执行情况?**
|
||||
A: 使用 asynqmon Web UI 或通过 Redis CLI 查看队列状态。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
本指南涵盖了:
|
||||
- ✅ 环境搭建(PostgreSQL、Redis)
|
||||
- ✅ 数据库迁移
|
||||
- ✅ 服务启动(API + Worker)
|
||||
- ✅ CRUD 操作示例
|
||||
- ✅ 异步任务提交和处理
|
||||
- ✅ 事务处理
|
||||
- ✅ 监控和调试
|
||||
- ✅ 故障排查
|
||||
- ✅ 性能调优
|
||||
|
||||
**推荐开发流程**:
|
||||
1. 设计数据模型 → 2. 创建迁移文件 → 3. 实现 Store 层 → 4. 实现 Service 层 → 5. 实现 Handler 层 → 6. 编写测试 → 7. 运行和验证
|
||||
901
specs/002-gorm-postgres-asynq/research.md
Normal file
901
specs/002-gorm-postgres-asynq/research.md
Normal file
@@ -0,0 +1,901 @@
|
||||
# Research: 数据持久化与异步任务处理集成
|
||||
|
||||
**Feature**: 002-gorm-postgres-asynq
|
||||
**Date**: 2025-11-12
|
||||
**Purpose**: 记录技术选型决策、最佳实践和架构考量
|
||||
|
||||
## 概述
|
||||
|
||||
本文档记录了 GORM + PostgreSQL + Asynq 集成的技术研究成果,包括技术选型理由、配置建议、最佳实践和常见陷阱。
|
||||
|
||||
---
|
||||
|
||||
## 1. GORM 与 PostgreSQL 集成
|
||||
|
||||
### 决策:选择 GORM 作为 ORM 框架
|
||||
|
||||
**理由**:
|
||||
- **官方支持**:GORM 是 Go 生态系统中最流行的 ORM,社区活跃,文档完善
|
||||
- **PostgreSQL 原生支持**:提供专门的 PostgreSQL 驱动和方言
|
||||
- **功能完整**:支持复杂查询、关联关系、事务、钩子、软删除等
|
||||
- **性能优秀**:支持预编译语句、批量操作、连接池管理
|
||||
- **符合 Constitution**:项目技术栈要求使用 GORM
|
||||
|
||||
**替代方案**:
|
||||
- **sqlx**:更轻量,但功能不够完整,需要手写更多 SQL
|
||||
- **ent**:Facebook 开发,功能强大,但学习曲线陡峭,且不符合项目技术栈要求
|
||||
|
||||
### GORM 最佳实践
|
||||
|
||||
#### 1.1 连接初始化
|
||||
|
||||
```go
|
||||
// pkg/database/postgres.go
|
||||
import (
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func InitPostgres(cfg *config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
|
||||
)
|
||||
|
||||
// GORM 配置
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent), // 使用 Zap 替代 GORM 日志
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "tb_", // 表名前缀
|
||||
SingularTable: true, // 使用单数表名
|
||||
},
|
||||
PrepareStmt: true, // 启用预编译语句缓存
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取底层 sql.DB 进行连接池配置
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 sql.DB 失败: %w", err)
|
||||
}
|
||||
|
||||
// 连接池配置(参考 Constitution 性能要求)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) // 最大连接数:25
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) // 最大空闲连接:10
|
||||
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) // 连接最大生命周期:5m
|
||||
|
||||
// 验证连接
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("PostgreSQL 连接验证失败: %w", err)
|
||||
}
|
||||
|
||||
log.Info("PostgreSQL 连接成功",
|
||||
zap.String("host", cfg.Host),
|
||||
zap.Int("port", cfg.Port),
|
||||
zap.String("database", cfg.DBName))
|
||||
|
||||
return db, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 连接池配置建议
|
||||
|
||||
| 参数 | 推荐值 | 理由 |
|
||||
|------|--------|------|
|
||||
| MaxOpenConns | 25 | 平衡性能和资源,避免 PostgreSQL 连接耗尽 |
|
||||
| MaxIdleConns | 10 | 保持足够的空闲连接以应对突发流量 |
|
||||
| ConnMaxLifetime | 5m | 定期回收连接,避免长连接问题 |
|
||||
|
||||
**计算公式**:
|
||||
```
|
||||
MaxOpenConns = (可用内存 / 每连接内存) * 安全系数
|
||||
每连接内存 ≈ 10MB(PostgreSQL 典型值)
|
||||
安全系数 = 0.7(为其他进程预留资源)
|
||||
```
|
||||
|
||||
#### 1.3 模型定义规范
|
||||
|
||||
```go
|
||||
// internal/model/user.go
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除
|
||||
|
||||
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:100" json:"email"`
|
||||
Status string `gorm:"not null;size:20;default:'active'" json:"status"`
|
||||
|
||||
// 关联关系示例(如果需要)
|
||||
// Orders []Order `gorm:"foreignKey:UserID" json:"orders,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名(如果不使用默认命名)
|
||||
func (User) TableName() string {
|
||||
return "tb_user" // 遵循 NamingStrategy 的 TablePrefix
|
||||
}
|
||||
```
|
||||
|
||||
**命名规范**:
|
||||
- 字段名使用 PascalCase(Go 约定)
|
||||
- 数据库列名自动转换为 snake_case
|
||||
- 表名使用 `tb_` 前缀(可配置)
|
||||
- JSON tag 使用 snake_case
|
||||
|
||||
#### 1.4 事务处理
|
||||
|
||||
```go
|
||||
// internal/store/postgres/transaction.go
|
||||
func (s *Store) Transaction(ctx context.Context, fn func(*Store) error) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 创建事务内的 Store 实例
|
||||
txStore := &Store{db: tx, logger: s.logger}
|
||||
return fn(txStore)
|
||||
})
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
err := store.Transaction(ctx, func(tx *Store) error {
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err // 自动回滚
|
||||
}
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err // 自动回滚
|
||||
}
|
||||
return nil // 自动提交
|
||||
})
|
||||
```
|
||||
|
||||
**事务最佳实践**:
|
||||
- 使用 `context.Context` 传递超时和取消信号
|
||||
- 事务内操作尽可能快(< 50ms),避免长事务锁表
|
||||
- 事务失败自动回滚,无需手动处理
|
||||
- 避免事务嵌套(GORM 使用 SavePoint 处理嵌套事务)
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库迁移:golang-migrate
|
||||
|
||||
### 决策:使用 golang-migrate 而非 GORM AutoMigrate
|
||||
|
||||
**理由**:
|
||||
- **版本控制**:迁移文件版本化,可追溯数据库 schema 变更历史
|
||||
- **可回滚**:每个迁移包含 up/down 脚本,支持安全回滚
|
||||
- **生产安全**:明确的 SQL 语句,避免 AutoMigrate 的意外变更
|
||||
- **团队协作**:迁移文件可 code review,减少数据库变更风险
|
||||
- **符合 Constitution**:项目规范要求使用外部迁移工具
|
||||
|
||||
**GORM AutoMigrate 的问题**:
|
||||
- 无法回滚
|
||||
- 无法删除列(只能添加和修改)
|
||||
- 不支持复杂的 schema 变更(如重命名列)
|
||||
- 生产环境风险高
|
||||
|
||||
### golang-migrate 使用指南
|
||||
|
||||
#### 2.1 安装
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install golang-migrate
|
||||
|
||||
# Linux
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.15.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/local/bin/
|
||||
|
||||
# Go install
|
||||
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||
```
|
||||
|
||||
#### 2.2 创建迁移文件
|
||||
|
||||
```bash
|
||||
# 创建新迁移
|
||||
migrate create -ext sql -dir migrations -seq init_schema
|
||||
|
||||
# 生成文件:
|
||||
# migrations/000001_init_schema.up.sql
|
||||
# migrations/000001_init_schema.down.sql
|
||||
```
|
||||
|
||||
#### 2.3 迁移文件示例
|
||||
|
||||
```sql
|
||||
-- migrations/000001_init_schema.up.sql
|
||||
CREATE TABLE IF NOT EXISTS tb_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_deleted_at ON tb_user(deleted_at);
|
||||
CREATE INDEX idx_user_status ON tb_user(status);
|
||||
|
||||
-- migrations/000001_init_schema.down.sql
|
||||
DROP TABLE IF EXISTS tb_user;
|
||||
```
|
||||
|
||||
#### 2.4 执行迁移
|
||||
|
||||
```bash
|
||||
# 向上迁移(应用所有未执行的迁移)
|
||||
migrate -path migrations -database "postgresql://user:password@localhost:5432/dbname?sslmode=disable" up
|
||||
|
||||
# 回滚最后一次迁移
|
||||
migrate -path migrations -database "postgresql://user:password@localhost:5432/dbname?sslmode=disable" down 1
|
||||
|
||||
# 迁移到指定版本
|
||||
migrate -path migrations -database "postgresql://user:password@localhost:5432/dbname?sslmode=disable" goto 3
|
||||
|
||||
# 强制设置版本(修复脏迁移)
|
||||
migrate -path migrations -database "postgresql://user:password@localhost:5432/dbname?sslmode=disable" force 2
|
||||
```
|
||||
|
||||
#### 2.5 迁移脚本封装
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/migrate.sh
|
||||
|
||||
set -e
|
||||
|
||||
DB_USER=${DB_USER:-"postgres"}
|
||||
DB_PASSWORD=${DB_PASSWORD:-"password"}
|
||||
DB_HOST=${DB_HOST:-"localhost"}
|
||||
DB_PORT=${DB_PORT:-"5432"}
|
||||
DB_NAME=${DB_NAME:-"junhong_cmp"}
|
||||
|
||||
DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable"
|
||||
|
||||
case "$1" in
|
||||
up)
|
||||
migrate -path migrations -database "$DATABASE_URL" up
|
||||
;;
|
||||
down)
|
||||
migrate -path migrations -database "$DATABASE_URL" down ${2:-1}
|
||||
;;
|
||||
create)
|
||||
migrate create -ext sql -dir migrations -seq "$2"
|
||||
;;
|
||||
version)
|
||||
migrate -path migrations -database "$DATABASE_URL" version
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {up|down [n]|create <name>|version}"
|
||||
exit 1
|
||||
esac
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Asynq 任务队列
|
||||
|
||||
### 决策:选择 Asynq 作为异步任务队列
|
||||
|
||||
**理由**:
|
||||
- **Redis 原生支持**:基于 Redis,无需额外中间件
|
||||
- **功能完整**:支持任务重试、优先级、定时任务、唯一性约束
|
||||
- **高性能**:支持并发处理,可配置 worker 数量
|
||||
- **可观测性**:提供 Web UI 监控面板(asynqmon)
|
||||
- **符合 Constitution**:项目技术栈要求使用 Asynq
|
||||
|
||||
**替代方案**:
|
||||
- **Machinery**:功能类似,但社区活跃度不如 Asynq
|
||||
- **RabbitMQ + amqp091-go**:更重量级,需要额外部署 RabbitMQ
|
||||
- **Kafka**:适合大规模流处理,对本项目过于复杂
|
||||
|
||||
### Asynq 架构设计
|
||||
|
||||
#### 3.1 Client(任务提交)
|
||||
|
||||
```go
|
||||
// pkg/queue/client.go
|
||||
import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *asynq.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewClient(rdb *redis.Client, logger *zap.Logger) *Client {
|
||||
return &Client{
|
||||
client: asynq.NewClient(asynq.RedisClientOpt{Addr: rdb.Options().Addr}),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) EnqueueTask(ctx context.Context, taskType string, payload []byte, opts ...asynq.Option) error {
|
||||
task := asynq.NewTask(taskType, payload, opts...)
|
||||
info, err := c.client.EnqueueContext(ctx, task)
|
||||
if err != nil {
|
||||
c.logger.Error("任务入队失败",
|
||||
zap.String("task_type", taskType),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
c.logger.Info("任务入队成功",
|
||||
zap.String("task_id", info.ID),
|
||||
zap.String("queue", info.Queue))
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Server(任务处理)
|
||||
|
||||
```go
|
||||
// pkg/queue/server.go
|
||||
func NewServer(rdb *redis.Client, cfg *config.QueueConfig, logger *zap.Logger) *asynq.Server {
|
||||
return asynq.NewServer(
|
||||
asynq.RedisClientOpt{Addr: rdb.Options().Addr},
|
||||
asynq.Config{
|
||||
Concurrency: cfg.Concurrency, // 并发数(默认 10)
|
||||
Queues: map[string]int{
|
||||
"critical": 6, // 权重:60%
|
||||
"default": 3, // 权重:30%
|
||||
"low": 1, // 权重:10%
|
||||
},
|
||||
ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {
|
||||
logger.Error("任务执行失败",
|
||||
zap.String("task_type", task.Type()),
|
||||
zap.Error(err))
|
||||
}),
|
||||
Logger: &AsynqLogger{logger: logger}, // 自定义日志适配器
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// cmd/worker/main.go
|
||||
func main() {
|
||||
// ... 初始化配置、日志、Redis
|
||||
|
||||
srv := queue.NewServer(rdb, cfg.Queue, logger)
|
||||
mux := asynq.NewServeMux()
|
||||
|
||||
// 注册任务处理器
|
||||
mux.HandleFunc(constants.TaskTypeEmailSend, task.HandleEmailSend)
|
||||
mux.HandleFunc(constants.TaskTypeDataSync, task.HandleDataSync)
|
||||
|
||||
if err := srv.Run(mux); err != nil {
|
||||
logger.Fatal("Worker 启动失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 任务处理器(Handler)
|
||||
|
||||
```go
|
||||
// internal/task/email.go
|
||||
func HandleEmailSend(ctx context.Context, t *asynq.Task) error {
|
||||
var payload EmailPayload
|
||||
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
return fmt.Errorf("解析任务参数失败: %w", err)
|
||||
}
|
||||
|
||||
// 幂等性检查(使用 Redis 或数据库)
|
||||
key := constants.RedisTaskLockKey(payload.RequestID)
|
||||
if exists, _ := rdb.Exists(ctx, key).Result(); exists > 0 {
|
||||
logger.Info("任务已处理,跳过",
|
||||
zap.String("request_id", payload.RequestID))
|
||||
return nil // 返回 nil 表示成功,避免重试
|
||||
}
|
||||
|
||||
// 执行任务
|
||||
if err := sendEmail(ctx, payload); err != nil {
|
||||
return fmt.Errorf("发送邮件失败: %w", err) // 返回错误触发重试
|
||||
}
|
||||
|
||||
// 标记任务已完成(设置过期时间,避免内存泄漏)
|
||||
rdb.SetEx(ctx, key, "1", 24*time.Hour)
|
||||
|
||||
logger.Info("邮件发送成功",
|
||||
zap.String("to", payload.To),
|
||||
zap.String("request_id", payload.RequestID))
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Asynq 配置建议
|
||||
|
||||
#### 3.4 重试策略
|
||||
|
||||
```go
|
||||
// 默认重试策略:指数退避
|
||||
task := asynq.NewTask(
|
||||
constants.TaskTypeDataSync,
|
||||
payload,
|
||||
asynq.MaxRetry(5), // 最大重试 5 次
|
||||
asynq.Timeout(10*time.Minute), // 任务超时 10 分钟
|
||||
asynq.Queue("default"), // 队列名称
|
||||
asynq.Retention(24*time.Hour), // 保留成功任务 24 小时
|
||||
)
|
||||
|
||||
// 自定义重试延迟(指数退避:1s, 2s, 4s, 8s, 16s)
|
||||
asynq.RetryDelayFunc(func(n int, e error, t *asynq.Task) time.Duration {
|
||||
return time.Duration(1<<uint(n)) * time.Second
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.5 并发配置
|
||||
|
||||
| 场景 | 并发数 | 理由 |
|
||||
|------|--------|------|
|
||||
| CPU 密集型任务 | CPU 核心数 | 避免上下文切换开销 |
|
||||
| I/O 密集型任务 | CPU 核心数 × 2 | 充分利用等待时间 |
|
||||
| 混合任务 | 10(默认) | 平衡性能和资源 |
|
||||
|
||||
**水平扩展**:
|
||||
- 启动多个 Worker 进程(不同机器或容器)
|
||||
- 所有 Worker 连接同一个 Redis
|
||||
- Asynq 自动负载均衡
|
||||
|
||||
#### 3.6 监控与调试
|
||||
|
||||
```bash
|
||||
# 安装 asynqmon(Web UI)
|
||||
go install github.com/hibiken/asynqmon@latest
|
||||
|
||||
# 启动监控面板
|
||||
asynqmon --redis-addr=localhost:6379
|
||||
|
||||
# 访问 http://localhost:8080
|
||||
# 查看任务状态、队列统计、失败任务、重试历史
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 幂等性设计
|
||||
|
||||
### 4.1 为什么需要幂等性?
|
||||
|
||||
**场景**:
|
||||
- 系统重启时,Asynq 自动重新排队未完成的任务
|
||||
- 任务执行失败后自动重试
|
||||
- 网络抖动导致任务重复提交
|
||||
|
||||
**风险**:
|
||||
- 重复发送邮件/短信
|
||||
- 重复扣款/充值
|
||||
- 重复创建订单
|
||||
|
||||
### 4.2 幂等性实现模式
|
||||
|
||||
#### 模式 1:唯一键去重(推荐)
|
||||
|
||||
```go
|
||||
func HandleOrderCreate(ctx context.Context, t *asynq.Task) error {
|
||||
var payload OrderPayload
|
||||
json.Unmarshal(t.Payload(), &payload)
|
||||
|
||||
// 使用业务唯一键(如订单号)去重
|
||||
key := constants.RedisTaskLockKey(payload.OrderID)
|
||||
|
||||
// SetNX:仅当 key 不存在时设置
|
||||
ok, err := rdb.SetNX(ctx, key, "1", 24*time.Hour).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Redis 操作失败: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
logger.Info("订单已创建,跳过",
|
||||
zap.String("order_id", payload.OrderID))
|
||||
return nil // 幂等返回
|
||||
}
|
||||
|
||||
// 执行业务逻辑
|
||||
if err := createOrder(ctx, payload); err != nil {
|
||||
rdb.Del(ctx, key) // 失败时删除锁,允许重试
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 模式 2:数据库唯一约束
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_order (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id VARCHAR(50) NOT NULL UNIQUE, -- 业务唯一键
|
||||
status VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
```go
|
||||
func createOrder(ctx context.Context, payload OrderPayload) error {
|
||||
order := &model.Order{
|
||||
OrderID: payload.OrderID,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
// GORM 插入,如果 order_id 重复则返回错误
|
||||
if err := db.WithContext(ctx).Create(order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
logger.Info("订单已存在,跳过", zap.String("order_id", payload.OrderID))
|
||||
return nil // 幂等返回
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 模式 3:状态机(复杂业务)
|
||||
|
||||
```go
|
||||
func HandleOrderProcess(ctx context.Context, t *asynq.Task) error {
|
||||
var payload OrderPayload
|
||||
json.Unmarshal(t.Payload(), &payload)
|
||||
|
||||
// 加载订单
|
||||
order, err := store.Order.GetByID(ctx, payload.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 状态检查:仅处理特定状态的订单
|
||||
if order.Status != constants.OrderStatusPending {
|
||||
logger.Info("订单状态不匹配,跳过",
|
||||
zap.String("order_id", payload.OrderID),
|
||||
zap.String("current_status", order.Status))
|
||||
return nil // 幂等返回
|
||||
}
|
||||
|
||||
// 状态转换
|
||||
order.Status = constants.OrderStatusProcessing
|
||||
if err := store.Order.Update(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 执行业务逻辑
|
||||
// ...
|
||||
|
||||
order.Status = constants.OrderStatusCompleted
|
||||
return store.Order.Update(ctx, order)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置管理
|
||||
|
||||
### 5.1 数据库配置结构
|
||||
|
||||
```go
|
||||
// pkg/config/config.go
|
||||
type DatabaseConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"` // 明文存储(按需求)
|
||||
DBName string `mapstructure:"dbname"`
|
||||
SSLMode string `mapstructure:"sslmode"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
type QueueConfig struct {
|
||||
Concurrency int `mapstructure:"concurrency"`
|
||||
Queues map[string]int `mapstructure:"queues"`
|
||||
RetryMax int `mapstructure:"retry_max"`
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 配置文件示例
|
||||
|
||||
```yaml
|
||||
# configs/config.yaml
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: postgres
|
||||
password: password # 明文存储(生产环境建议使用环境变量)
|
||||
dbname: junhong_cmp
|
||||
sslmode: disable
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 10
|
||||
conn_max_lifetime: 5m
|
||||
|
||||
queue:
|
||||
concurrency: 10
|
||||
queues:
|
||||
critical: 6
|
||||
default: 3
|
||||
low: 1
|
||||
retry_max: 5
|
||||
timeout: 10m
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能优化建议
|
||||
|
||||
### 6.1 数据库查询优化
|
||||
|
||||
**索引策略**:
|
||||
- 为 WHERE、JOIN、ORDER BY 常用字段添加索引
|
||||
- 复合索引按选择性从高到低排列
|
||||
- 避免过多索引(影响写入性能)
|
||||
|
||||
```sql
|
||||
-- 单列索引
|
||||
CREATE INDEX idx_user_status ON tb_user(status);
|
||||
|
||||
-- 复合索引(状态 + 创建时间)
|
||||
CREATE INDEX idx_user_status_created ON tb_user(status, created_at);
|
||||
|
||||
-- 部分索引(仅索引活跃用户)
|
||||
CREATE INDEX idx_user_active ON tb_user(status) WHERE status = 'active';
|
||||
```
|
||||
|
||||
**批量操作**:
|
||||
```go
|
||||
// 避免 N+1 查询
|
||||
// ❌ 错误
|
||||
for _, orderID := range orderIDs {
|
||||
order, _ := db.Where("id = ?", orderID).First(&Order{}).Error
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
var orders []Order
|
||||
db.Where("id IN ?", orderIDs).Find(&orders)
|
||||
|
||||
// 批量插入
|
||||
db.CreateInBatches(users, 100) // 每批 100 条
|
||||
```
|
||||
|
||||
### 6.2 慢查询监控
|
||||
|
||||
```go
|
||||
// GORM 慢查询日志
|
||||
db.Logger = logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 100 * time.Millisecond, // 慢查询阈值
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 故障处理与恢复
|
||||
|
||||
### 7.1 数据库连接失败
|
||||
|
||||
**重试策略**:
|
||||
```go
|
||||
func InitPostgresWithRetry(cfg *config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
|
||||
maxRetries := 5
|
||||
retryDelay := 2 * time.Second
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
db, err := InitPostgres(cfg, logger)
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
logger.Warn("数据库连接失败,重试中",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Int("max_retries", maxRetries),
|
||||
zap.Error(err))
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // 指数退避
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("数据库连接失败,已重试 %d 次", maxRetries)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 任务队列故障恢复
|
||||
|
||||
**Redis 断线重连**:
|
||||
- Asynq 自动处理 Redis 断线重连
|
||||
- Worker 重启后自动从 Redis 恢复未完成任务
|
||||
|
||||
**脏任务清理**:
|
||||
```bash
|
||||
# 使用 asynqmon 手动清理死信队列
|
||||
# 或编写定时任务自动归档失败任务
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
### 8.1 数据库集成测试
|
||||
|
||||
```go
|
||||
// tests/integration/database_test.go
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
// 使用 testcontainers 启动 PostgreSQL
|
||||
ctx := context.Background()
|
||||
postgresContainer, err := postgres.RunContainer(ctx,
|
||||
testcontainers.WithImage("postgres:14"),
|
||||
postgres.WithDatabase("test_db"),
|
||||
postgres.WithUsername("postgres"),
|
||||
postgres.WithPassword("password"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer postgresContainer.Terminate(ctx)
|
||||
|
||||
// 连接测试数据库
|
||||
connStr, _ := postgresContainer.ConnectionString(ctx)
|
||||
db, _ := gorm.Open(postgres.Open(connStr), &gorm.Config{})
|
||||
|
||||
// 运行迁移
|
||||
db.AutoMigrate(&model.User{})
|
||||
|
||||
// 测试 CRUD
|
||||
user := &model.User{Username: "test", Email: "test@example.com"}
|
||||
assert.NoError(t, db.Create(user).Error)
|
||||
|
||||
var found model.User
|
||||
assert.NoError(t, db.Where("username = ?", "test").First(&found).Error)
|
||||
assert.Equal(t, "test@example.com", found.Email)
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 任务队列测试
|
||||
|
||||
```go
|
||||
// tests/integration/task_test.go
|
||||
func TestEmailTask(t *testing.T) {
|
||||
// 启动内存模式的 Asynq(测试用)
|
||||
srv := asynq.NewServer(
|
||||
asynq.RedisClientOpt{Addr: "localhost:6379"},
|
||||
asynq.Config{Concurrency: 1},
|
||||
)
|
||||
|
||||
mux := asynq.NewServeMux()
|
||||
mux.HandleFunc(constants.TaskTypeEmailSend, task.HandleEmailSend)
|
||||
|
||||
// 提交任务
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{Addr: "localhost:6379"})
|
||||
payload, _ := json.Marshal(EmailPayload{To: "test@example.com"})
|
||||
client.Enqueue(asynq.NewTask(constants.TaskTypeEmailSend, payload))
|
||||
|
||||
// 启动 worker 处理
|
||||
go srv.Run(mux)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// 验证任务已处理
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全考虑
|
||||
|
||||
### 9.1 SQL 注入防护
|
||||
|
||||
**✅ GORM 自动防护**:
|
||||
```go
|
||||
// GORM 使用预编译语句,自动转义参数
|
||||
db.Where("username = ?", userInput).First(&user)
|
||||
```
|
||||
|
||||
**❌ 避免原始 SQL**:
|
||||
```go
|
||||
// 危险:SQL 注入风险
|
||||
db.Raw("SELECT * FROM users WHERE username = '" + userInput + "'").Scan(&user)
|
||||
|
||||
// 安全:使用参数化查询
|
||||
db.Raw("SELECT * FROM users WHERE username = ?", userInput).Scan(&user)
|
||||
```
|
||||
|
||||
### 9.2 密码存储
|
||||
|
||||
```yaml
|
||||
# configs/config.yaml
|
||||
database:
|
||||
password: ${DB_PASSWORD} # 从环境变量读取(生产环境推荐)
|
||||
```
|
||||
|
||||
```bash
|
||||
# .env 文件(不提交到 Git)
|
||||
export DB_PASSWORD=secret_password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 部署与运维
|
||||
|
||||
### 10.1 健康检查
|
||||
|
||||
```go
|
||||
// internal/handler/health.go
|
||||
func (h *Handler) HealthCheck(c *fiber.Ctx) error {
|
||||
health := map[string]string{
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
// 检查 PostgreSQL
|
||||
sqlDB, _ := h.db.DB()
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
health["postgres"] = "down"
|
||||
health["status"] = "degraded"
|
||||
} else {
|
||||
health["postgres"] = "up"
|
||||
}
|
||||
|
||||
// 检查 Redis(任务队列)
|
||||
if err := h.rdb.Ping(c.Context()).Err(); err != nil {
|
||||
health["redis"] = "down"
|
||||
health["status"] = "degraded"
|
||||
} else {
|
||||
health["redis"] = "up"
|
||||
}
|
||||
|
||||
statusCode := fiber.StatusOK
|
||||
if health["status"] != "ok" {
|
||||
statusCode = fiber.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
return c.Status(statusCode).JSON(health)
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 优雅关闭
|
||||
|
||||
```go
|
||||
// cmd/worker/main.go
|
||||
func main() {
|
||||
// ... 初始化
|
||||
|
||||
srv := queue.NewServer(rdb, cfg.Queue, logger)
|
||||
|
||||
// 处理信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-quit
|
||||
logger.Info("收到关闭信号,开始优雅关闭")
|
||||
|
||||
// 停止接收新任务,等待现有任务完成(最多 30 秒)
|
||||
srv.Shutdown()
|
||||
}()
|
||||
|
||||
// 启动 Worker
|
||||
if err := srv.Run(mux); err != nil {
|
||||
logger.Fatal("Worker 运行失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 技术选型 | 关键决策 | 核心理由 |
|
||||
|---------|----------|----------|
|
||||
| **GORM** | 使用 GORM 而非 sqlx | 功能完整,符合项目技术栈 |
|
||||
| **golang-migrate** | 使用外部迁移工具而非 AutoMigrate | 版本控制,可回滚,生产安全 |
|
||||
| **Asynq** | 使用 Asynq 而非 Machinery | Redis 原生,功能完整,监控友好 |
|
||||
| **连接池** | MaxOpenConns=25, MaxIdleConns=10 | 平衡性能和资源消耗 |
|
||||
| **重试策略** | 最大 5 次,指数退避 | 避免雪崩,给系统恢复时间 |
|
||||
| **幂等性** | Redis 去重 + 数据库唯一约束 | 防止重复执行,确保数据一致性 |
|
||||
|
||||
**下一步**:Phase 1 设计与契约生成(data-model.md、contracts/、quickstart.md)
|
||||
194
specs/002-gorm-postgres-asynq/spec.md
Normal file
194
specs/002-gorm-postgres-asynq/spec.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Feature Specification: 数据持久化与异步任务处理集成
|
||||
|
||||
**Feature Branch**: `002-gorm-postgres-asynq`
|
||||
**Created**: 2025-11-12
|
||||
**Status**: Draft
|
||||
**Input**: User description: "集成gorm、Postgresql数据库和asynq任务队列系统"
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2025-11-12
|
||||
|
||||
- Q: PostgreSQL连接应如何处理凭证管理和传输安全? → A: 凭证直接写在配置文件(config.yaml)中,明文存储
|
||||
- Q: 任务失败后应该如何重试? → A: 最大重试5次,指数退避策略(1s、2s、4s、8s、16s)
|
||||
- Q: 数据库表结构的创建和变更应该如何执行? → A: 完全不使用GORM迁移,使用外部迁移工具(如golang-migrate)管理SQL迁移文件
|
||||
- Q: Worker进程应该如何配置并发任务处理? → A: 支持多个worker进程,每个进程可配置并发数(默认10);不同任务类型可配置独立的队列优先级
|
||||
- Q: 系统重启时,正在执行中或排队中的任务应该如何处理? → A: 所有未完成任务(包括执行中)自动重新排队,重启后继续执行;任务处理逻辑需保证幂等性
|
||||
|
||||
### Session 2025-11-13
|
||||
|
||||
- Q: 数据库慢查询(>100ms)和任务执行状态应该如何进行监控和指标收集? → A: 仅记录日志文件,不收集指标
|
||||
- Q: 当数据库连接池耗尽时,新的数据库请求应该如何处理? → A: 请求排队等待直到获得连接(带超时,如5秒)
|
||||
- Q: 当数据库执行慢查询时,系统应该如何避免请求超时? → A: 使用context超时控制(如3秒),超时后取消查询
|
||||
- Q: 当PostgreSQL主从切换时,系统应该如何感知并重新连接? → A: 依赖GORM的自动重连机制,连接失败时重试
|
||||
- Q: 当并发事务产生死锁时,系统应该如何检测和恢复? → A: 依赖PostgreSQL自动检测,捕获死锁错误并重试(最多3次)
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - 可靠的数据存储与检索 (Priority: P1)
|
||||
|
||||
作为系统,需要能够可靠地持久化存储业务数据(如用户信息、业务记录等),并支持高效的数据查询和修改操作,确保数据的一致性和完整性。
|
||||
|
||||
**Why this priority**: 这是系统的核心基础能力,没有数据持久化就无法提供任何有意义的业务功能。所有后续功能都依赖于数据存储能力。
|
||||
|
||||
**Independent Test**: 可以通过创建、读取、更新、删除(CRUD)测试数据来独立验证。测试应包括基本的数据操作、事务提交、数据一致性验证等场景。
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 系统接收到新的业务数据, **When** 执行数据保存操作, **Then** 数据应成功持久化到数据库,并可以被后续查询检索到
|
||||
2. **Given** 需要修改已存在的数据, **When** 执行更新操作, **Then** 数据应被正确更新,且旧数据被新数据替换
|
||||
3. **Given** 需要删除数据, **When** 执行删除操作, **Then** 数据应从数据库中移除,后续查询不应返回该数据
|
||||
4. **Given** 多个数据操作需要原子性执行, **When** 在事务中执行这些操作, **Then** 要么全部成功提交,要么全部回滚,保证数据一致性
|
||||
5. **Given** 执行数据查询, **When** 查询条件匹配多条记录, **Then** 系统应返回所有匹配的记录,支持分页和排序
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - 异步任务处理能力 (Priority: P2)
|
||||
|
||||
作为系统,需要能够将耗时的操作(如发送邮件、生成报表、数据同步等)放到后台异步执行,避免阻塞用户请求,提升用户体验和系统响应速度。
|
||||
|
||||
**Why this priority**: 许多业务操作需要较长时间完成,如果在用户请求中同步执行会导致超时和糟糕的用户体验。异步任务处理是提升系统性能和用户体验的关键。
|
||||
|
||||
**Independent Test**: 可以通过提交一个耗时任务(如模拟发送邮件),验证任务被成功加入队列,然后在后台完成执行,用户请求立即返回而不等待任务完成。
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 系统需要执行一个耗时操作, **When** 将任务提交到任务队列, **Then** 任务应被成功加入队列,用户请求立即返回,不阻塞等待
|
||||
2. **Given** 任务队列中有待处理的任务, **When** 后台工作进程运行, **Then** 任务应按顺序被取出并执行
|
||||
3. **Given** 任务执行过程中发生错误, **When** 任务失败, **Then** 系统应记录错误信息,并根据配置进行重试
|
||||
4. **Given** 任务需要定时执行, **When** 到达指定时间, **Then** 任务应自动触发执行
|
||||
5. **Given** 需要查看任务执行状态, **When** 查询任务信息, **Then** 应能获取任务的当前状态(等待、执行中、成功、失败)和执行历史
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - 数据库连接管理与监控 (Priority: P3)
|
||||
|
||||
作为系统管理员,需要能够监控数据库连接状态、查询性能和任务队列健康度,及时发现和解决潜在问题,确保系统稳定运行。
|
||||
|
||||
**Why this priority**: 虽然不是核心业务功能,但对系统的稳定性和可维护性至关重要。良好的监控能力可以预防故障和提升运维效率。
|
||||
|
||||
**Independent Test**: 可以通过健康检查接口验证数据库连接状态和任务队列状态,模拟连接失败场景验证系统的容错能力。
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 系统启动时, **When** 初始化数据库连接池, **Then** 应成功建立连接,并验证数据库可访问性
|
||||
2. **Given** 数据库连接出现问题, **When** 检测到连接失败, **Then** 系统应记录错误日志,并尝试重新建立连接
|
||||
3. **Given** 需要监控系统健康状态, **When** 调用健康检查接口, **Then** 应返回数据库和任务队列的当前状态(正常/异常)
|
||||
4. **Given** 系统关闭时, **When** 执行清理操作, **Then** 应优雅地关闭数据库连接和任务队列,等待正在执行的任务完成
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- 当数据库连接池耗尽时,新的数据库请求会排队等待可用连接,等待超时时间为5秒。超时后返回503 Service Unavailable错误,错误消息提示"数据库连接池繁忙,请稍后重试"
|
||||
- 当任务队列积压过多任务(超过 10,000 个待处理任务或 Redis 内存使用超过 80%)时,系统应触发告警,并考虑暂停低优先级任务提交或扩展 Worker 进程数量
|
||||
- 当数据库执行慢查询时,系统使用context.WithTimeout为每个数据库操作设置超时时间(默认3秒)。超时后自动取消查询并返回504 Gateway Timeout错误,错误消息提示"数据库查询超时,请优化查询条件或联系管理员"
|
||||
- 当任务重复执行5次后仍然失败时,任务应被标记为"最终失败"状态,记录完整错误历史,并可选择发送告警通知或进入死信队列等待人工处理
|
||||
- 当PostgreSQL主从切换时,系统依赖GORM的自动重连机制。当检测到连接失败或不可用时,GORM会自动尝试重新建立连接。失败的查询会返回数据库连接错误,应用层应在合理范围内进行重试(建议重试1-3次,每次间隔100ms)
|
||||
- 当并发事务产生死锁时,PostgreSQL会自动检测并中止其中一个事务(返回SQLSTATE 40P01错误)。应用层捕获死锁错误后,应自动重试该事务(建议最多重试3次,每次间隔50-100ms随机延迟)。超过重试次数后,返回409 Conflict错误,提示"数据库操作冲突,请稍后重试"
|
||||
- 当系统重启时,所有未完成的任务(包括排队中和执行中的任务)会利用Asynq的Redis持久化机制自动重新排队,重启后Worker进程会继续处理这些任务。所有任务处理逻辑必须设计为幂等操作,确保任务重复执行不会产生副作用或数据不一致
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: 系统必须能够建立和管理与PostgreSQL数据库的连接池,支持配置最大连接数、空闲连接数等参数。数据库连接配置(包括主机地址、端口、用户名、密码、数据库名)存储在配置文件(config.yaml)中,明文形式保存。当连接池耗尽时,新请求排队等待可用连接(默认超时5秒),超时后返回503错误。系统依赖GORM的自动重连机制处理数据库连接失败或主从切换场景
|
||||
- **FR-002**: 系统必须支持标准的CRUD操作(创建、读取、更新、删除),并提供统一的数据访问接口。接口应包括但不限于: Create(创建记录)、GetByID(按ID查询)、Update(更新记录)、Delete(软删除)、List(分页列表查询)等基础方法,所有 Store 层接口遵循一致的命名和参数约定(详见 data-model.md)
|
||||
- **FR-003**: 系统必须支持数据库事务,包括事务的开始、提交、回滚操作,确保数据一致性。当发生死锁时(SQLSTATE 40P01),系统应捕获错误并自动重试事务(最多3次,每次间隔50-100ms随机延迟),超过重试次数后返回409错误
|
||||
- **FR-004**: 系统必须支持数据库迁移,使用外部迁移工具(如golang-migrate)通过版本化的SQL迁移文件管理表结构的创建和变更,不使用GORM AutoMigrate功能。迁移文件应包含up/down脚本以支持正向迁移和回滚
|
||||
- **FR-005**: 系统必须提供查询构建能力,支持条件查询、分页、排序、关联查询等常见操作。所有数据库查询必须使用context.WithTimeout设置超时时间(默认3秒),超时后自动取消查询并返回504错误
|
||||
- **FR-006**: 系统必须能够将任务提交到异步任务队列,任务应包含任务类型、参数、优先级等信息
|
||||
- **FR-007**: 系统必须提供后台工作进程,从任务队列中获取任务并执行。支持启动多个worker进程实例,每个进程可独立配置并发处理数(默认10个并发goroutine)。不同任务类型可配置到不同的队列,并设置队列优先级,实现资源隔离和灵活扩展。Worker 进程异常退出时,Asynq 会自动将执行中的任务标记为失败并重新排队;建议使用进程管理工具(如 systemd, supervisord)实现 Worker 自动重启
|
||||
- **FR-008**: 系统必须支持任务重试机制,当任务执行失败时能够按配置的策略自动重试。默认最大重试5次,采用指数退避策略(重试间隔为1s、2s、4s、8s、16s),每个任务类型可独立配置重试参数
|
||||
- **FR-009**: 系统必须支持任务优先级,高优先级任务应优先被处理
|
||||
- **FR-010**: 系统必须能够记录任务执行历史和状态,包括开始时间、结束时间、执行结果、错误信息等。任务执行状态通过日志文件记录,不使用外部指标收集系统
|
||||
- **FR-011**: 系统必须提供健康检查接口,能够验证数据库连接和任务队列的可用性
|
||||
- **FR-012**: 系统必须支持定时任务,能够按照cron表达式或固定间隔调度任务执行
|
||||
- **FR-013**: 系统必须记录慢查询日志,当数据库查询超过阈值(100ms)时记录详细信息用于优化。日志应包含 SQL 语句、执行时间、参数和上下文信息。监控采用日志文件方式,不使用 Prometheus 或其他指标收集系统
|
||||
- **FR-014**: 系统必须支持配置化的数据库和任务队列参数,如连接字符串、最大重试次数、任务超时时间等
|
||||
- **FR-015**: 系统必须在关闭时优雅地清理资源,关闭数据库连接并等待正在执行的任务完成
|
||||
- **FR-016**: 系统必须支持任务持久化和故障恢复。利用Asynq基于Redis的持久化机制,确保系统重启或崩溃时未完成的任务不会丢失。所有任务处理函数必须设计为幂等操作,支持任务重新执行而不产生副作用
|
||||
|
||||
### Technical Requirements (Constitution-Driven)
|
||||
|
||||
**Tech Stack Compliance**:
|
||||
- [x] 所有数据库操作使用GORM (不直接使用 `database/sql`)
|
||||
- [x] 数据库迁移使用golang-migrate (不使用GORM AutoMigrate)
|
||||
- [x] 所有异步任务使用Asynq
|
||||
- [x] 所有HTTP操作使用Fiber框架 (不使用 `net/http`)
|
||||
- [x] 所有JSON操作使用sonic (不使用 `encoding/json`)
|
||||
- [x] 所有日志使用Zap + Lumberjack.v2
|
||||
- [x] 所有配置使用Viper
|
||||
- [x] 使用Go官方工具链: `go fmt`, `go vet`, `golangci-lint`
|
||||
|
||||
**Architecture Requirements**:
|
||||
- [x] 实现遵循 Handler → Service → Store → Model 分层架构
|
||||
- [x] 依赖通过结构体字段注入(不使用构造函数模式)
|
||||
- [x] 统一错误码定义在 `pkg/errors/`
|
||||
- [x] 统一API响应通过 `pkg/response/`
|
||||
- [x] 所有常量定义在 `pkg/constants/` (不使用魔法数字/字符串)
|
||||
- [x] **不允许硬编码值: 3次及以上相同字面量必须定义为常量**
|
||||
- [x] **已定义的常量必须使用(不允许重复硬编码)**
|
||||
- [x] **代码注释优先使用中文(实现注释用中文)**
|
||||
- [x] **日志消息使用中文(logger.Info/Warn/Error/Debug用中文)**
|
||||
- [x] **错误消息支持中文(面向用户的错误有中文文本)**
|
||||
- [x] 所有Redis键通过 `pkg/constants/` 键生成函数管理
|
||||
- [x] 包结构扁平化,按功能组织(不按层级)
|
||||
|
||||
**Go Idiomatic Design Requirements**:
|
||||
- [x] 不使用Java风格模式: 无getter/setter方法、无I-前缀接口、无Impl-后缀
|
||||
- [x] 接口应小型化(1-3个方法),在使用处定义
|
||||
- [x] 错误处理显式化(返回错误,不使用panic)
|
||||
- [x] 使用组合(结构体嵌入)而非继承
|
||||
- [x] 使用goroutines和channels处理并发
|
||||
- [x] 命名遵循Go约定: `UserID` 不是 `userId`, `HTTPServer` 不是 `HttpServer`
|
||||
- [x] 不使用匈牙利命名法或类型前缀
|
||||
- [x] 代码结构简单直接
|
||||
|
||||
**API Design Requirements**:
|
||||
- [x] 所有API遵循RESTful原则
|
||||
- [x] 所有响应使用统一JSON格式,包含code/message/data/timestamp
|
||||
- [x] 所有错误消息包含错误码和双语描述
|
||||
- [x] 所有分页使用标准参数(page, page_size, total)
|
||||
- [x] 所有时间字段使用ISO 8601格式(RFC3339)
|
||||
- [x] 所有货币金额使用整数(分)
|
||||
|
||||
**Performance Requirements**:
|
||||
- [x] API响应时间(P95) < 200ms
|
||||
- [x] 数据库查询 < 50ms
|
||||
- [x] 批量操作使用bulk查询
|
||||
- [x] 列表查询实现分页(默认20条,最大100条)
|
||||
- [x] 非实时操作委托给异步任务
|
||||
- [x] 使用 `context.Context` 进行超时和取消控制
|
||||
|
||||
**Testing Requirements**:
|
||||
- [x] Service层业务逻辑必须有单元测试
|
||||
- [x] 所有API端点必须有集成测试
|
||||
- [x] 所有异步任务处理函数必须有幂等性测试,验证重复执行的正确性
|
||||
- [x] 测试使用Go标准testing框架,文件名为 `*_test.go`
|
||||
- [x] 多测试用例使用表驱动测试
|
||||
- [x] 测试相互独立,使用mocks/testcontainers
|
||||
- [x] 目标覆盖率: 总体70%+, 核心业务逻辑90%+
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **DatabaseConnection**: 代表与PostgreSQL数据库的连接,包含连接池配置、连接状态、健康检查等属性
|
||||
- **DataModel**: 代表业务数据模型,通过ORM映射到数据库表,包含数据验证规则和关联关系
|
||||
- **Task**: 代表异步任务,包含任务类型、任务参数、优先级、重试次数、执行状态等属性
|
||||
- **TaskQueue**: 代表任务队列,管理任务的提交、调度、执行和状态跟踪
|
||||
- **Worker**: 代表后台工作进程,从任务队列中获取任务并执行。每个Worker进程支持可配置的并发数(通过goroutine池实现),可以部署多个Worker进程实例实现水平扩展。不同Worker可订阅不同的任务队列,实现任务类型的资源隔离
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 数据库基本CRUD操作响应时间(P95)应小于50毫秒
|
||||
- **SC-002**: 系统应支持至少1000个并发数据库连接而不出现连接池耗尽
|
||||
- **SC-003**: 任务队列应能够处理每秒至少100个任务的提交速率
|
||||
- **SC-004**: 异步任务从提交到开始执行的延迟(空闲情况下)应小于100毫秒
|
||||
- **SC-005**: 数据持久化的可靠性应达到99.99%,即每10000次操作中失败不超过1次
|
||||
- **SC-006**: 失败任务的自动重试成功率应达到90%以上
|
||||
- **SC-007**: 系统启动时应在10秒内完成数据库连接和任务队列初始化
|
||||
- **SC-008**: 数据库查询慢查询(超过100ms)的占比应小于1%
|
||||
- **SC-009**: 系统关闭时应在30秒内优雅完成所有资源清理,不丢失正在执行的任务
|
||||
- **SC-010**: 健康检查接口应在1秒内返回系统健康状态
|
||||
393
specs/002-gorm-postgres-asynq/tasks.md
Normal file
393
specs/002-gorm-postgres-asynq/tasks.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Tasks: 数据持久化与异步任务处理集成
|
||||
|
||||
**Feature**: 002-gorm-postgres-asynq
|
||||
**Input**: Design documents from `/specs/002-gorm-postgres-asynq/`
|
||||
**Prerequisites**: plan.md, spec.md, data-model.md, contracts/api.yaml, research.md, quickstart.md
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1: 数据存储与检索, US2: 异步任务处理, US3: 连接管理与监控) to enable independent implementation and testing.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure (project already exists, validate/enhance)
|
||||
|
||||
- [ ] T001 Validate project structure matches plan.md (internal/, pkg/, cmd/, configs/, migrations/, tests/)
|
||||
- [ ] T002 Validate Go dependencies for Fiber + GORM + Asynq + Viper + Zap + golang-migrate
|
||||
- [ ] T003 [P] Validate unified error codes in pkg/errors/codes.go and pkg/errors/errors.go
|
||||
- [ ] T004 [P] Validate unified API response in pkg/response/response.go
|
||||
- [ ] T005 [P] Add database configuration constants in pkg/constants/constants.go (DefaultMaxOpenConns=25, DefaultMaxIdleConns=10, etc.)
|
||||
- [ ] T006 [P] Add task queue constants in pkg/constants/constants.go (TaskTypeEmailSend, TaskTypeDataSync, QueueCritical, QueueDefault, etc.)
|
||||
- [ ] T007 [P] Add user/order status constants in pkg/constants/constants.go (UserStatusActive, OrderStatusPending, etc.)
|
||||
- [ ] T008 [P] Add Redis key generation functions in pkg/constants/redis.go (RedisTaskLockKey, RedisTaskStatusKey)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [ ] T009 Implement PostgreSQL connection initialization in pkg/database/postgres.go (GORM + connection pool)
|
||||
- [ ] T010 Validate Redis connection initialization in pkg/database/redis.go (connection pool: PoolSize=10, MinIdleConns=5)
|
||||
- [ ] T011 [P] Add DatabaseConfig to pkg/config/config.go (Host, Port, User, Password, MaxOpenConns, MaxIdleConns, ConnMaxLifetime)
|
||||
- [ ] T012 [P] Add QueueConfig to pkg/config/config.go (Concurrency, Queues, RetryMax, Timeout)
|
||||
- [ ] T013 [P] Update config.yaml files with database and queue configurations (config.dev.yaml, config.staging.yaml, config.prod.yaml)
|
||||
- [ ] T014 Implement Asynq client initialization in pkg/queue/client.go (EnqueueTask with logging)
|
||||
- [ ] T015 Implement Asynq server initialization in pkg/queue/server.go (with queue priorities and error handler)
|
||||
- [ ] T016 Create base Store structure in internal/store/store.go with transaction support
|
||||
- [ ] T017 Initialize postgres store in internal/store/postgres/store.go (embed UserStore, OrderStore)
|
||||
- [ ] T018 Validate migrations directory structure (migrations/000001_init_schema.up.sql and .down.sql exist)
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - 可靠的数据存储与检索 (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: 实现可靠的数据持久化存储和高效的 CRUD 操作,确保数据一致性和完整性
|
||||
|
||||
**Independent Test**: 通过创建、读取、更新、删除用户和订单数据验证。包括基本 CRUD、事务提交、数据一致性验证等场景。
|
||||
|
||||
### Tests for User Story 1 (REQUIRED per Constitution)
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T019 [P] [US1] Unit tests for User Store layer in tests/unit/store_test.go (Create, GetByID, Update, Delete, List)
|
||||
- [ ] T020 [P] [US1] Unit tests for Order Store layer in tests/unit/store_test.go (Create, GetByID, Update, Delete, ListByUserID)
|
||||
- [ ] T021 [P] [US1] Unit tests for User Service layer in tests/unit/service_test.go (business logic validation)
|
||||
- [ ] T022 [P] [US1] Integration tests for User API endpoints in tests/integration/database_test.go (POST/GET/PUT/DELETE /users)
|
||||
- [ ] T023 [P] [US1] Transaction rollback tests in tests/unit/store_test.go (verify atomic operations)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
**Models & DTOs**:
|
||||
|
||||
- [ ] T024 [P] [US1] Validate BaseModel in internal/model/base.go (ID, CreatedAt, UpdatedAt, DeletedAt)
|
||||
- [ ] T025 [P] [US1] Validate User model in internal/model/user.go with GORM tags (Username, Email, Password, Status)
|
||||
- [ ] T026 [P] [US1] Validate Order model in internal/model/order.go with GORM tags (OrderID, UserID, Amount, Status)
|
||||
- [ ] T027 [P] [US1] Validate User DTOs in internal/model/user_dto.go (CreateUserRequest, UpdateUserRequest, UserResponse, ListUsersResponse)
|
||||
- [ ] T028 [P] [US1] Create Order DTOs in internal/model/order_dto.go (CreateOrderRequest, UpdateOrderRequest, OrderResponse, ListOrdersResponse)
|
||||
|
||||
**Store Layer (Data Access)**:
|
||||
|
||||
- [ ] T029 [US1] Implement UserStore in internal/store/postgres/user_store.go (Create, GetByID, Update, Delete, List with pagination)
|
||||
- [ ] T030 [US1] Implement OrderStore in internal/store/postgres/order_store.go (Create, GetByID, Update, Delete, ListByUserID)
|
||||
- [ ] T031 [US1] Add context timeout handling (3s default) and slow query logging (>100ms) in Store methods
|
||||
|
||||
**Service Layer (Business Logic)**:
|
||||
|
||||
- [ ] T032 [US1] Implement UserService in internal/service/user/service.go (CreateUser, GetUserByID, UpdateUser, DeleteUser, ListUsers)
|
||||
- [ ] T033 [US1] Implement OrderService in internal/service/order/service.go (CreateOrder, GetOrderByID, UpdateOrder, DeleteOrder, ListOrdersByUserID)
|
||||
- [ ] T034 [US1] Add password hashing (bcrypt) in UserService.CreateUser
|
||||
- [ ] T035 [US1] Add validation logic in Service layer using Validator
|
||||
- [ ] T036 [US1] Implement transaction example in OrderService (CreateOrderWithUser)
|
||||
|
||||
**Handler Layer (HTTP Endpoints)**:
|
||||
|
||||
- [ ] T037 [US1] Validate/enhance User Handler in internal/handler/user.go (Create, GetByID, Update, Delete, List endpoints)
|
||||
- [ ] T038 [US1] Create Order Handler in internal/handler/order.go (Create, GetByID, Update, Delete, List endpoints)
|
||||
- [ ] T039 [US1] Add request validation using Validator in handlers
|
||||
- [ ] T040 [US1] Add unified error handling using pkg/errors/ and pkg/response/ in handlers
|
||||
- [ ] T041 [US1] Add structured logging with Zap in handlers (log user_id, order_id, operation, duration)
|
||||
- [ ] T042 [US1] Register User routes in cmd/api/main.go (POST/GET/PUT/DELETE /api/v1/users, /api/v1/users/:id)
|
||||
- [ ] T043 [US1] Register Order routes in cmd/api/main.go (POST/GET/PUT/DELETE /api/v1/orders, /api/v1/orders/:id)
|
||||
|
||||
**Database Migrations**:
|
||||
|
||||
- [ ] T044 [US1] Validate migration 000001_init_schema.up.sql (tb_user and tb_order tables with indexes)
|
||||
- [ ] T045 [US1] Validate migration 000001_init_schema.down.sql (DROP tables)
|
||||
- [ ] T046 [US1] Test migration up/down with scripts/migrate.sh
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - 异步任务处理能力 (Priority: P2)
|
||||
|
||||
**Goal**: 实现耗时操作的后台异步执行,避免阻塞用户请求,提升系统响应速度
|
||||
|
||||
**Independent Test**: 提交耗时任务(如发送邮件),验证任务被成功加入队列,用户请求立即返回,后台 Worker 完成任务执行。
|
||||
|
||||
### Tests for User Story 2 (REQUIRED per Constitution)
|
||||
|
||||
- [ ] T047 [P] [US2] Unit tests for Email task handler in tests/unit/task_handler_test.go (HandleEmailSend idempotency)
|
||||
- [ ] T048 [P] [US2] Unit tests for Sync task handler in tests/unit/task_handler_test.go (HandleDataSync idempotency)
|
||||
- [ ] T049 [P] [US2] Integration tests for task submission in tests/integration/task_test.go (EnqueueEmailTask, EnqueueSyncTask)
|
||||
- [ ] T050 [P] [US2] Integration tests for task queue in tests/integration/task_test.go (verify Worker processes tasks)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
**Task Payloads**:
|
||||
|
||||
- [ ] T051 [P] [US2] Validate EmailPayload in internal/task/email.go (RequestID, To, Subject, Body, CC, Attachments)
|
||||
- [ ] T052 [P] [US2] Validate DataSyncPayload in internal/task/sync.go (RequestID, SyncType, StartDate, EndDate, BatchSize)
|
||||
- [ ] T053 [P] [US2] Create SIMStatusSyncPayload in internal/task/sim.go (RequestID, ICCIDs, ForceSync)
|
||||
|
||||
**Task Handlers (Worker)**:
|
||||
|
||||
- [ ] T054 [US2] Implement HandleEmailSend in internal/task/email.go (with Redis idempotency lock and retry)
|
||||
- [ ] T055 [US2] Implement HandleDataSync in internal/task/sync.go (with idempotency and batch processing)
|
||||
- [ ] T056 [US2] Implement HandleSIMStatusSync in internal/task/sim.go (with idempotency)
|
||||
- [ ] T057 [US2] Add structured logging in task handlers (task_id, task_type, request_id, duration)
|
||||
- [ ] T058 [US2] Add error handling and retry logic in task handlers (max 5 retries, exponential backoff)
|
||||
|
||||
**Service Integration**:
|
||||
|
||||
- [ ] T059 [US2] Implement EmailService in internal/service/email/service.go (SendWelcomeEmail, EnqueueEmailTask)
|
||||
- [ ] T060 [US2] Implement SyncService in internal/service/sync/service.go (EnqueueDataSyncTask, EnqueueSIMStatusSyncTask)
|
||||
- [ ] T061 [US2] Add Queue Client dependency injection in Service constructors
|
||||
|
||||
**Handler Layer (Task Submission)**:
|
||||
|
||||
- [ ] T062 [US2] Validate/enhance Task Handler in internal/handler/task.go (SubmitEmailTask, SubmitSyncTask endpoints)
|
||||
- [ ] T063 [US2] Add request validation for task payloads in handler
|
||||
- [ ] T064 [US2] Add priority queue selection logic (critical/default/low) in handler
|
||||
- [ ] T065 [US2] Register task routes in cmd/api/main.go (POST /api/v1/tasks/email, POST /api/v1/tasks/sync)
|
||||
|
||||
**Worker Process**:
|
||||
|
||||
- [ ] T066 [US2] Validate Worker main in cmd/worker/main.go (initialize Server, register handlers, graceful shutdown)
|
||||
- [ ] T067 [US2] Register task handlers in Worker (HandleEmailSend, HandleDataSync, HandleSIMStatusSync)
|
||||
- [ ] T068 [US2] Add signal handling for graceful shutdown in Worker (SIGINT, SIGTERM)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - 数据库连接管理与监控 (Priority: P3)
|
||||
|
||||
**Goal**: 监控数据库连接状态、查询性能和任务队列健康度,确保系统稳定运行
|
||||
|
||||
**Independent Test**: 通过健康检查接口验证数据库和 Redis 连接状态,模拟连接失败场景验证容错能力。
|
||||
|
||||
### Tests for User Story 3 (REQUIRED per Constitution)
|
||||
|
||||
- [ ] T069 [P] [US3] Integration tests for health check in tests/integration/health_test.go (GET /health returns 200 when healthy)
|
||||
- [ ] T070 [P] [US3] Integration tests for degraded state in tests/integration/health_test.go (503 when database down)
|
||||
- [ ] T071 [P] [US3] Unit tests for graceful shutdown in tests/unit/shutdown_test.go (verify connections closed)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
**Health Check**:
|
||||
|
||||
- [ ] T072 [US3] Validate/enhance Health Handler in internal/handler/health.go (check PostgreSQL and Redis status)
|
||||
- [ ] T073 [US3] Add database Ping check with timeout in Health Handler
|
||||
- [ ] T074 [US3] Add Redis Ping check with timeout in Health Handler
|
||||
- [ ] T075 [US3] Return appropriate status codes (200 ok, 503 degraded/unavailable)
|
||||
- [ ] T076 [US3] Register health route in cmd/api/main.go (GET /health)
|
||||
|
||||
**Connection Management**:
|
||||
|
||||
- [ ] T077 [US3] Add connection pool monitoring in pkg/database/postgres.go (log Stats: OpenConnections, InUse, Idle)
|
||||
- [ ] T078 [US3] Add connection retry logic in pkg/database/postgres.go (max 5 retries, exponential backoff)
|
||||
- [ ] T079 [US3] Add slow query logging middleware in pkg/logger/middleware.go (log queries >100ms)
|
||||
|
||||
**Graceful Shutdown**:
|
||||
|
||||
- [ ] T080 [US3] Implement graceful shutdown in cmd/api/main.go (close DB, Redis, wait for requests, max 30s timeout)
|
||||
- [ ] T081 [US3] Validate graceful shutdown in cmd/worker/main.go (stop accepting tasks, wait for completion, max 30s)
|
||||
- [ ] T082 [US3] Add signal handling (SIGINT, SIGTERM) in both API and Worker processes
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Quality Gates
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories and final quality checks
|
||||
|
||||
### Documentation (Constitution Principle VII - REQUIRED)
|
||||
|
||||
- [ ] T083 [P] Create feature summary doc in docs/002-gorm-postgres-asynq/功能总结.md (Chinese filename and content)
|
||||
- [ ] T084 [P] Create usage guide in docs/002-gorm-postgres-asynq/使用指南.md (Chinese filename and content)
|
||||
- [ ] T085 [P] Create architecture doc in docs/002-gorm-postgres-asynq/架构说明.md (Chinese filename and content)
|
||||
- [ ] T086 Update README.md with brief feature description (2-3 sentences in Chinese)
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] T087 Code cleanup: Remove unused imports, variables, and functions
|
||||
- [ ] T088 Code refactoring: Extract duplicate logic into helper functions
|
||||
- [ ] T089 Performance optimization: Add database indexes for common queries (username, email, order_id, user_id, status)
|
||||
- [ ] T090 Performance testing: Verify API response time P95 < 200ms, P99 < 500ms
|
||||
- [ ] T091 [P] Additional unit tests to reach 70%+ overall coverage, 90%+ for Service layer
|
||||
- [ ] T092 Security audit: Verify no SQL injection (GORM uses prepared statements)
|
||||
- [ ] T093 Security audit: Verify password storage uses bcrypt hashing
|
||||
- [ ] T094 Security audit: Verify sensitive data not logged (passwords, tokens)
|
||||
- [ ] T095 Run quickstart.md validation (test all curl examples work)
|
||||
|
||||
### Quality Gates (Constitution Compliance)
|
||||
|
||||
- [ ] T096 Quality Gate: Run `go test ./...` (all tests pass)
|
||||
- [ ] T097 Quality Gate: Run `gofmt -l .` (no formatting issues)
|
||||
- [ ] T098 Quality Gate: Run `go vet ./...` (no issues)
|
||||
- [ ] T099 Quality Gate: Run `golangci-lint run` (no critical issues)
|
||||
- [ ] T100 Quality Gate: Verify test coverage with `go test -cover ./...` (70%+ overall, 90%+ Service)
|
||||
- [ ] T101 Quality Gate: Check no TODO/FIXME remains (or documented in GitHub issues)
|
||||
- [ ] T102 Quality Gate: Verify database migrations work (up and down)
|
||||
- [ ] T103 Quality Gate: Verify API documentation in contracts/api.yaml matches implementation
|
||||
- [ ] T104 Quality Gate: Verify no hardcoded constants (all use pkg/constants/)
|
||||
- [ ] T105 Quality Gate: Verify no duplicate hardcoded values (3+ identical literals must be constants)
|
||||
- [ ] T106 Quality Gate: Verify defined constants are used (no duplicate hardcoding)
|
||||
- [ ] T107 Quality Gate: Verify code comments use Chinese (implementation comments in Chinese)
|
||||
- [ ] T108 Quality Gate: Verify log messages use Chinese (logger.Info/Warn/Error/Debug in Chinese)
|
||||
- [ ] T109 Quality Gate: Verify error messages support Chinese (user-facing errors have Chinese text)
|
||||
- [ ] T110 Quality Gate: Verify no Java-style patterns (no getter/setter, no I-prefix, no Impl-suffix)
|
||||
- [ ] T111 Quality Gate: Verify Go naming conventions (UserID not userId, HTTPServer not HttpServer)
|
||||
- [ ] T112 Quality Gate: Verify error handling is explicit (no panic/recover in business logic)
|
||||
- [ ] T113 Quality Gate: Verify uses goroutines/channels for concurrency (not thread pools)
|
||||
- [ ] T114 Quality Gate: Verify no ORM associations (foreignKey, belongsTo tags - use manual joins)
|
||||
- [ ] T115 Quality Gate: Verify feature docs created in docs/002-gorm-postgres-asynq/ with Chinese filenames
|
||||
- [ ] T116 Quality Gate: Verify summary doc content uses Chinese
|
||||
- [ ] T117 Quality Gate: Verify README.md updated with brief description
|
||||
- [ ] T118 Quality Gate: Verify ALL HTTP requests logged to access.log (via pkg/logger/Middleware())
|
||||
- [ ] T119 Quality Gate: Verify access log includes request/response bodies (limited to 50KB)
|
||||
- [ ] T120 Quality Gate: Verify no middleware bypasses logging (test auth failures, rate limits)
|
||||
- [ ] T121 Quality Gate: Verify access log has all required fields (method, path, status, duration_ms, request_id, ip, user_agent, request_body, response_body)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3-5)**: All depend on Foundational phase completion
|
||||
- User Story 1 (P1): Can start after Foundational - No dependencies on other stories
|
||||
- User Story 2 (P2): Can start after Foundational - Independent (may integrate with US1 for examples)
|
||||
- User Story 3 (P3): Can start after Foundational - Independent
|
||||
- **Polish (Phase 6)**: Depends on all user stories being complete
|
||||
|
||||
### User Story Independence
|
||||
|
||||
- **US1 (P1)**: Fully independent - can be tested and deployed alone (MVP)
|
||||
- **US2 (P2)**: Fully independent - can be tested and deployed alone (may reference US1 models as examples)
|
||||
- **US3 (P3)**: Fully independent - can be tested and deployed alone
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and FAIL before implementation
|
||||
- Models → Store → Service → Handler → Routes
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Phase 1 (Setup)**:
|
||||
- T003, T004, T005, T006, T007, T008 can all run in parallel
|
||||
|
||||
**Phase 2 (Foundational)**:
|
||||
- T011, T012, T013 (config) can run in parallel
|
||||
- T014, T015 (queue client/server) can run in parallel after config
|
||||
|
||||
**Phase 3 (User Story 1)**:
|
||||
- T019-T023 (all tests) can run in parallel
|
||||
- T024-T028 (all models/DTOs) can run in parallel
|
||||
- T029, T030 (Store implementations) can run in parallel after models
|
||||
- T032, T033 (Service implementations) can run in parallel after Store
|
||||
|
||||
**Phase 4 (User Story 2)**:
|
||||
- T047-T050 (all tests) can run in parallel
|
||||
- T051-T053 (all payloads) can run in parallel
|
||||
- T054-T056 (all task handlers) can run in parallel after payloads
|
||||
- T059, T060 (Service implementations) can run in parallel after handlers
|
||||
|
||||
**Phase 5 (User Story 3)**:
|
||||
- T069-T071 (all tests) can run in parallel
|
||||
- T073, T074 (Ping checks) can run in parallel
|
||||
- T077, T078, T079 (connection management) can run in parallel
|
||||
|
||||
**Phase 6 (Polish)**:
|
||||
- T083-T085 (all docs) can run in parallel
|
||||
- T096-T121 (quality gates) run sequentially but can be automated in CI
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests together:
|
||||
go test -v tests/unit/store_test.go & # T019, T020
|
||||
go test -v tests/unit/service_test.go & # T021
|
||||
go test -v tests/integration/database_test.go & # T022
|
||||
wait
|
||||
|
||||
# Launch all models together:
|
||||
Task: "Validate User model in internal/model/user.go" # T025
|
||||
Task: "Validate Order model in internal/model/order.go" # T026
|
||||
Task: "Validate User DTOs" # T027
|
||||
Task: "Create Order DTOs" # T028
|
||||
|
||||
# Launch both Store implementations together:
|
||||
Task: "Implement UserStore" # T029
|
||||
Task: "Implement OrderStore" # T030
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (T001-T008)
|
||||
2. Complete Phase 2: Foundational (T009-T018) - CRITICAL
|
||||
3. Complete Phase 3: User Story 1 (T019-T046)
|
||||
4. **STOP and VALIDATE**: Test CRUD operations independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP! 🎯)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Polish → Final quality checks → Production ready
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup (Phase 1) + Foundational (Phase 2) together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1 (T019-T046)
|
||||
- Developer B: User Story 2 (T047-T068)
|
||||
- Developer C: User Story 3 (T069-T082)
|
||||
3. Stories complete and integrate independently
|
||||
4. Team reconvenes for Polish (Phase 6)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies, can run in parallel
|
||||
- [Story] label (US1, US2, US3) maps task to specific user story for traceability
|
||||
- Each user story is independently completable and testable
|
||||
- Tests are written FIRST and should FAIL before implementation (TDD approach)
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Project structure already exists - tasks validate/enhance existing code where noted
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
|
||||
---
|
||||
|
||||
## Task Count Summary
|
||||
|
||||
- **Total Tasks**: 121
|
||||
- **Phase 1 (Setup)**: 8 tasks
|
||||
- **Phase 2 (Foundational)**: 10 tasks
|
||||
- **Phase 3 (User Story 1)**: 28 tasks (5 tests + 23 implementation)
|
||||
- **Phase 4 (User Story 2)**: 22 tasks (4 tests + 18 implementation)
|
||||
- **Phase 5 (User Story 3)**: 14 tasks (3 tests + 11 implementation)
|
||||
- **Phase 6 (Polish)**: 39 tasks (4 docs + 35 quality gates)
|
||||
|
||||
**Parallel Opportunities**: ~40 tasks marked [P] can run in parallel within their phases
|
||||
|
||||
**Suggested MVP Scope**: Phase 1 + Phase 2 + Phase 3 (User Story 1) = 46 tasks
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -64,7 +63,8 @@ func setupAuthTestApp(t *testing.T, rdb *redis.Client) *fiber.App {
|
||||
})
|
||||
})
|
||||
|
||||
app.Get("/api/v1/users", handler.GetUsers)
|
||||
// 注释:用户路由已移至实例方法,集成测试中使用测试路由即可
|
||||
// 实际的用户路由测试应在 cmd/api/main.go 中完整初始化
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
489
tests/integration/database_test.go
Normal file
489
tests/integration/database_test.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
testcontainers_postgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.uber.org/zap"
|
||||
postgresDriver "gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestMain 设置测试环境
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// setupTestDB 启动 PostgreSQL 容器并使用迁移脚本初始化数据库
|
||||
func setupTestDB(t *testing.T) (*postgres.Store, func()) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 启动 PostgreSQL 容器
|
||||
postgresContainer, err := testcontainers_postgres.RunContainer(ctx,
|
||||
testcontainers.WithImage("postgres:14-alpine"),
|
||||
testcontainers_postgres.WithDatabase("testdb"),
|
||||
testcontainers_postgres.WithUsername("postgres"),
|
||||
testcontainers_postgres.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(30*time.Second),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err, "启动 PostgreSQL 容器失败")
|
||||
|
||||
// 获取连接字符串
|
||||
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err, "获取数据库连接字符串失败")
|
||||
|
||||
// 应用数据库迁移
|
||||
migrationsPath := getMigrationsPath(t)
|
||||
m, err := migrate.New(
|
||||
fmt.Sprintf("file://%s", migrationsPath),
|
||||
connStr,
|
||||
)
|
||||
require.NoError(t, err, "创建迁移实例失败")
|
||||
|
||||
// 执行向上迁移
|
||||
err = m.Up()
|
||||
require.NoError(t, err, "执行数据库迁移失败")
|
||||
|
||||
// 连接数据库
|
||||
db, err := gorm.Open(postgresDriver.Open(connStr), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err, "连接数据库失败")
|
||||
|
||||
// 创建测试 logger
|
||||
testLogger := zap.NewNop()
|
||||
store := postgres.NewStore(db, testLogger)
|
||||
|
||||
// 返回清理函数
|
||||
cleanup := func() {
|
||||
// 执行向下迁移清理数据
|
||||
if err := m.Down(); err != nil && err != migrate.ErrNoChange {
|
||||
t.Logf("清理迁移失败: %v", err)
|
||||
}
|
||||
m.Close()
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
if err := postgresContainer.Terminate(ctx); err != nil {
|
||||
t.Logf("终止容器失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
|
||||
// getMigrationsPath 获取迁移文件路径
|
||||
func getMigrationsPath(t *testing.T) string {
|
||||
// 获取项目根目录
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err, "获取工作目录失败")
|
||||
|
||||
// 从测试目录向上找到项目根目录
|
||||
migrationsPath := filepath.Join(wd, "..", "..", "migrations")
|
||||
|
||||
// 验证迁移目录存在
|
||||
_, err = os.Stat(migrationsPath)
|
||||
require.NoError(t, err, fmt.Sprintf("迁移目录不存在: %s", migrationsPath))
|
||||
|
||||
return migrationsPath
|
||||
}
|
||||
|
||||
// TestUserCRUD 测试用户 CRUD 操作
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("创建用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
err := store.User.Create(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, user.ID)
|
||||
assert.NotZero(t, user.CreatedAt)
|
||||
assert.NotZero(t, user.UpdatedAt)
|
||||
})
|
||||
|
||||
t.Run("根据ID查询用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "queryuser",
|
||||
Email: "query@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 查询用户
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.Username, found.Username)
|
||||
assert.Equal(t, user.Email, found.Email)
|
||||
assert.Equal(t, constants.UserStatusActive, found.Status)
|
||||
})
|
||||
|
||||
t.Run("根据用户名查询用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "findbyname",
|
||||
Email: "findbyname@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 根据用户名查询
|
||||
found, err := store.User.GetByUsername(ctx, "findbyname")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.ID, found.ID)
|
||||
assert.Equal(t, user.Email, found.Email)
|
||||
})
|
||||
|
||||
t.Run("更新用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "updateuser",
|
||||
Email: "update@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新用户
|
||||
user.Email = "newemail@example.com"
|
||||
user.Status = constants.UserStatusInactive
|
||||
err = store.User.Update(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "newemail@example.com", found.Email)
|
||||
assert.Equal(t, constants.UserStatusInactive, found.Status)
|
||||
})
|
||||
|
||||
t.Run("列表查询用户", func(t *testing.T) {
|
||||
// 创建多个测试用户
|
||||
for i := 1; i <= 5; i++ {
|
||||
user := &model.User{
|
||||
Username: fmt.Sprintf("listuser%d", i),
|
||||
Email: fmt.Sprintf("list%d@example.com", i),
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
users, total, err := store.User.List(ctx, 1, 3)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(users), 3)
|
||||
assert.GreaterOrEqual(t, total, int64(5))
|
||||
})
|
||||
|
||||
t.Run("软删除用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "deleteuser",
|
||||
Email: "delete@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.User.Delete(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除(查询应该找不到)
|
||||
_, err = store.User.GetByID(ctx, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderCRUD 测试订单 CRUD 操作
|
||||
func TestOrderCRUD(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "orderuser",
|
||||
Email: "orderuser@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建订单", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
err := store.Order.Create(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, order.ID)
|
||||
assert.NotZero(t, order.CreatedAt)
|
||||
})
|
||||
|
||||
t.Run("根据ID查询订单", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-002",
|
||||
UserID: user.ID,
|
||||
Amount: 20000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 查询订单
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.OrderID, found.OrderID)
|
||||
assert.Equal(t, order.Amount, found.Amount)
|
||||
})
|
||||
|
||||
t.Run("根据订单号查询", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-003",
|
||||
UserID: user.ID,
|
||||
Amount: 30000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 根据订单号查询
|
||||
found, err := store.Order.GetByOrderID(ctx, "ORD-003")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("根据用户ID列表查询", func(t *testing.T) {
|
||||
// 创建多个订单
|
||||
for i := 1; i <= 3; i++ {
|
||||
order := &model.Order{
|
||||
OrderID: fmt.Sprintf("ORD-USER-%d", i),
|
||||
UserID: user.ID,
|
||||
Amount: int64(i * 10000),
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
orders, total, err := store.Order.ListByUserID(ctx, user.ID, 1, 10)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(orders), 3)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
})
|
||||
|
||||
t.Run("更新订单状态", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-UPDATE",
|
||||
UserID: user.ID,
|
||||
Amount: 50000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新状态
|
||||
now := time.Now()
|
||||
order.Status = constants.OrderStatusPaid
|
||||
order.PaidAt = &now
|
||||
err = store.Order.Update(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.OrderStatusPaid, found.Status)
|
||||
assert.NotNil(t, found.PaidAt)
|
||||
})
|
||||
|
||||
t.Run("软删除订单", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-DELETE",
|
||||
UserID: user.ID,
|
||||
Amount: 60000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.Order.Delete(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除
|
||||
_, err = store.Order.GetByID(ctx, order.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestTransaction 测试事务功能
|
||||
func TestTransaction(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("事务提交", func(t *testing.T) {
|
||||
var userID uint
|
||||
var orderID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "txuser",
|
||||
Email: "txuser@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 创建订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-TX-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
orderID = order.ID
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证用户和订单都已创建
|
||||
user, err := store.User.GetByID(ctx, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "txuser", user.Username)
|
||||
|
||||
order, err := store.Order.GetByID(ctx, orderID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ORD-TX-001", order.OrderID)
|
||||
})
|
||||
|
||||
t.Run("事务回滚", func(t *testing.T) {
|
||||
var userID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "rollbackuser",
|
||||
Email: "rollback@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 模拟错误,触发回滚
|
||||
return fmt.Errorf("模拟错误")
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "模拟错误", err.Error())
|
||||
|
||||
// 验证用户未创建(已回滚)
|
||||
_, err = store.User.GetByID(ctx, userID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConcurrentOperations 测试并发操作
|
||||
func TestConcurrentOperations(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("并发创建用户", func(t *testing.T) {
|
||||
concurrency := 10
|
||||
errChan := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(index int) {
|
||||
user := &model.User{
|
||||
Username: fmt.Sprintf("concurrent%d", index),
|
||||
Email: fmt.Sprintf("concurrent%d@example.com", index),
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
errChan <- store.User.Create(ctx, user)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
successCount := 0
|
||||
for i := 0; i < concurrency; i++ {
|
||||
err := <-errChan
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, concurrency, successCount, "所有并发创建应该成功")
|
||||
})
|
||||
}
|
||||
169
tests/integration/health_test.go
Normal file
169
tests/integration/health_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestHealthCheckNormal 测试健康检查 - 正常状态
|
||||
func TestHealthCheckNormal(t *testing.T) {
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 初始化 Redis 客户端(使用本地 Redis)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// 验证响应内容
|
||||
// 注意:这里可以进一步解析 JSON 响应体验证详细信息
|
||||
}
|
||||
|
||||
// TestHealthCheckDatabaseDown 测试健康检查 - 数据库异常
|
||||
func TestHealthCheckDatabaseDown(t *testing.T) {
|
||||
t.Skip("需要模拟数据库连接失败的场景")
|
||||
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化一个会失败的数据库连接
|
||||
db, err := gorm.Open(sqlite.Open("/invalid/path/test.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
// 预期会失败
|
||||
t.Log("数据库连接失败(预期行为)")
|
||||
}
|
||||
|
||||
// 初始化 Redis 客户端
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码应该是 503 (Service Unavailable)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHealthCheckRedisDown 测试健康检查 - Redis 异常
|
||||
func TestHealthCheckRedisDown(t *testing.T) {
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 初始化一个连接到无效地址的 Redis 客户端
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:9999", // 无效端口
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码应该是 503 (Service Unavailable)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHealthCheckDetailed 测试健康检查 - 验证详细信息
|
||||
func TestHealthCheckDetailed(t *testing.T) {
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 初始化 Redis 客户端
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 测试 Redis 连接
|
||||
ctx := context.Background()
|
||||
_, err = rdb.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
t.Skip("Redis 未运行,跳过测试")
|
||||
}
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// TODO: 解析 JSON 响应并验证包含以下字段:
|
||||
// - status: "healthy"
|
||||
// - postgres: "up"
|
||||
// - redis: "up"
|
||||
// - timestamp
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/google/uuid"
|
||||
@@ -115,7 +115,7 @@ func TestPanicRecovery(t *testing.T) {
|
||||
if tt.shouldPanic {
|
||||
// panic 应该返回统一错误响应
|
||||
var response response.Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ func TestSubsequentRequestsAfterPanic(t *testing.T) {
|
||||
|
||||
// 验证响应内容
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Request %d: failed to unmarshal response: %v", i, err)
|
||||
}
|
||||
|
||||
@@ -606,7 +606,7 @@ func TestRecoverMiddlewareOrder(t *testing.T) {
|
||||
// 解析响应,验证返回了统一错误格式
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var response response.Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
|
||||
312
tests/integration/task_test.go
Normal file
312
tests/integration/task_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// EmailPayload 邮件任务载荷(测试用)
|
||||
type EmailPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
CC []string `json:"cc,omitempty"`
|
||||
}
|
||||
|
||||
// TestTaskSubmit 测试任务提交
|
||||
func TestTaskSubmit(t *testing.T) {
|
||||
// 创建 Redis 客户端
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
// 清理测试数据
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
// 创建 Asynq 客户端
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 构造任务载荷
|
||||
payload := &EmailPayload{
|
||||
RequestID: "test-request-001",
|
||||
To: "test@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
// 验证
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, info.ID)
|
||||
assert.Equal(t, constants.QueueDefault, info.Queue)
|
||||
assert.Equal(t, constants.DefaultRetryMax, info.MaxRetry)
|
||||
}
|
||||
|
||||
// TestTaskPriority 测试任务优先级
|
||||
func TestTaskPriority(t *testing.T) {
|
||||
// 创建 Redis 客户端
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
// 创建 Asynq 客户端
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queue string
|
||||
}{
|
||||
{"Critical Priority", constants.QueueCritical},
|
||||
{"Default Priority", constants.QueueDefault},
|
||||
{"Low Priority", constants.QueueLow},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := &EmailPayload{
|
||||
RequestID: "test-request-" + tt.queue,
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task, asynq.Queue(tt.queue))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.queue, info.Queue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskRetry 测试任务重试机制
|
||||
func TestTaskRetry(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := &EmailPayload{
|
||||
RequestID: "retry-test-001",
|
||||
To: "test@example.com",
|
||||
Subject: "Retry Test",
|
||||
Body: "Test retry mechanism",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务并设置重试次数
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Second),
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, info.MaxRetry)
|
||||
assert.Equal(t, 30*time.Second, info.Timeout)
|
||||
}
|
||||
|
||||
// TestTaskIdempotency 测试任务幂等性键
|
||||
func TestTaskIdempotency(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
requestID := "idempotent-test-001"
|
||||
lockKey := constants.RedisTaskLockKey(requestID)
|
||||
|
||||
// 第一次设置锁(模拟任务开始执行)
|
||||
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result, "第一次设置锁应该成功")
|
||||
|
||||
// 第二次设置锁(模拟重复任务)
|
||||
result, err = redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result, "第二次设置锁应该失败(幂等性)")
|
||||
|
||||
// 验证锁存在
|
||||
exists, err := redisClient.Exists(ctx, lockKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists)
|
||||
|
||||
// 验证 TTL
|
||||
ttl, err := redisClient.TTL(ctx, lockKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, ttl.Hours(), 23.0)
|
||||
assert.LessOrEqual(t, ttl.Hours(), 24.0)
|
||||
}
|
||||
|
||||
// TestTaskStatusTracking 测试任务状态跟踪
|
||||
func TestTaskStatusTracking(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
taskID := "task-123456"
|
||||
statusKey := constants.RedisTaskStatusKey(taskID)
|
||||
|
||||
// 设置任务状态
|
||||
statuses := []string{"pending", "processing", "completed"}
|
||||
|
||||
for _, status := range statuses {
|
||||
err := redisClient.Set(ctx, statusKey, status, 7*24*time.Hour).Err()
|
||||
require.NoError(t, err)
|
||||
|
||||
// 读取状态
|
||||
result, err := redisClient.Get(ctx, statusKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, status, result)
|
||||
}
|
||||
|
||||
// 验证 TTL
|
||||
ttl, err := redisClient.TTL(ctx, statusKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, ttl.Hours(), 24.0*6)
|
||||
}
|
||||
|
||||
// TestQueueInspection 测试队列检查
|
||||
func TestQueueInspection(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 提交多个任务
|
||||
for i := 0; i < 5; i++ {
|
||||
payload := &EmailPayload{
|
||||
RequestID: "test-" + string(rune(i)),
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task, asynq.Queue(constants.QueueDefault))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 创建 Inspector 检查队列
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
// 获取队列信息
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, info.Pending)
|
||||
assert.Equal(t, 0, info.Active)
|
||||
}
|
||||
|
||||
// TestTaskSerialization 测试任务序列化
|
||||
func TestTaskSerialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload EmailPayload
|
||||
}{
|
||||
{
|
||||
name: "Simple Email",
|
||||
payload: EmailPayload{
|
||||
RequestID: "req-001",
|
||||
To: "user@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Hello World",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email with CC",
|
||||
payload: EmailPayload{
|
||||
RequestID: "req-002",
|
||||
To: "user@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Hello World",
|
||||
CC: []string{"cc1@example.com", "cc2@example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 序列化
|
||||
payloadBytes, err := sonic.Marshal(tt.payload)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, payloadBytes)
|
||||
|
||||
// 反序列化
|
||||
var decoded EmailPayload
|
||||
err = sonic.Unmarshal(payloadBytes, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证
|
||||
assert.Equal(t, tt.payload.RequestID, decoded.RequestID)
|
||||
assert.Equal(t, tt.payload.To, decoded.To)
|
||||
assert.Equal(t, tt.payload.Subject, decoded.Subject)
|
||||
assert.Equal(t, tt.payload.Body, decoded.Body)
|
||||
assert.Equal(t, tt.payload.CC, decoded.CC)
|
||||
})
|
||||
}
|
||||
}
|
||||
502
tests/unit/model_test.go
Normal file
502
tests/unit/model_test.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestUserValidation 测试用户模型验证
|
||||
func TestUserValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的创建用户请求", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
Email: "valid@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("用户名太短", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "ab", // 少于 3 个字符
|
||||
Email: "valid@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("用户名太长", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "a123456789012345678901234567890123456789012345678901", // 超过 50 个字符
|
||||
Email: "valid@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的邮箱格式", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
Email: "invalid-email",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("密码太短", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
Email: "valid@example.com",
|
||||
Password: "short", // 少于 8 个字符
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("缺少必填字段", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
// 缺少 Email 和 Password
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserUpdateValidation 测试用户更新验证
|
||||
func TestUserUpdateValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的更新请求", func(t *testing.T) {
|
||||
email := "newemail@example.com"
|
||||
status := constants.UserStatusActive
|
||||
req := &model.UpdateUserRequest{
|
||||
Email: &email,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的邮箱格式", func(t *testing.T) {
|
||||
email := "invalid-email"
|
||||
req := &model.UpdateUserRequest{
|
||||
Email: &email,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的状态值", func(t *testing.T) {
|
||||
status := "invalid_status"
|
||||
req := &model.UpdateUserRequest{
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("空更新请求", func(t *testing.T) {
|
||||
req := &model.UpdateUserRequest{}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err) // 空更新请求应该是有效的
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderValidation 测试订单模型验证
|
||||
func TestOrderValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的创建订单请求", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("订单号太短", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-123", // 少于 10 个字符
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("订单号太长", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-12345678901234567890123456789012345678901234567890", // 超过 50 个字符
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("用户ID无效", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 0, // 用户ID必须大于0
|
||||
Amount: 10000,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("金额为负数", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: -1000, // 金额不能为负数
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("缺少必填字段", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
// 缺少 UserID 和 Amount
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderUpdateValidation 测试订单更新验证
|
||||
func TestOrderUpdateValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的更新请求", func(t *testing.T) {
|
||||
status := constants.OrderStatusPaid
|
||||
remark := "已支付"
|
||||
req := &model.UpdateOrderRequest{
|
||||
Status: &status,
|
||||
Remark: &remark,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的状态值", func(t *testing.T) {
|
||||
status := "invalid_status"
|
||||
req := &model.UpdateOrderRequest{
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserModel 测试用户模型
|
||||
func TestUserModel(t *testing.T) {
|
||||
t.Run("创建用户模型", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
assert.Equal(t, "testuser", user.Username)
|
||||
assert.Equal(t, "test@example.com", user.Email)
|
||||
assert.Equal(t, constants.UserStatusActive, user.Status)
|
||||
})
|
||||
|
||||
t.Run("用户表名", func(t *testing.T) {
|
||||
user := &model.User{}
|
||||
assert.Equal(t, "tb_user", user.TableName())
|
||||
})
|
||||
|
||||
t.Run("软删除字段", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
// DeletedAt 应该是 nil (未删除)
|
||||
assert.True(t, user.DeletedAt.Time.IsZero())
|
||||
})
|
||||
|
||||
t.Run("LastLoginAt 可选字段", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
assert.Nil(t, user.LastLoginAt)
|
||||
|
||||
// 设置登录时间
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
assert.NotNil(t, user.LastLoginAt)
|
||||
assert.Equal(t, now, *user.LastLoginAt)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderModel 测试订单模型
|
||||
func TestOrderModel(t *testing.T) {
|
||||
t.Run("创建订单模型", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
assert.Equal(t, "ORD-2025-001", order.OrderID)
|
||||
assert.Equal(t, uint(1), order.UserID)
|
||||
assert.Equal(t, int64(10000), order.Amount)
|
||||
assert.Equal(t, constants.OrderStatusPending, order.Status)
|
||||
})
|
||||
|
||||
t.Run("订单表名", func(t *testing.T) {
|
||||
order := &model.Order{}
|
||||
assert.Equal(t, "tb_order", order.TableName())
|
||||
})
|
||||
|
||||
t.Run("可选时间字段", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
assert.Nil(t, order.PaidAt)
|
||||
assert.Nil(t, order.CompletedAt)
|
||||
|
||||
// 设置支付时间
|
||||
now := time.Now()
|
||||
order.PaidAt = &now
|
||||
assert.NotNil(t, order.PaidAt)
|
||||
assert.Equal(t, now, *order.PaidAt)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseModel 测试基础模型
|
||||
func TestBaseModel(t *testing.T) {
|
||||
t.Run("BaseModel 字段", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
// ID 应该是 0 (未保存)
|
||||
assert.Zero(t, user.ID)
|
||||
|
||||
// 时间戳应该是零值
|
||||
assert.True(t, user.CreatedAt.IsZero())
|
||||
assert.True(t, user.UpdatedAt.IsZero())
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserStatusConstants 测试用户状态常量
|
||||
func TestUserStatusConstants(t *testing.T) {
|
||||
t.Run("用户状态常量定义", func(t *testing.T) {
|
||||
assert.Equal(t, "active", constants.UserStatusActive)
|
||||
assert.Equal(t, "inactive", constants.UserStatusInactive)
|
||||
assert.Equal(t, "suspended", constants.UserStatusSuspended)
|
||||
})
|
||||
|
||||
t.Run("用户状态验证", func(t *testing.T) {
|
||||
validStatuses := []string{
|
||||
constants.UserStatusActive,
|
||||
constants.UserStatusInactive,
|
||||
constants.UserStatusSuspended,
|
||||
}
|
||||
|
||||
for _, status := range validStatuses {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: status,
|
||||
}
|
||||
assert.Contains(t, validStatuses, user.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderStatusConstants 测试订单状态常量
|
||||
func TestOrderStatusConstants(t *testing.T) {
|
||||
t.Run("订单状态常量定义", func(t *testing.T) {
|
||||
assert.Equal(t, "pending", constants.OrderStatusPending)
|
||||
assert.Equal(t, "paid", constants.OrderStatusPaid)
|
||||
assert.Equal(t, "processing", constants.OrderStatusProcessing)
|
||||
assert.Equal(t, "completed", constants.OrderStatusCompleted)
|
||||
assert.Equal(t, "cancelled", constants.OrderStatusCancelled)
|
||||
})
|
||||
|
||||
t.Run("订单状态流转", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
// 订单状态流转:pending -> paid -> processing -> completed
|
||||
assert.Equal(t, constants.OrderStatusPending, order.Status)
|
||||
|
||||
order.Status = constants.OrderStatusPaid
|
||||
assert.Equal(t, constants.OrderStatusPaid, order.Status)
|
||||
|
||||
order.Status = constants.OrderStatusProcessing
|
||||
assert.Equal(t, constants.OrderStatusProcessing, order.Status)
|
||||
|
||||
order.Status = constants.OrderStatusCompleted
|
||||
assert.Equal(t, constants.OrderStatusCompleted, order.Status)
|
||||
})
|
||||
|
||||
t.Run("订单取消", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-002",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
// 从任何状态都可以取消
|
||||
order.Status = constants.OrderStatusCancelled
|
||||
assert.Equal(t, constants.OrderStatusCancelled, order.Status)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserResponse 测试用户响应模型
|
||||
func TestUserResponse(t *testing.T) {
|
||||
t.Run("创建用户响应", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
resp := &model.UserResponse{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Status: constants.UserStatusActive,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
assert.Equal(t, uint(1), resp.ID)
|
||||
assert.Equal(t, "testuser", resp.Username)
|
||||
assert.Equal(t, "test@example.com", resp.Email)
|
||||
assert.Equal(t, constants.UserStatusActive, resp.Status)
|
||||
})
|
||||
|
||||
t.Run("用户响应不包含密码", func(t *testing.T) {
|
||||
// UserResponse 结构体不应该包含 Password 字段
|
||||
resp := &model.UserResponse{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
// 验证结构体大小合理 (不包含密码字段)
|
||||
assert.NotNil(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
// TestListResponse 测试列表响应模型
|
||||
func TestListResponse(t *testing.T) {
|
||||
t.Run("用户列表响应", func(t *testing.T) {
|
||||
users := []model.UserResponse{
|
||||
{ID: 1, Username: "user1", Email: "user1@example.com", Status: constants.UserStatusActive},
|
||||
{ID: 2, Username: "user2", Email: "user2@example.com", Status: constants.UserStatusActive},
|
||||
}
|
||||
|
||||
resp := &model.ListUsersResponse{
|
||||
Users: users,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Total: 100,
|
||||
TotalPages: 5,
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, len(resp.Users))
|
||||
assert.Equal(t, 1, resp.Page)
|
||||
assert.Equal(t, 20, resp.PageSize)
|
||||
assert.Equal(t, int64(100), resp.Total)
|
||||
assert.Equal(t, 5, resp.TotalPages)
|
||||
})
|
||||
|
||||
t.Run("订单列表响应", func(t *testing.T) {
|
||||
orders := []model.OrderResponse{
|
||||
{ID: 1, OrderID: "ORD-001", UserID: 1, Amount: 10000, Status: constants.OrderStatusPending},
|
||||
{ID: 2, OrderID: "ORD-002", UserID: 1, Amount: 20000, Status: constants.OrderStatusPaid},
|
||||
}
|
||||
|
||||
resp := &model.ListOrdersResponse{
|
||||
Orders: orders,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Total: 50,
|
||||
TotalPages: 3,
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, len(resp.Orders))
|
||||
assert.Equal(t, 1, resp.Page)
|
||||
assert.Equal(t, 20, resp.PageSize)
|
||||
assert.Equal(t, int64(50), resp.Total)
|
||||
assert.Equal(t, 3, resp.TotalPages)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFieldTags 测试字段标签
|
||||
func TestFieldTags(t *testing.T) {
|
||||
t.Run("User GORM 标签", func(t *testing.T) {
|
||||
user := &model.User{}
|
||||
|
||||
// 验证 TableName 方法存在
|
||||
tableName := user.TableName()
|
||||
assert.Equal(t, "tb_user", tableName)
|
||||
})
|
||||
|
||||
t.Run("Order GORM 标签", func(t *testing.T) {
|
||||
order := &model.Order{}
|
||||
|
||||
// 验证 TableName 方法存在
|
||||
tableName := order.TableName()
|
||||
assert.Equal(t, "tb_order", tableName)
|
||||
})
|
||||
}
|
||||
555
tests/unit/queue_test.go
Normal file
555
tests/unit/queue_test.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestQueueClientEnqueue 测试任务入队
|
||||
func TestQueueClientEnqueue(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "test-001",
|
||||
"to": "test@example.com",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, info.ID)
|
||||
assert.Equal(t, constants.QueueDefault, info.Queue)
|
||||
}
|
||||
|
||||
// TestQueueClientEnqueueWithOptions 测试带选项的任务入队
|
||||
func TestQueueClientEnqueueWithOptions(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []asynq.Option
|
||||
verify func(*testing.T, *asynq.TaskInfo)
|
||||
}{
|
||||
{
|
||||
name: "Custom Queue",
|
||||
opts: []asynq.Option{
|
||||
asynq.Queue(constants.QueueCritical),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, constants.QueueCritical, info.Queue)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Retry",
|
||||
opts: []asynq.Option{
|
||||
asynq.MaxRetry(3),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, 3, info.MaxRetry)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Timeout",
|
||||
opts: []asynq.Option{
|
||||
asynq.Timeout(5 * time.Minute),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, 5*time.Minute, info.Timeout)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Delayed Task",
|
||||
opts: []asynq.Option{
|
||||
asynq.ProcessIn(10 * time.Second),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.True(t, info.NextProcessAt.After(time.Now()))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Combined Options",
|
||||
opts: []asynq.Option{
|
||||
asynq.Queue(constants.QueueCritical),
|
||||
asynq.MaxRetry(5),
|
||||
asynq.Timeout(10 * time.Minute),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, constants.QueueCritical, info.Queue)
|
||||
assert.Equal(t, 5, info.MaxRetry)
|
||||
assert.Equal(t, 10*time.Minute, info.Timeout)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"request_id": "test-" + tt.name,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task, tt.opts...)
|
||||
|
||||
require.NoError(t, err)
|
||||
tt.verify(t, info)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueueClientTaskUniqueness 测试任务唯一性
|
||||
func TestQueueClientTaskUniqueness(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "unique-001",
|
||||
"to": "test@example.com",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 第一次提交
|
||||
task1 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info1, err := client.Enqueue(task1,
|
||||
asynq.TaskID("unique-task-001"),
|
||||
asynq.Unique(1*time.Hour),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, info1)
|
||||
|
||||
// 第二次提交(重复)
|
||||
task2 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info2, err := client.Enqueue(task2,
|
||||
asynq.TaskID("unique-task-001"),
|
||||
asynq.Unique(1*time.Hour),
|
||||
)
|
||||
|
||||
// 应该返回错误(任务已存在)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, info2)
|
||||
}
|
||||
|
||||
// TestQueuePriorityWeights 测试队列优先级权重
|
||||
func TestQueuePriorityWeights(t *testing.T) {
|
||||
queues := map[string]int{
|
||||
constants.QueueCritical: 6,
|
||||
constants.QueueDefault: 3,
|
||||
constants.QueueLow: 1,
|
||||
}
|
||||
|
||||
// 验证权重总和
|
||||
totalWeight := 0
|
||||
for _, weight := range queues {
|
||||
totalWeight += weight
|
||||
}
|
||||
assert.Equal(t, 10, totalWeight)
|
||||
|
||||
// 验证权重比例
|
||||
assert.Equal(t, 0.6, float64(queues[constants.QueueCritical])/float64(totalWeight))
|
||||
assert.Equal(t, 0.3, float64(queues[constants.QueueDefault])/float64(totalWeight))
|
||||
assert.Equal(t, 0.1, float64(queues[constants.QueueLow])/float64(totalWeight))
|
||||
}
|
||||
|
||||
// TestTaskPayloadSizeLimit 测试任务载荷大小限制
|
||||
func TestTaskPayloadSizeLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payloadSize int
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "Small Payload (1KB)",
|
||||
payloadSize: 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Medium Payload (100KB)",
|
||||
payloadSize: 100 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Large Payload (1MB)",
|
||||
payloadSize: 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
// Redis 默认支持最大 512MB,但实际应用中不建议超过 1MB
|
||||
}
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建指定大小的载荷
|
||||
largeData := make([]byte, tt.payloadSize)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"request_id": "size-test-001",
|
||||
"data": largeData,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeDataSync, payloadBytes)
|
||||
info, err := client.Enqueue(task)
|
||||
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, info)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskScheduling 测试任务调度
|
||||
func TestTaskScheduling(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scheduleOpt asynq.Option
|
||||
expectedTime time.Time
|
||||
}{
|
||||
{
|
||||
name: "Process In 5 Seconds",
|
||||
scheduleOpt: asynq.ProcessIn(5 * time.Second),
|
||||
expectedTime: time.Now().Add(5 * time.Second),
|
||||
},
|
||||
{
|
||||
name: "Process At Specific Time",
|
||||
scheduleOpt: asynq.ProcessAt(time.Now().Add(10 * time.Second)),
|
||||
expectedTime: time.Now().Add(10 * time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"request_id": "schedule-test-" + tt.name,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task, tt.scheduleOpt)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.NextProcessAt.After(time.Now()))
|
||||
// 允许 1 秒的误差
|
||||
assert.WithinDuration(t, tt.expectedTime, info.NextProcessAt, 1*time.Second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueueInspectorStats 测试队列统计
|
||||
func TestQueueInspectorStats(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 提交一些任务
|
||||
for i := 0; i < 5; i++ {
|
||||
payload := map[string]string{
|
||||
"request_id": "stats-test-" + string(rune(i)),
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 使用 Inspector 查询统计
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 5, info.Pending)
|
||||
assert.Equal(t, 0, info.Active)
|
||||
assert.Equal(t, 0, info.Completed)
|
||||
}
|
||||
|
||||
// TestTaskRetention 测试任务保留策略
|
||||
func TestTaskRetention(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "retention-test-001",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务并设置保留时间
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task,
|
||||
asynq.Retention(24*time.Hour), // 保留 24 小时
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, info)
|
||||
}
|
||||
|
||||
// TestQueueDraining 测试队列暂停和恢复
|
||||
func TestQueueDraining(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
// 暂停队列
|
||||
err := inspector.PauseQueue(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 检查队列是否已暂停
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.Paused)
|
||||
|
||||
// 恢复队列
|
||||
err = inspector.UnpauseQueue(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 检查队列是否已恢复
|
||||
info, err = inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, info.Paused)
|
||||
}
|
||||
|
||||
// TestTaskCancellation 测试任务取消
|
||||
func TestTaskCancellation(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "cancel-test-001",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 取消任务
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
err = inspector.DeleteTask(constants.QueueDefault, info.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证任务已删除
|
||||
queueInfo, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, queueInfo.Pending)
|
||||
}
|
||||
|
||||
// TestBatchTaskEnqueue 测试批量任务入队
|
||||
func TestBatchTaskEnqueue(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 批量创建任务
|
||||
batchSize := 100
|
||||
for i := 0; i < batchSize; i++ {
|
||||
payload := map[string]string{
|
||||
"request_id": "batch-" + string(rune(i)),
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 验证任务数量
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, batchSize, info.Pending)
|
||||
}
|
||||
|
||||
// TestTaskGrouping 测试任务分组
|
||||
func TestTaskGrouping(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 提交分组任务
|
||||
groupKey := "email-batch-001"
|
||||
for i := 0; i < 5; i++ {
|
||||
payload := map[string]string{
|
||||
"request_id": "group-" + string(rune(i)),
|
||||
"group": groupKey,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task,
|
||||
asynq.Group(groupKey),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 验证任务已按组提交
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, info.Pending, 5)
|
||||
}
|
||||
550
tests/unit/store_test.go
Normal file
550
tests/unit/store_test.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// setupTestStore 创建内存数据库用于单元测试
|
||||
func setupTestStore(t *testing.T) (*postgres.Store, func()) {
|
||||
// 使用 SQLite 内存数据库进行单元测试
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err, "创建内存数据库失败")
|
||||
|
||||
// 自动迁移
|
||||
err = db.AutoMigrate(&model.User{}, &model.Order{})
|
||||
require.NoError(t, err, "数据库迁移失败")
|
||||
|
||||
// 创建测试 logger
|
||||
testLogger := zap.NewNop()
|
||||
store := postgres.NewStore(db, testLogger)
|
||||
|
||||
cleanup := func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
|
||||
// TestUserStore 测试用户 Store 层
|
||||
func TestUserStore(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("创建用户成功", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
err := store.User.Create(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, user.ID)
|
||||
assert.False(t, user.CreatedAt.IsZero())
|
||||
assert.False(t, user.UpdatedAt.IsZero())
|
||||
})
|
||||
|
||||
t.Run("创建重复用户名失败", func(t *testing.T) {
|
||||
user1 := &model.User{
|
||||
Username: "duplicate",
|
||||
Email: "user1@example.com",
|
||||
Password: "password1",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 尝试创建相同用户名
|
||||
user2 := &model.User{
|
||||
Username: "duplicate",
|
||||
Email: "user2@example.com",
|
||||
Password: "password2",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err = store.User.Create(ctx, user2)
|
||||
assert.Error(t, err, "应该返回唯一约束错误")
|
||||
})
|
||||
|
||||
t.Run("根据ID查询用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "findbyid",
|
||||
Email: "findbyid@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.Username, found.Username)
|
||||
assert.Equal(t, user.Email, found.Email)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的用户", func(t *testing.T) {
|
||||
_, err := store.User.GetByID(ctx, 99999)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("根据用户名查询用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "findbyname",
|
||||
Email: "findbyname@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.User.GetByUsername(ctx, "findbyname")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("更新用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "updatetest",
|
||||
Email: "update@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新用户
|
||||
user.Email = "newemail@example.com"
|
||||
user.Status = constants.UserStatusInactive
|
||||
err = store.User.Update(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "newemail@example.com", found.Email)
|
||||
assert.Equal(t, constants.UserStatusInactive, found.Status)
|
||||
})
|
||||
|
||||
t.Run("软删除用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "deletetest",
|
||||
Email: "delete@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.User.Delete(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除
|
||||
_, err = store.User.GetByID(ctx, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("分页列表查询", func(t *testing.T) {
|
||||
// 创建10个用户
|
||||
for i := 1; i <= 10; i++ {
|
||||
user := &model.User{
|
||||
Username: "listuser" + string(rune('0'+i)),
|
||||
Email: "list" + string(rune('0'+i)) + "@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 第一页
|
||||
users, total, err := store.User.List(ctx, 1, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(users), 5)
|
||||
assert.GreaterOrEqual(t, total, int64(10))
|
||||
|
||||
// 第二页
|
||||
users2, total2, err := store.User.List(ctx, 2, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(users2), 5)
|
||||
assert.Equal(t, total, total2)
|
||||
|
||||
// 验证不同页的数据不同
|
||||
if len(users) > 0 && len(users2) > 0 {
|
||||
assert.NotEqual(t, users[0].ID, users2[0].ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderStore 测试订单 Store 层
|
||||
func TestOrderStore(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "orderuser",
|
||||
Email: "orderuser@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建订单成功", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-TEST-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
err := store.Order.Create(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, order.ID)
|
||||
assert.False(t, order.CreatedAt.IsZero())
|
||||
})
|
||||
|
||||
t.Run("创建重复订单号失败", func(t *testing.T) {
|
||||
order1 := &model.Order{
|
||||
OrderID: "ORD-DUP-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 尝试创建相同订单号
|
||||
order2 := &model.Order{
|
||||
OrderID: "ORD-DUP-001",
|
||||
UserID: user.ID,
|
||||
Amount: 20000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err = store.Order.Create(ctx, order2)
|
||||
assert.Error(t, err, "应该返回唯一约束错误")
|
||||
})
|
||||
|
||||
t.Run("根据ID查询订单", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-FIND-001",
|
||||
UserID: user.ID,
|
||||
Amount: 20000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.OrderID, found.OrderID)
|
||||
assert.Equal(t, order.Amount, found.Amount)
|
||||
})
|
||||
|
||||
t.Run("根据订单号查询", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-FIND-002",
|
||||
UserID: user.ID,
|
||||
Amount: 30000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.Order.GetByOrderID(ctx, "ORD-FIND-002")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("根据用户ID列表查询", func(t *testing.T) {
|
||||
// 创建多个订单
|
||||
for i := 1; i <= 5; i++ {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-LIST-" + string(rune('0'+i)),
|
||||
UserID: user.ID,
|
||||
Amount: int64(i * 10000),
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
orders, total, err := store.Order.ListByUserID(ctx, user.ID, 1, 10)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(orders), 5)
|
||||
assert.GreaterOrEqual(t, total, int64(5))
|
||||
})
|
||||
|
||||
t.Run("更新订单状态", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-UPDATE-001",
|
||||
UserID: user.ID,
|
||||
Amount: 50000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新状态
|
||||
now := time.Now()
|
||||
order.Status = constants.OrderStatusPaid
|
||||
order.PaidAt = &now
|
||||
err = store.Order.Update(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.OrderStatusPaid, found.Status)
|
||||
assert.NotNil(t, found.PaidAt)
|
||||
})
|
||||
|
||||
t.Run("软删除订单", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-DELETE-001",
|
||||
UserID: user.ID,
|
||||
Amount: 60000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.Order.Delete(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除
|
||||
_, err = store.Order.GetByID(ctx, order.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStoreTransaction 测试事务功能
|
||||
func TestStoreTransaction(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("事务提交成功", func(t *testing.T) {
|
||||
var userID uint
|
||||
var orderID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "txuser1",
|
||||
Email: "txuser1@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 创建订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-TX-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
orderID = order.ID
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证用户和订单都已创建
|
||||
user, err := store.User.GetByID(ctx, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "txuser1", user.Username)
|
||||
|
||||
order, err := store.Order.GetByID(ctx, orderID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ORD-TX-001", order.OrderID)
|
||||
})
|
||||
|
||||
t.Run("事务回滚", func(t *testing.T) {
|
||||
var userID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "rollbackuser",
|
||||
Email: "rollback@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 模拟错误,触发回滚
|
||||
return errors.New("模拟错误")
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "模拟错误", err.Error())
|
||||
|
||||
// 验证用户未创建(已回滚)
|
||||
_, err = store.User.GetByID(ctx, userID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("嵌套事务回滚", func(t *testing.T) {
|
||||
var user1ID, user2ID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx1 *postgres.Store) error {
|
||||
// 外层事务:创建第一个用户
|
||||
user1 := &model.User{
|
||||
Username: "nested1",
|
||||
Email: "nested1@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx1.User.Create(ctx, user1); err != nil {
|
||||
return err
|
||||
}
|
||||
user1ID = user1.ID
|
||||
|
||||
// 内层事务:创建第二个用户并失败
|
||||
err := tx1.Transaction(ctx, func(tx2 *postgres.Store) error {
|
||||
user2 := &model.User{
|
||||
Username: "nested2",
|
||||
Email: "nested2@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx2.User.Create(ctx, user2); err != nil {
|
||||
return err
|
||||
}
|
||||
user2ID = user2.ID
|
||||
|
||||
// 内层事务失败
|
||||
return errors.New("内层事务失败")
|
||||
})
|
||||
|
||||
// 内层事务失败导致外层事务也失败
|
||||
return err
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
|
||||
// 验证两个用户都未创建
|
||||
_, err = store.User.GetByID(ctx, user1ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = store.User.GetByID(ctx, user2ID)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConcurrentAccess 测试并发访问
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("并发创建用户", func(t *testing.T) {
|
||||
concurrency := 20
|
||||
errChan := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(index int) {
|
||||
user := &model.User{
|
||||
Username: "concurrent" + string(rune('A'+index)),
|
||||
Email: "concurrent" + string(rune('A'+index)) + "@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
errChan <- store.User.Create(ctx, user)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
successCount := 0
|
||||
for i := 0; i < concurrency; i++ {
|
||||
err := <-errChan
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, concurrency, successCount, "所有并发创建应该成功")
|
||||
})
|
||||
|
||||
t.Run("并发读写同一用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "rwuser",
|
||||
Email: "rwuser@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
concurrency := 10
|
||||
done := make(chan bool, concurrency*2)
|
||||
|
||||
// 并发读
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
_, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// 并发写
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(index int) {
|
||||
user.Status = constants.UserStatusActive
|
||||
err := store.User.Update(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 等待所有操作完成
|
||||
for i := 0; i < concurrency*2; i++ {
|
||||
<-done
|
||||
}
|
||||
})
|
||||
}
|
||||
390
tests/unit/task_handler_test.go
Normal file
390
tests/unit/task_handler_test.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// MockEmailPayload 邮件任务载荷(测试用)
|
||||
type MockEmailPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
CC []string `json:"cc,omitempty"`
|
||||
}
|
||||
|
||||
// TestHandlerIdempotency 测试处理器幂等性逻辑
|
||||
func TestHandlerIdempotency(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
requestID := "test-req-001"
|
||||
lockKey := constants.RedisTaskLockKey(requestID)
|
||||
|
||||
// 测试场景1: 第一次执行(未加锁)
|
||||
t.Run("First Execution - Should Acquire Lock", func(t *testing.T) {
|
||||
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result, "第一次执行应该成功获取锁")
|
||||
})
|
||||
|
||||
// 测试场景2: 重复执行(已加锁)
|
||||
t.Run("Duplicate Execution - Should Skip", func(t *testing.T) {
|
||||
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result, "重复执行应该跳过(锁已存在)")
|
||||
})
|
||||
|
||||
// 清理
|
||||
redisClient.Del(ctx, lockKey)
|
||||
}
|
||||
|
||||
// TestHandlerErrorHandling 测试处理器错误处理
|
||||
func TestHandlerErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload MockEmailPayload
|
||||
shouldError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid Payload",
|
||||
payload: MockEmailPayload{
|
||||
RequestID: "valid-001",
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test Body",
|
||||
},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Missing RequestID",
|
||||
payload: MockEmailPayload{
|
||||
RequestID: "",
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test Body",
|
||||
},
|
||||
shouldError: true,
|
||||
errorMsg: "request_id 不能为空",
|
||||
},
|
||||
{
|
||||
name: "Missing To",
|
||||
payload: MockEmailPayload{
|
||||
RequestID: "test-002",
|
||||
To: "",
|
||||
Subject: "Test",
|
||||
Body: "Test Body",
|
||||
},
|
||||
shouldError: true,
|
||||
errorMsg: "收件人不能为空",
|
||||
},
|
||||
{
|
||||
name: "Invalid Email Format",
|
||||
payload: MockEmailPayload{
|
||||
RequestID: "test-003",
|
||||
To: "invalid-email",
|
||||
Subject: "Test",
|
||||
Body: "Test Body",
|
||||
},
|
||||
shouldError: true,
|
||||
errorMsg: "邮箱格式无效",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证载荷
|
||||
err := validateEmailPayload(&tt.payload)
|
||||
|
||||
if tt.shouldError {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// validateEmailPayload 验证邮件载荷(模拟实际处理器中的验证逻辑)
|
||||
func validateEmailPayload(payload *MockEmailPayload) error {
|
||||
if payload.RequestID == "" {
|
||||
return asynq.SkipRetry // 参数错误不重试
|
||||
}
|
||||
if payload.To == "" {
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
// 简单的邮箱格式验证
|
||||
if payload.To != "" && !contains(payload.To, "@") {
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i < len(s)-len(substr)+1; i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestHandlerRetryLogic 测试重试逻辑
|
||||
func TestHandlerRetryLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
error error
|
||||
shouldRetry bool
|
||||
}{
|
||||
{
|
||||
name: "Retryable Error - Network Issue",
|
||||
error: assert.AnError,
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "Non-Retryable Error - Invalid Params",
|
||||
error: asynq.SkipRetry,
|
||||
shouldRetry: false,
|
||||
},
|
||||
{
|
||||
name: "No Error",
|
||||
error: nil,
|
||||
shouldRetry: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
shouldRetry := tt.error != nil && tt.error != asynq.SkipRetry
|
||||
assert.Equal(t, tt.shouldRetry, shouldRetry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPayloadDeserialization 测试载荷反序列化
|
||||
func TestPayloadDeserialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonPayload string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid JSON",
|
||||
jsonPayload: `{"request_id":"test-001","to":"test@example.com","subject":"Test","body":"Body"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
jsonPayload: `{invalid json}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Empty JSON",
|
||||
jsonPayload: `{}`,
|
||||
expectError: false, // JSON 解析成功,但验证会失败
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var payload MockEmailPayload
|
||||
err := sonic.Unmarshal([]byte(tt.jsonPayload), &payload)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskStatusTransition 测试任务状态转换
|
||||
func TestTaskStatusTransition(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
taskID := "task-transition-001"
|
||||
statusKey := constants.RedisTaskStatusKey(taskID)
|
||||
|
||||
// 状态转换序列
|
||||
transitions := []struct {
|
||||
status string
|
||||
valid bool
|
||||
}{
|
||||
{"pending", true},
|
||||
{"processing", true},
|
||||
{"completed", true},
|
||||
{"failed", false}, // completed 后不应该转到 failed
|
||||
}
|
||||
|
||||
currentStatus := ""
|
||||
for _, tr := range transitions {
|
||||
t.Run("Transition to "+tr.status, func(t *testing.T) {
|
||||
// 检查状态转换是否合法
|
||||
if isValidTransition(currentStatus, tr.status) == tr.valid {
|
||||
err := redisClient.Set(ctx, statusKey, tr.status, 7*24*time.Hour).Err()
|
||||
require.NoError(t, err)
|
||||
currentStatus = tr.status
|
||||
} else {
|
||||
// 不合法的转换应该被拒绝
|
||||
assert.False(t, tr.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isValidTransition 检查状态转换是否合法
|
||||
func isValidTransition(from, to string) bool {
|
||||
validTransitions := map[string][]string{
|
||||
"": {"pending"},
|
||||
"pending": {"processing", "failed"},
|
||||
"processing": {"completed", "failed"},
|
||||
"completed": {}, // 终态
|
||||
"failed": {}, // 终态
|
||||
}
|
||||
|
||||
allowed, exists := validTransitions[from]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, valid := range allowed {
|
||||
if valid == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestConcurrentTaskExecution 测试并发任务执行
|
||||
func TestConcurrentTaskExecution(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
// 模拟多个并发任务尝试获取同一个锁
|
||||
requestID := "concurrent-test-001"
|
||||
lockKey := constants.RedisTaskLockKey(requestID)
|
||||
|
||||
concurrency := 10
|
||||
successCount := 0
|
||||
|
||||
done := make(chan bool, concurrency)
|
||||
|
||||
// 并发执行
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
if err == nil && result {
|
||||
successCount++
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// 等待所有 goroutine 完成
|
||||
for i := 0; i < concurrency; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// 验证只有一个成功获取锁
|
||||
assert.Equal(t, 1, successCount, "只有一个任务应该成功获取锁")
|
||||
}
|
||||
|
||||
// TestTaskTimeout 测试任务超时处理
|
||||
func TestTaskTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskDuration time.Duration
|
||||
timeout time.Duration
|
||||
shouldTimeout bool
|
||||
}{
|
||||
{
|
||||
name: "Normal Execution",
|
||||
taskDuration: 100 * time.Millisecond,
|
||||
timeout: 1 * time.Second,
|
||||
shouldTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "Timeout Execution",
|
||||
taskDuration: 2 * time.Second,
|
||||
timeout: 500 * time.Millisecond,
|
||||
shouldTimeout: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
|
||||
defer cancel()
|
||||
|
||||
// 模拟任务执行
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
time.Sleep(tt.taskDuration)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
assert.False(t, tt.shouldTimeout, "任务应该正常完成")
|
||||
case <-ctx.Done():
|
||||
assert.True(t, tt.shouldTimeout, "任务应该超时")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockExpiration 测试锁过期机制
|
||||
func TestLockExpiration(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
requestID := "expiration-test-001"
|
||||
lockKey := constants.RedisTaskLockKey(requestID)
|
||||
|
||||
// 设置短 TTL 的锁
|
||||
result, err := redisClient.SetNX(ctx, lockKey, "1", 100*time.Millisecond).Result()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result)
|
||||
|
||||
// 等待锁过期
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// 验证锁已过期,可以重新获取
|
||||
result, err = redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result, "锁过期后应该可以重新获取")
|
||||
}
|
||||
Reference in New Issue
Block a user