From e98dd4d7255646f97653d46691cfeb4a064a5ee1 Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 11 Nov 2025 15:16:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9A=E5=AE=8C=E4=BA=86=E4=B8=80=E9=83=A8?= =?UTF-8?q?=E5=88=86,=E5=A4=87=E4=BB=BD=E4=B8=80=E4=B8=8B,=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E4=BB=A5=E5=A4=96=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 65 +++ .specify/memory/constitution.md | 372 ++++++++++++++++-- .specify/templates/plan-template.md | 5 + .specify/templates/spec-template.md | 5 + .specify/templates/tasks-template.md | 5 + cmd/api/main.go | 174 +++++++- config/config-dev.yaml | 0 config/config-prod.yaml | 0 config/config-test.yaml | 0 configs/config.dev.yaml | 41 ++ configs/config.prod.yaml | 40 ++ configs/config.staging.yaml | 40 ++ configs/config.yaml | 41 ++ go.mod | 27 +- go.sum | 179 ++++++++- internal/handler/health.go | 17 + internal/handler/user.go | 33 ++ internal/middleware/auth.go | 53 +++ internal/middleware/ratelimit.go | 41 ++ internal/middleware/recover.go | 44 +++ main.go | 9 - pkg/config/config.go | 167 ++++++++ pkg/config/loader.go | 93 +++++ pkg/config/watcher.go | 43 ++ pkg/constants/constants.go | 21 + pkg/constants/redis.go | 13 + pkg/database/redis.go | 53 +++ pkg/errors/codes.go | 39 ++ pkg/errors/errors.go | 49 +++ pkg/logger/logger.go | 146 +++++++ pkg/logger/middleware.go | 52 +++ pkg/response/response.go | 46 +++ pkg/validator/token.go | 65 +++ .../data-model.md | 217 ++++++++-- .../optional-improvements.md | 218 ++++++++++ .../001-fiber-middleware-integration/plan.md | 14 +- .../research.md | 60 +-- .../001-fiber-middleware-integration/spec.md | 64 ++- .../001-fiber-middleware-integration/tasks.md | 55 +-- 39 files changed, 2423 insertions(+), 183 deletions(-) create mode 100644 .gitignore delete mode 100644 config/config-dev.yaml delete mode 100644 config/config-prod.yaml delete mode 100644 config/config-test.yaml create mode 100644 configs/config.dev.yaml create mode 100644 configs/config.prod.yaml create mode 100644 configs/config.staging.yaml create mode 100644 configs/config.yaml create mode 100644 internal/handler/health.go create mode 100644 internal/handler/user.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/middleware/recover.go delete mode 100644 main.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/loader.go create mode 100644 pkg/config/watcher.go create mode 100644 pkg/constants/constants.go create mode 100644 pkg/constants/redis.go create mode 100644 pkg/database/redis.go create mode 100644 pkg/errors/codes.go create mode 100644 pkg/errors/errors.go create mode 100644 pkg/logger/logger.go create mode 100644 pkg/logger/middleware.go create mode 100644 pkg/response/response.go create mode 100644 pkg/validator/token.go create mode 100644 specs/001-fiber-middleware-integration/optional-improvements.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b25dc08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +*.test +*.out + +# Go workspace file +go.work + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage/ +*.coverprofile + +# Dependency directories +vendor/ + +# Go build cache +.cache/ + +# Environment variables +.env +.env.* +!.env.example + +# Log files +logs/ +*.log + +# IDEs and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.bak + +# Redis dump +dump.rdb + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Configuration overrides (keep templates) +config/local.yaml +configs/local.yaml + +# Build output +dist/ +build/ diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index f5d5399..8c391d8 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,42 +1,36 @@ # 君鸿卡管系统 Constitution @@ -107,10 +101,322 @@ excessive abstraction, and OOP-heavy designs. key := "sim:status:" + iccid ``` +**Magic Numbers 和硬编码规则 (Magic Numbers and Hardcoding Rules):** + +- **MUST NOT** 在代码中直接使用 magic numbers(未定义含义的数字字面量) +- **MUST NOT** 在代码中硬编码字符串字面量(URL、状态码、配置值、业务规则等) +- 当相同的字面量值在 **3 个或以上位置**使用时,**MUST** 提取为常量 +- 已定义的常量 **MUST** 被使用,**MUST NOT** 重复硬编码相同的值 +- 只允许在以下情况使用字面量: + - 语言特性:`nil`、`true`、`false` + - 数学常量:`0`、`1`、`-1`(用于循环、索引、比较等明确的上下文) + - 一次性使用的临时值(测试数据、日志消息等) + +**正确的常量使用:** +```go +// pkg/constants/constants.go +const ( + DefaultPageSize = 20 + MaxPageSize = 100 + MinPageSize = 1 + + SIMStatusActive = "active" + SIMStatusInactive = "inactive" + SIMStatusSuspended = "suspended" + + OrderTypeRecharge = "recharge" + OrderTypeTransfer = "transfer" +) + +// internal/handler/user.go +func (h *Handler) ListUsers(c *fiber.Ctx) error { + page := c.QueryInt("page", 1) + pageSize := c.QueryInt("page_size", constants.DefaultPageSize) // ✅ 使用常量 + + if pageSize > constants.MaxPageSize { // ✅ 使用常量 + pageSize = constants.MaxPageSize + } + + users, err := h.service.List(page, pageSize) + // ... +} + +// internal/service/sim.go +func (s *Service) Activate(iccid string) error { + return s.store.UpdateStatus(iccid, constants.SIMStatusActive) // ✅ 使用常量 +} +``` + +**错误的硬编码模式:** +```go +// ❌ 硬编码 magic number(在多处使用) +func ListUsers(c *fiber.Ctx) error { + pageSize := c.QueryInt("page_size", 20) // ❌ 硬编码 20 + if pageSize > 100 { // ❌ 硬编码 100 + pageSize = 100 + } + // ... +} + +func ListOrders(c *fiber.Ctx) error { + pageSize := c.QueryInt("page_size", 20) // ❌ 重复硬编码 20 + // ... +} + +// ❌ 硬编码字符串(在多处使用) +func ActivateSIM(iccid string) error { + return UpdateStatus(iccid, "active") // ❌ 硬编码 "active" +} + +func IsSIMActive(sim *SIM) bool { + return sim.Status == "active" // ❌ 重复硬编码 "active" +} + +// ❌ 已定义常量但不使用 +// 常量已定义在 pkg/response/codes.go +func Success(c *fiber.Ctx, data any) error { + return c.JSON(Response{ + Code: 0, // ❌ 应该使用 errors.CodeSuccess + Data: data, + }) +} +``` + +**可接受的字面量使用:** +```go +// ✅ 语言特性和明确上下文的数字 +if user == nil { // ✅ nil + return errors.New("user not found") +} + +for i := 0; i < len(items); i++ { // ✅ 0, 1 用于循环 + // ... +} + +if count == 1 { // ✅ 1 用于比较 + // 特殊处理单个元素 +} + +// ✅ 测试数据和日志消息 +func TestUserCreate(t *testing.T) { + user := &User{ + Name: "Test User", // ✅ 测试数据 + Email: "test@example.com", + } + // ... +} + +logger.Info("processing request", // ✅ 日志消息 + zap.String("path", c.Path()), + zap.Int("status", 200)) +``` + +**中文注释和输出规范 (Chinese Comments and Output Standards):** + +本项目面向中文开发者,为提高代码可读性和维护效率,**SHOULD** 优先使用中文进行注释和输出。 + +- 代码注释(implementation comments)**SHOULD** 使用中文 +- 日志消息(log messages)**SHOULD** 使用中文 +- 用户可见的错误消息 **MUST** 使用中文(通过 `pkg/errors/` 的双语消息支持) +- 内部错误消息和调试日志 **SHOULD** 使用中文 +- Go 文档注释(doc comments for exported APIs)**MAY** 使用英文以保持生态兼容性,但中文注释更佳 +- 变量名、函数名、类型名 **MUST** 使用英文(遵循 Go 命名规范) + +**正确的中文注释使用:** +```go +// GetUserByID 根据用户 ID 获取用户信息 +// 如果用户不存在,返回 NotFoundError +func (s *Service) GetUserByID(ctx context.Context, id string) (*User, error) { + // 参数验证 + if id == "" { + return nil, errors.New(errors.CodeInvalidParam, "用户 ID 不能为空") + } + + // 从存储层获取用户 + user, err := s.store.GetByID(ctx, id) + if err != nil { + s.logger.Error("获取用户失败", + zap.String("user_id", id), + zap.Error(err)) + return nil, fmt.Errorf("获取用户失败: %w", err) + } + + // 检查用户状态 + if user.Status != constants.UserStatusActive { + s.logger.Warn("用户状态异常", + zap.String("user_id", id), + zap.String("status", user.Status)) + } + + return user, nil +} + +// CreateOrder 创建新订单 +func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) { + // 开启事务 + tx := s.db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + s.logger.Error("创建订单时发生panic", zap.Any("panic", r)) + } + }() + + // 验证库存 + if err := s.validateStock(ctx, req.ProductID, req.Quantity); err != nil { + tx.Rollback() + return nil, fmt.Errorf("库存验证失败: %w", err) + } + + // 创建订单记录 + order := &Order{ + UserID: req.UserID, + ProductID: req.ProductID, + Quantity: req.Quantity, + Status: constants.OrderStatusPending, + } + + if err := tx.Create(order).Error; err != nil { + tx.Rollback() + s.logger.Error("创建订单失败", + zap.String("user_id", req.UserID), + zap.Error(err)) + return nil, err + } + + tx.Commit() + s.logger.Info("订单创建成功", + zap.String("order_id", order.ID), + zap.String("user_id", req.UserID)) + + return order, nil +} +``` + +**正确的中文日志使用:** +```go +// 信息日志 +logger.Info("服务启动成功", + zap.String("host", cfg.Server.Host), + zap.Int("port", cfg.Server.Port)) + +logger.Info("配置热重载成功", + zap.String("config_file", "config.yaml"), + zap.Time("reload_time", time.Now())) + +// 警告日志 +logger.Warn("Redis 连接延迟较高", + zap.Duration("latency", latency), + zap.String("threshold", "50ms")) + +// 错误日志 +logger.Error("数据库连接失败", + zap.String("host", cfg.DB.Host), + zap.Int("port", cfg.DB.Port), + zap.Error(err)) + +logger.Error("令牌验证失败", + zap.String("token", token[:10]+"..."), + zap.String("client_ip", clientIP), + zap.Error(err)) + +// 调试日志 +logger.Debug("处理用户请求", + zap.String("request_id", requestID), + zap.String("method", c.Method()), + zap.String("path", c.Path()), + zap.String("user_id", userID)) +``` + +**用户可见错误消息(双语支持):** +```go +// pkg/errors/codes.go +var errorMessages = map[int]ErrorMessage{ + CodeSuccess: {"Success", "成功"}, + CodeInternalError: {"Internal server error", "内部服务器错误"}, + CodeMissingToken: {"Missing authentication token", "缺失认证令牌"}, + CodeInvalidToken: {"Invalid or expired token", "令牌无效或已过期"}, + CodeTooManyRequests: {"Too many requests", "请求过于频繁"}, + CodeAuthServiceUnavailable: {"Authentication service unavailable", "认证服务不可用"}, +} + +// 使用时根据语言返回对应消息 +message := errors.GetMessage(code, "zh") // 返回中文消息 +``` + +**错误的英文注释(不推荐):** +```go +// ❌ 全英文注释(可读性较差,不便于中文团队维护) +// GetUserByID retrieves user information by user ID +// Returns NotFoundError if user does not exist +func (s *Service) GetUserByID(ctx context.Context, id string) (*User, error) { + // Validate parameters + if id == "" { + return nil, errors.New(errors.CodeInvalidParam, "用户 ID 不能为空") + } + + // Fetch user from store + user, err := s.store.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get user", // ❌ 英文日志 + zap.String("user_id", id), + zap.Error(err)) + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return user, nil +} +``` + +**例外情况(可使用英文):** +```go +// ✅ 导出的包级别文档注释可使用英文(生态兼容性) +// Package user provides user management functionality. +// It includes user CRUD operations, authentication, and authorization. +package user + +// ✅ 导出的 API 文档注释可使用英文 +// UserService provides user-related business logic operations. +type UserService struct { + store UserStorer + logger *zap.Logger +} + +// ✅ 但内部实现注释应使用中文 +func (s *UserService) Create(ctx context.Context, req *CreateUserRequest) (*User, error) { + // 验证用户输入 + if err := req.Validate(); err != nil { + return nil, err + } + + // 检查用户是否已存在 + existing, _ := s.store.GetByEmail(ctx, req.Email) + if existing != nil { + return nil, errors.New(errors.CodeUserExists, "用户邮箱已存在") + } + + // 创建用户记录 + user := &User{ + Name: req.Name, + Email: req.Email, + } + + return s.store.Create(ctx, user) +} +``` + **理由 (RATIONALE):** 清晰的分层架构和代码组织使代码易于理解、测试和维护。统一的错误处理和响应格式提升 API 一致性和客户端集成体验。依赖注入模式便于单元测试和模块替换。集中管理常量和 Redis key 避免拼写错误、重复定义和命名不一致,提升代码可维护性和重构安全性。Redis key 统一管理便于监控、调试和缓存策略调整。遵循 Go 官方代码风格确保代码一致性和可读性。 +避免硬编码和强制使用常量的规则能够: +1. **提高可维护性**:修改常量值只需改一处,不需要搜索所有硬编码位置 +2. **减少错误**:避免手动输入错误(拼写错误、大小写错误) +3. **增强可读性**:`constants.MaxPageSize` 比 `100` 更能表达意图 +4. **便于重构**:IDE 可以追踪常量使用,重命名时不会遗漏 +5. **统一业务规则**:确保所有地方使用相同的业务规则值 +6. "3 次规则"提供明确的阈值,避免过早优化,同时确保重复值被及时抽取 + --- ### III. Testing Standards (测试标准) @@ -689,7 +995,12 @@ Go 语言的设计哲学强调简单性、直接性和实用性。Java 风格的 - [ ] 无 TODO/FIXME 遗留(或已记录 Issue) - [ ] API 文档已更新(如有 API 变更) - [ ] 数据库迁移文件已创建(如有 schema 变更) -- [ ] 常量和 Redis key 使用符合规范(无硬编码字符串) +- [ ] **常量和 Redis key 使用符合规范**(无硬编码字符串) +- [ ] **无重复硬编码值**(3 次以上相同字面量已提取为常量) +- [ ] **已定义的常量被正确使用**(无重复硬编码已有常量的值) +- [ ] **代码注释优先使用中文**(实现注释使用中文,提高团队可读性) +- [ ] **日志消息使用中文**(Info/Warn/Error/Debug 日志使用中文描述) +- [ ] **错误消息支持中文**(用户可见错误有中文消息) - [ ] **无 Java 风格反模式**(无 getter/setter、无不必要接口、无过度抽象) - [ ] **遵循 Go 命名约定**(缩写大小写一致、包名简短、无下划线) - [ ] **使用 Go 惯用并发模式**(goroutines/channels,无线程池类) @@ -726,12 +1037,13 @@ Go 语言的设计哲学强调简单性、直接性和实用性。Java 风格的 - 所有 PR 审查 **MUST** 验证是否符合本宪章 - 违反宪章的代码 **MUST** 在合并前修正 - 特别关注 Go 惯用法原则,拒绝 Java 风格的代码 +- 特别关注常量使用规范,拒绝不必要的硬编码 - 任何复杂性增加(新依赖、新架构层)**MUST** 在设计文档中明确说明必要性 ### 运行时开发指导 -开发时参考本宪章确保一致性。如有疑问,优先遵守原则,再讨论例外情况。记住:**写 Go 代码,不是用 Go 语法写 Java**。 +开发时参考本宪章确保一致性。如有疑问,优先遵守原则,再讨论例外情况。记住:**写 Go 代码,不是用 Go 语法写 Java**。记住:**定义常量是为了使用,不是为了装饰**。 --- -**Version**: 2.0.0 | **Ratified**: 2025-11-10 | **Last Amended**: 2025-11-10 +**Version**: 2.1.1 | **Ratified**: 2025-11-10 | **Last Amended**: 2025-11-11 diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index 28cb170..db0396f 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -50,6 +50,11 @@ - [ ] 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 diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index 19bfa45..d4abeb9 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -112,6 +112,11 @@ - [ ] Unified error codes defined in `pkg/errors/` - [ ] Unified API responses via `pkg/response/` - [ ] All constants defined in `pkg/constants/` (no magic numbers/strings) +- [ ] **No hardcoded values: 3+ identical literals must become constants** +- [ ] **Defined constants must be used (no duplicate hardcoding)** +- [ ] **Code comments prefer Chinese (implementation comments in Chinese)** +- [ ] **Log messages use Chinese (logger.Info/Warn/Error/Debug in Chinese)** +- [ ] **Error messages support Chinese (user-facing errors have Chinese text)** - [ ] All Redis keys managed via `pkg/constants/` key generation functions - [ ] Package structure is flat, organized by feature (not by layer) diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md index 73523d2..b7ecb02 100644 --- a/.specify/templates/tasks-template.md +++ b/.specify/templates/tasks-template.md @@ -181,6 +181,11 @@ Foundational tasks for 君鸿卡管系统 tech stack: - [ ] TXXX Quality Gate: Verify database migrations work correctly - [ ] TXXX Quality Gate: Verify API documentation updated (if API changes) - [ ] TXXX Quality Gate: Verify no hardcoded constants or Redis keys (all use pkg/constants/) +- [ ] TXXX Quality Gate: Verify no duplicate hardcoded values (3+ identical literals must be constants) +- [ ] TXXX Quality Gate: Verify defined constants are used (no duplicate hardcoding of constant values) +- [ ] TXXX Quality Gate: Verify code comments use Chinese (implementation comments in Chinese) +- [ ] TXXX Quality Gate: Verify log messages use Chinese (logger Info/Warn/Error/Debug in Chinese) +- [ ] TXXX Quality Gate: Verify error messages support Chinese (user-facing errors have Chinese text) - [ ] TXXX Quality Gate: Verify no Java-style anti-patterns (no getter/setter, no I-prefix, no Impl-suffix) - [ ] TXXX Quality Gate: Verify Go naming conventions (UserID not userId, HTTPServer not HttpServer) - [ ] TXXX Quality Gate: Verify error handling is explicit (no panic/recover abuse) diff --git a/cmd/api/main.go b/cmd/api/main.go index 012e683..c56e7d1 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,25 +1,185 @@ package main import ( + "context" + "os" + "os/signal" + "strconv" + "syscall" + "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/requestid" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/handler" + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/logger" + "github.com/break/junhong_cmp_fiber/pkg/validator" ) 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 logger.Sync() + + appLogger := logger.GetAppLogger() + appLogger.Info("应用程序启动中...", + zap.String("address", cfg.Server.Address), + ) + redisAddr := cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port) + // 连接 Redis + 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 redisClient.Close() + + // 测试 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)) + + // 创建令牌验证器 + tokenValidator := validator.NewTokenValidator(redisClient, appLogger) + + // 启动配置文件监听器(热重载) + watchCtx, cancelWatch := context.WithCancel(context.Background()) + defer cancelWatch() + go config.Watch(watchCtx, appLogger) + + // 创建 Fiber 应用 app := fiber.New(fiber.Config{ - AppName: "君鸿卡管系统 v0.0.1", + AppName: "君鸿卡管系统 v1.0.0", StrictRouting: true, CaseSensitive: true, JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, - // Prefork: true, // 该字段是用来开启监听端口是否可以被多个应用监听的,非必要不要开了 + Prefork: cfg.Server.Prefork, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, }) - //压缩中间件-根据Accept-Encoding使用gzip 、 deflate 和 brotli 压缩来压缩响应 - app.Use(compress.New(compress.Config{ - // Next: func(c *fiber.Ctx) bool { return c.Path() == "/test"}, //返回值为true时会跳过压缩中间件 - Level: 0, + + // 中间件注册(顺序很重要) + // 1. Recover - 必须第一个,捕获所有 panic + app.Use(middleware.Recover(appLogger)) + + // 2. RequestID - 为每个请求生成唯一 ID + app.Use(requestid.New(requestid.Config{ + Generator: func() string { + return uuid.NewString() + }, })) - app.Listen(":3000") + // 3. Logger - 记录所有请求 + app.Use(logger.Middleware()) + + // 4. Compress - 响应压缩 + app.Use(compress.New(compress.Config{ + Level: compress.LevelDefault, + })) + + // 路由注册 + + // 公共端点(无需认证) + app.Get("/health", handler.HealthCheck) + + // API v1 路由组 + v1 := app.Group("/api/v1") + + // 受保护的端点(需要认证) + if cfg.Middleware.EnableAuth { + v1.Use(middleware.KeyAuth(tokenValidator, appLogger)) + } + + // 可选:启用限流器 + if cfg.Middleware.EnableRateLimiter { + var rateLimitStorage fiber.Storage + + // 根据配置选择存储后端 + if cfg.Middleware.RateLimiter.Storage == "redis" { + rateLimitStorage = middleware.NewRedisStorage( + cfg.Redis.Address, + cfg.Redis.Password, + cfg.Redis.DB, + cfg.Redis.Port, + ) + appLogger.Info("限流器使用 Redis 存储", zap.String("redis_address", cfg.Redis.Address)) + } else { + rateLimitStorage = nil // 使用内存存储 + appLogger.Info("限流器使用内存存储") + } + + v1.Use(middleware.RateLimiter( + cfg.Middleware.RateLimiter.Max, + cfg.Middleware.RateLimiter.Expiration, + rateLimitStorage, + )) + } + + // 注册受保护的路由 + v1.Get("/users", handler.GetUsers) + + // 优雅关闭 + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + go func() { + if err := app.Listen(cfg.Server.Address); err != nil { + appLogger.Fatal("服务器启动失败", zap.Error(err)) + } + }() + + appLogger.Info("服务器已启动", zap.String("address", cfg.Server.Address)) + + // 等待关闭信号 + <-quit + appLogger.Info("正在关闭服务器...") + + // 取消配置监听器 + cancelWatch() + + // 关闭 HTTP 服务器 + if err := app.ShutdownWithTimeout(cfg.Server.ShutdownTimeout); err != nil { + appLogger.Error("强制关闭服务器", zap.Error(err)) + } + + appLogger.Info("服务器已停止") } diff --git a/config/config-dev.yaml b/config/config-dev.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/config/config-prod.yaml b/config/config-prod.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/config/config-test.yaml b/config/config-test.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/configs/config.dev.yaml b/configs/config.dev.yaml new file mode 100644 index 0000000..6c8896b --- /dev/null +++ b/configs/config.dev.yaml @@ -0,0 +1,41 @@ +server: + address: ":3000" + read_timeout: "10s" + write_timeout: "10s" + shutdown_timeout: "30s" + prefork: false + +redis: + address: "cxd.whcxd.cn" + password: "cpNbWtAaqgo1YJmbMp3h" + port: 16299 + db: 0 + pool_size: 10 + min_idle_conns: 5 + dial_timeout: "5s" + read_timeout: "3s" + write_timeout: "3s" + +logging: + level: "debug" # 开发环境使用 debug 级别 + development: true # 启用开发模式(美化日志输出) + app_log: + filename: "logs/app.log" + max_size: 100 + max_backups: 10 # 开发环境保留较少备份 + max_age: 7 # 7天 + compress: false # 开发环境不压缩 + access_log: + filename: "logs/access.log" + max_size: 100 + max_backups: 10 + max_age: 7 + compress: false + +middleware: + enable_auth: false # 开发环境可选禁用认证 + enable_rate_limiter: false + rate_limiter: + max: 1000 + expiration: "1m" + storage: "memory" diff --git a/configs/config.prod.yaml b/configs/config.prod.yaml new file mode 100644 index 0000000..d04093f --- /dev/null +++ b/configs/config.prod.yaml @@ -0,0 +1,40 @@ +server: + address: ":8080" + read_timeout: "10s" + write_timeout: "10s" + shutdown_timeout: "30s" + prefork: true # 生产环境启用多进程模式 + +redis: + address: "redis-prod:6379" + password: "${REDIS_PASSWORD}" + db: 0 + pool_size: 50 # 生产环境更大的连接池 + min_idle_conns: 20 + dial_timeout: "5s" + read_timeout: "3s" + write_timeout: "3s" + +logging: + level: "warn" # 生产环境较少详细日志 + development: false + app_log: + filename: "logs/app.log" + max_size: 100 + max_backups: 60 + max_age: 60 + compress: true + access_log: + filename: "logs/access.log" + max_size: 500 + max_backups: 180 + max_age: 180 + compress: true + +middleware: + enable_auth: true + enable_rate_limiter: true + rate_limiter: + max: 5000 + expiration: "1m" + storage: "redis" # 生产环境使用 Redis 分布式限流 diff --git a/configs/config.staging.yaml b/configs/config.staging.yaml new file mode 100644 index 0000000..cd87201 --- /dev/null +++ b/configs/config.staging.yaml @@ -0,0 +1,40 @@ +server: + address: ":8080" + read_timeout: "10s" + write_timeout: "10s" + shutdown_timeout: "30s" + prefork: false + +redis: + address: "redis-staging:6379" + password: "${REDIS_PASSWORD}" # 从环境变量读取 + db: 0 + pool_size: 20 + min_idle_conns: 10 + dial_timeout: "5s" + read_timeout: "3s" + write_timeout: "3s" + +logging: + level: "info" + development: false + app_log: + filename: "logs/app.log" + max_size: 100 + max_backups: 30 + max_age: 30 + compress: true + access_log: + filename: "logs/access.log" + max_size: 500 + max_backups: 90 + max_age: 90 + compress: true + +middleware: + enable_auth: true + enable_rate_limiter: true + rate_limiter: + max: 1000 + expiration: "1m" + storage: "memory" diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..27ab34a --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,41 @@ +server: + address: ":3000" + read_timeout: "10s" + write_timeout: "10s" + shutdown_timeout: "30s" + prefork: false + +redis: + address: "cxd.whcxd.cn" + password: "cpNbWtAaqgo1YJmbMp3h" + port: 16299 + db: 0 + pool_size: 10 + min_idle_conns: 5 + dial_timeout: "5s" + read_timeout: "3s" + write_timeout: "3s" + +logging: + level: "info" + development: false + app_log: + filename: "logs/app.log" + max_size: 100 # MB + max_backups: 30 + max_age: 30 # 天 + compress: true + access_log: + filename: "logs/access.log" + max_size: 500 # MB + max_backups: 90 + max_age: 90 # 天 + compress: true + +middleware: + enable_auth: true + enable_rate_limiter: false # 默认禁用 + rate_limiter: + max: 100 # 请求数 + expiration: "1m" # 每分钟 + storage: "memory" # 或 "redis" diff --git a/go.mod b/go.mod index b34d495..16db88d 100644 --- a/go.mod +++ b/go.mod @@ -1,28 +1,49 @@ -module junhong_cmp_fiber +module github.com/break/junhong_cmp_fiber go 1.25.1 require ( github.com/bytedance/sonic v1.14.2 + github.com/fsnotify/fsnotify v1.9.0 github.com/gofiber/fiber/v2 v2.52.9 + github.com/gofiber/storage/redis/v3 v3.4.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.16.0 + github.com/spf13/viper v1.21.0 + go.uber.org/zap v1.27.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( 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/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // 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/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // 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 + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.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/sys v0.38.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 8d87d67..ba60f32 100644 --- a/go.sum +++ b/go.sum @@ -1,49 +1,151 @@ +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= +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= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= 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/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/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= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +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/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/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/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= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +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/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-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= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/storage/redis/v3 v3.4.1 h1:feZc1xv1UuW+a1qnpISPaak7r/r0SkNVFHmg9R7PJ/c= +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/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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +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= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +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/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= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +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/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= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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/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/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= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -51,29 +153,64 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ 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= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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/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= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +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.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= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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/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.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= 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/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= diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..04ba1a7 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,17 @@ +package handler + +import ( + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// HealthCheck 健康检查处理器 +func HealthCheck(c *fiber.Ctx) error { + return response.Success(c, fiber.Map{ + "status": "healthy", + "timestamp": time.Now().Format(time.RFC3339), + }) +} diff --git a/internal/handler/user.go b/internal/handler/user.go new file mode 100644 index 0000000..5a846c6 --- /dev/null +++ b/internal/handler/user.go @@ -0,0 +1,33 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// GetUsers 获取用户列表(示例受保护端点) +func GetUsers(c *fiber.Ctx) error { + // 从上下文获取用户 ID(由 auth 中间件设置) + userID := c.Locals(constants.ContextKeyUserID) + + // 示例数据 + users := []fiber.Map{ + { + "id": "user-123", + "name": "张三", + "email": "zhangsan@example.com", + }, + { + "id": "user-456", + "name": "李四", + "email": "lisi@example.com", + }, + } + + return response.SuccessWithMessage(c, fiber.Map{ + "users": users, + "authenticated_as": userID, + }, "success") +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..312862e --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/keyauth" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/break/junhong_cmp_fiber/pkg/validator" +) + +// KeyAuth 创建基于 Redis 的令牌认证中间件 +func KeyAuth(v *validator.TokenValidator, logger *zap.Logger) fiber.Handler { + return keyauth.New(keyauth.Config{ + KeyLookup: "header:token", + Validator: func(c *fiber.Ctx, key string) (bool, error) { + // 验证令牌 + userID, err := v.Validate(key) + if err != nil { + // 获取请求 ID 用于日志 + requestID := "" + if rid := c.Locals(constants.ContextKeyRequestID); rid != nil { + requestID = rid.(string) + } + + logger.Warn("令牌验证失败", + zap.String("request_id", requestID), + zap.Error(err), + ) + return false, err + } + + // 在上下文中存储用户 ID + c.Locals(constants.ContextKeyUserID, userID) + return true, nil + }, + ErrorHandler: func(c *fiber.Ctx, err error) error { + // 将错误映射到统一响应格式 + switch err { + case keyauth.ErrMissingOrMalformedAPIKey: + return response.Error(c, 400, errors.CodeMissingToken, errors.GetMessage(errors.CodeMissingToken, "zh")) + case errors.ErrInvalidToken: + return response.Error(c, 400, errors.CodeInvalidToken, errors.GetMessage(errors.CodeInvalidToken, "zh")) + case errors.ErrRedisUnavailable: + return response.Error(c, 503, errors.CodeAuthServiceUnavailable, errors.GetMessage(errors.CodeAuthServiceUnavailable, "zh")) + default: + return response.Error(c, 500, errors.CodeInternalError, errors.GetMessage(errors.CodeInternalError, "zh")) + } + }, + }) +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..76e3248 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/storage/redis/v3" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// RateLimiter 创建基于 IP 的限流中间件 +// storage 参数:nil = 内存存储,传入 Redis storage = 分布式限流 +func RateLimiter(max int, expiration time.Duration, storage fiber.Storage) fiber.Handler { + return limiter.New(limiter.Config{ + Max: max, + Expiration: expiration, + KeyGenerator: func(c *fiber.Ctx) string { + // 使用统一的 Redis 键生成函数 + return constants.RedisRateLimitKey(c.IP()) + }, + LimitReached: func(c *fiber.Ctx) error { + return response.Error(c, 400, errors.CodeTooManyRequests, errors.GetMessage(errors.CodeTooManyRequests, "zh")) + }, + Storage: storage, // 支持内存或 Redis 存储 + }) +} + +// NewRedisStorage 创建 Redis 存储用于限流 +func NewRedisStorage(addr, password string, db, prot int) fiber.Storage { + return redis.New(redis.Config{ + Host: addr, + Port: prot, + Password: password, + Database: db, + Reset: false, + }) +} diff --git a/internal/middleware/recover.go b/internal/middleware/recover.go new file mode 100644 index 0000000..8772559 --- /dev/null +++ b/internal/middleware/recover.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "runtime/debug" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// Recover 创建自定义 panic 恢复中间件 +func Recover(logger *zap.Logger) fiber.Handler { + return func(c *fiber.Ctx) error { + defer func() { + if r := recover(); r != nil { + // 获取请求 ID + requestID := "" + if rid := c.Locals(constants.ContextKeyRequestID); rid != nil { + requestID = rid.(string) + } + + // 捕获堆栈跟踪 + stack := debug.Stack() + + // 记录 panic 信息 + logger.Error("Panic 已恢复", + zap.String("request_id", requestID), + zap.String("method", c.Method()), + zap.String("path", c.Path()), + zap.Any("panic", r), + zap.String("stack", string(stack)), + ) + + // 返回统一错误响应 + _ = response.Error(c, 500, errors.CodeInternalError, errors.GetMessage(errors.CodeInternalError, "zh")) + } + }() + + return c.Next() + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 2d4c288..0000000 --- a/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import "github.com/gofiber/fiber/v2" - -func main() { - app := fiber.New() - - app.Listen(":3000") -} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..9c87f77 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,167 @@ +package config + +import ( + "errors" + "fmt" + "strings" + "sync/atomic" + "time" + "unsafe" +) + +// globalConfig 保存当前配置,支持原子访问 +var globalConfig atomic.Pointer[Config] + +// Config 应用配置 +type Config struct { + Server ServerConfig `mapstructure:"server"` + Redis RedisConfig `mapstructure:"redis"` + Logging LoggingConfig `mapstructure:"logging"` + Middleware MiddlewareConfig `mapstructure:"middleware"` +} + +// ServerConfig HTTP 服务器配置 +type ServerConfig struct { + Address string `mapstructure:"address"` // 例如 ":3000" + ReadTimeout time.Duration `mapstructure:"read_timeout"` // 例如 "10s" + WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "10s" + ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 例如 "30s" + Prefork bool `mapstructure:"prefork"` // 多进程模式 +} + +// RedisConfig Redis 连接配置 +type RedisConfig struct { + Address string `mapstructure:"address"` // 例如 "localhost" + Port int `mapstructure:"port"` // 例如 "6379" + Password string `mapstructure:"password"` // 无认证时留空 + DB int `mapstructure:"db"` // 数据库编号(0-15) + PoolSize int `mapstructure:"pool_size"` // 最大连接数 + MinIdleConns int `mapstructure:"min_idle_conns"` // 保活连接数 + DialTimeout time.Duration `mapstructure:"dial_timeout"` // 例如 "5s" + ReadTimeout time.Duration `mapstructure:"read_timeout"` // 例如 "3s" + WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "3s" +} + +// LoggingConfig 日志配置 +type LoggingConfig struct { + Level string `mapstructure:"level"` // debug, info, warn, error + Development bool `mapstructure:"development"` // 启用开发模式(美化输出) + AppLog LogRotationConfig `mapstructure:"app_log"` // 应用日志配置 + AccessLog LogRotationConfig `mapstructure:"access_log"` // HTTP 访问日志配置 +} + +// LogRotationConfig Lumberjack 日志轮转配置 +type LogRotationConfig struct { + Filename string `mapstructure:"filename"` // 日志文件路径 + MaxSize int `mapstructure:"max_size"` // 轮转前的最大大小(MB) + MaxBackups int `mapstructure:"max_backups"` // 保留的旧文件最大数量 + MaxAge int `mapstructure:"max_age"` // 保留旧文件的最大天数 + Compress bool `mapstructure:"compress"` // 压缩轮转的文件 +} + +// MiddlewareConfig 中间件配置 +type MiddlewareConfig struct { + EnableAuth bool `mapstructure:"enable_auth"` // 启用 keyauth 中间件 + EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // 启用限流器(默认:false) + RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // 限流器配置 +} + +// RateLimiterConfig 限流器配置 +type RateLimiterConfig struct { + Max int `mapstructure:"max"` // 时间窗口内的最大请求数 + Expiration time.Duration `mapstructure:"expiration"` // 时间窗口(例如 "1m") + Storage string `mapstructure:"storage"` // "memory" 或 "redis" +} + +// Validate 验证配置值 +func (c *Config) Validate() error { + // 服务器验证 + if c.Server.Address == "" { + return fmt.Errorf("invalid configuration: server.address: must be non-empty (current value: empty)") + } + if c.Server.ReadTimeout < 5*time.Second || c.Server.ReadTimeout > 300*time.Second { + return fmt.Errorf("invalid configuration: server.read_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.ReadTimeout) + } + if c.Server.WriteTimeout < 5*time.Second || c.Server.WriteTimeout > 300*time.Second { + return fmt.Errorf("invalid configuration: server.write_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.WriteTimeout) + } + if c.Server.ShutdownTimeout < 10*time.Second || c.Server.ShutdownTimeout > 120*time.Second { + return fmt.Errorf("invalid configuration: server.shutdown_timeout: duration out of range (current value: %s, expected: 10s-120s)", c.Server.ShutdownTimeout) + } + + // Redis 验证 + if c.Redis.Address == "" { + return fmt.Errorf("invalid configuration: redis.address: must be non-empty (current value: empty)") + } + if !strings.Contains(c.Redis.Address, ":") { + return fmt.Errorf("invalid configuration: redis.address: invalid format (current value: %s, expected: HOST:PORT)", c.Redis.Address) + } + if c.Redis.DB < 0 || c.Redis.DB > 15 { + return fmt.Errorf("invalid configuration: redis.db: database number out of range (current value: %d, expected: 0-15)", c.Redis.DB) + } + if c.Redis.PoolSize <= 0 || c.Redis.PoolSize > 1000 { + return fmt.Errorf("invalid configuration: redis.pool_size: pool size out of range (current value: %d, expected: 1-1000)", c.Redis.PoolSize) + } + if c.Redis.MinIdleConns < 0 || c.Redis.MinIdleConns > c.Redis.PoolSize { + return fmt.Errorf("invalid configuration: redis.min_idle_conns: must be 0 to pool_size (current value: %d, pool_size: %d)", c.Redis.MinIdleConns, c.Redis.PoolSize) + } + + // 日志验证 + validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} + if !validLevels[c.Logging.Level] { + return fmt.Errorf("invalid configuration: logging.level: invalid log level (current value: %s, expected: debug, info, warn, error)", c.Logging.Level) + } + if c.Logging.AppLog.Filename == "" { + return fmt.Errorf("invalid configuration: logging.app_log.filename: must be non-empty valid file path") + } + if c.Logging.AccessLog.Filename == "" { + return fmt.Errorf("invalid configuration: logging.access_log.filename: must be non-empty valid file path") + } + if c.Logging.AppLog.MaxSize < 1 || c.Logging.AppLog.MaxSize > 1000 { + return fmt.Errorf("invalid configuration: logging.app_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AppLog.MaxSize) + } + if c.Logging.AccessLog.MaxSize < 1 || c.Logging.AccessLog.MaxSize > 1000 { + return fmt.Errorf("invalid configuration: logging.access_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AccessLog.MaxSize) + } + + // 中间件验证 + if c.Middleware.RateLimiter.Max <= 0 { + c.Middleware.RateLimiter.Max = 100 // 默认值 + } + if c.Middleware.RateLimiter.Max > 10000 { + return fmt.Errorf("invalid configuration: middleware.rate_limiter.max: request limit out of range (current value: %d, expected: 1-10000)", c.Middleware.RateLimiter.Max) + } + validStorage := map[string]bool{"memory": true, "redis": true} + if c.Middleware.RateLimiter.Storage != "" && !validStorage[c.Middleware.RateLimiter.Storage] { + return fmt.Errorf("invalid configuration: middleware.rate_limiter.storage: invalid storage type (current value: %s, expected: memory or redis)", c.Middleware.RateLimiter.Storage) + } + + return nil +} + +// Get 返回当前配置 +func Get() *Config { + return globalConfig.Load() +} + +// Set 原子地更新全局配置 +func Set(cfg *Config) error { + if cfg == nil { + return errors.New("config cannot be nil") + } + if err := cfg.Validate(); err != nil { + return err + } + globalConfig.Store(cfg) + return nil +} + +// unsafeSet 无验证设置配置(仅供热重载内部使用) +func unsafeSet(cfg *Config) { + globalConfig.Store(cfg) +} + +// atomicSwap 原子地交换配置 +func atomicSwap(new *Config) *Config { + return (*Config)(atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&globalConfig)), unsafe.Pointer(new))) +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go new file mode 100644 index 0000000..db90a19 --- /dev/null +++ b/pkg/config/loader.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/spf13/viper" +) + +// Load 从文件和环境变量加载配置 +func Load() (*Config, error) { + // 确定配置路径 + configPath := os.Getenv(constants.EnvConfigPath) + if configPath == "" { + configPath = constants.DefaultConfigPath + } + + // 检查环境特定配置(dev, staging, prod) + configEnv := os.Getenv(constants.EnvConfigEnv) + if configEnv != "" { + // 优先尝试环境特定配置 + envConfigPath := fmt.Sprintf("configs/config.%s.yaml", configEnv) + if _, err := os.Stat(envConfigPath); err == nil { + configPath = envConfigPath + } + } + + // 设置 Viper + viper.SetConfigFile(configPath) + viper.SetConfigType("yaml") + + // 启用环境变量覆盖 + viper.AutomaticEnv() + viper.SetEnvPrefix("APP") + + // 读取配置文件 + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // 反序列化到 Config 结构体 + cfg := &Config{} + if err := viper.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // 验证配置 + if err := cfg.Validate(); err != nil { + return nil, err + } + + // 设为全局配置 + globalConfig.Store(cfg) + + return cfg, nil +} + +// Reload 重新加载当前配置文件 +func Reload() (*Config, error) { + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to reload config: %w", err) + } + + cfg := &Config{} + if err := viper.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // 设置前验证 + if err := cfg.Validate(); err != nil { + return nil, err + } + + // 原子交换 + globalConfig.Store(cfg) + + return cfg, nil +} + +// GetConfigPath 返回当前已加载配置文件的绝对路径 +func GetConfigPath() string { + configFile := viper.ConfigFileUsed() + if configFile == "" { + return "" + } + absPath, err := filepath.Abs(configFile) + if err != nil { + return configFile + } + return absPath +} diff --git a/pkg/config/watcher.go b/pkg/config/watcher.go new file mode 100644 index 0000000..e243844 --- /dev/null +++ b/pkg/config/watcher.go @@ -0,0 +1,43 @@ +package config + +import ( + "context" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +// Watch 监听配置文件变化 +// 运行直到上下文被取消 +func Watch(ctx context.Context, logger *zap.Logger) { + viper.WatchConfig() + viper.OnConfigChange(func(e fsnotify.Event) { + select { + case <-ctx.Done(): + return // 如果上下文被取消则停止处理 + default: + logger.Info("配置文件已更改", zap.String("file", e.Name)) + + // 尝试重新加载 + newConfig, err := Reload() + if err != nil { + logger.Error("重新加载配置失败,保留先前配置", + zap.Error(err), + zap.String("file", e.Name), + ) + return + } + + logger.Info("配置重新加载成功", + zap.String("file", e.Name), + zap.String("server_address", newConfig.Server.Address), + zap.String("log_level", newConfig.Logging.Level), + ) + } + }) + + // 阻塞直到上下文被取消 + <-ctx.Done() + logger.Info("配置监听器已停止") +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..29f3c8c --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,21 @@ +package constants + +// Fiber Locals 的上下文键 +const ( + ContextKeyRequestID = "requestid" + ContextKeyUserID = "user_id" + ContextKeyStartTime = "start_time" +) + +// 配置环境变量 +const ( + EnvConfigPath = "CONFIG_PATH" + EnvConfigEnv = "CONFIG_ENV" // dev, staging, prod +) + +// 默认配置值 +const ( + DefaultConfigPath = "configs/config.yaml" + DefaultServerAddr = ":3000" + DefaultRedisAddr = "localhost:6379" +) diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go new file mode 100644 index 0000000..726f78e --- /dev/null +++ b/pkg/constants/redis.go @@ -0,0 +1,13 @@ +package constants + +import "fmt" + +// RedisAuthTokenKey 生成认证令牌的 Redis 键 +func RedisAuthTokenKey(token string) string { + return fmt.Sprintf("auth:token:%s", token) +} + +// RedisRateLimitKey 生成限流的 Redis 键 +func RedisRateLimitKey(ip string) string { + return fmt.Sprintf("ratelimit:%s", ip) +} diff --git a/pkg/database/redis.go b/pkg/database/redis.go new file mode 100644 index 0000000..0cff76f --- /dev/null +++ b/pkg/database/redis.go @@ -0,0 +1,53 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// RedisConfig Redis 配置 +type RedisConfig struct { + Address string + Password string + DB int + PoolSize int + MinIdleConns int + DialTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration +} + +// NewRedisClient 创建新的 Redis 客户端 +func NewRedisClient(cfg RedisConfig, logger *zap.Logger) (*redis.Client, error) { + client := redis.NewClient(&redis.Options{ + Addr: cfg.Address, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConns, + DialTimeout: cfg.DialTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + MaxRetries: 3, + PoolTimeout: 4 * time.Second, + }) + + // 测试连接 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to redis: %w", err) + } + + logger.Info("Redis 连接成功", + zap.String("address", cfg.Address), + zap.Int("db", cfg.DB), + ) + + return client, nil +} diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go new file mode 100644 index 0000000..a4474e5 --- /dev/null +++ b/pkg/errors/codes.go @@ -0,0 +1,39 @@ +package errors + +// 应用错误码 +const ( + CodeSuccess = 0 // 成功 + CodeInternalError = 1000 // 内部服务器错误 + CodeMissingToken = 1001 // 缺失认证令牌 + CodeInvalidToken = 1002 // 令牌无效或已过期 + CodeTooManyRequests = 1003 // 请求过于频繁(限流) + CodeAuthServiceUnavailable = 1004 // 认证服务不可用(Redis 宕机) +) + +// ErrorMessage 表示双语错误消息 +type ErrorMessage struct { + EN string + ZH string +} + +// errorMessages 将错误码映射到双语消息 +var errorMessages = map[int]ErrorMessage{ + CodeSuccess: {"Success", "成功"}, + CodeInternalError: {"Internal server error", "内部服务器错误"}, + CodeMissingToken: {"Missing authentication token", "缺失认证令牌"}, + CodeInvalidToken: {"Invalid or expired token", "令牌无效或已过期"}, + CodeTooManyRequests: {"Too many requests", "请求过于频繁"}, + CodeAuthServiceUnavailable: {"Authentication service unavailable", "认证服务不可用"}, +} + +// GetMessage 根据错误码和语言返回错误消息 +func GetMessage(code int, lang string) string { + msg, ok := errorMessages[code] + if !ok { + return "Unknown error" + } + if lang == "zh" || lang == "zh-CN" { + return msg.ZH + } + return msg.EN +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..d4fa214 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,49 @@ +package errors + +import ( + "errors" + "fmt" +) + +// 中间件标准错误类型 +var ( + ErrMissingToken = errors.New("missing authentication token") + ErrInvalidToken = errors.New("invalid or expired token") + ErrRedisUnavailable = errors.New("redis unavailable") + ErrTooManyRequests = errors.New("too many requests") +) + +// AppError 表示带错误码的应用错误 +type AppError struct { + Code int // 应用错误码 + Message string // 错误消息 + Err error // 底层错误(可选) +} + +func (e *AppError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Err) + } + return e.Message +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +// New 创建新的 AppError +func New(code int, message string) *AppError { + return &AppError{ + Code: code, + Message: message, + } +} + +// Wrap 用错误码和消息包装现有错误 +func Wrap(code int, message string, err error) *AppError { + return &AppError{ + Code: code, + Message: message, + Err: err, + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..1b03323 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,146 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +var ( + // appLogger 应用日志记录器 + appLogger *zap.Logger + // accessLogger 访问日志记录器 + accessLogger *zap.Logger +) + +// InitLoggers 初始化应用和访问日志记录器 +func InitLoggers( + level string, + development bool, + appLogConfig LogRotationConfig, + accessLogConfig LogRotationConfig, +) error { + // 解析日志级别 + zapLevel := parseLevel(level) + + // 创建编码器配置 + encoderConfig := zapcore.EncoderConfig{ + TimeKey: "timestamp", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + MessageKey: "message", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, // RFC3339 格式 + EncodeDuration: zapcore.SecondsDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + + // 选择编码器(开发模式使用控制台,生产使用 JSON) + var encoder zapcore.Encoder + if development { + encoder = zapcore.NewConsoleEncoder(encoderConfig) + } else { + encoder = zapcore.NewJSONEncoder(encoderConfig) + } + + // 创建应用日志核心 + appCore := zapcore.NewCore( + encoder, + zapcore.AddSync(newLumberjackLogger(appLogConfig)), + zapLevel, + ) + + // 创建访问日志核心(始终使用 JSON) + accessCore := zapcore.NewCore( + zapcore.NewJSONEncoder(encoderConfig), + zapcore.AddSync(newLumberjackLogger(accessLogConfig)), + zapcore.InfoLevel, // 访问日志始终使用 info 级别 + ) + + // 构建日志记录器 + if development { + // 开发模式:添加调用者信息和堆栈跟踪 + appLogger = zap.New(appCore, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.Development()) + } else { + // 生产模式:添加调用者信息,仅在 error 时添加堆栈跟踪 + appLogger = zap.New(appCore, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) + } + + // 访问日志不需要调用者信息 + accessLogger = zap.New(accessCore) + + return nil +} + +// GetAppLogger 返回应用日志记录器 +func GetAppLogger() *zap.Logger { + if appLogger == nil { + // 如果未初始化,返回 nop logger + return zap.NewNop() + } + return appLogger +} + +// GetAccessLogger 返回访问日志记录器 +func GetAccessLogger() *zap.Logger { + if accessLogger == nil { + // 如果未初始化,返回 nop logger + return zap.NewNop() + } + return accessLogger +} + +// Sync 刷新所有日志缓冲区 +func Sync() error { + if appLogger != nil { + if err := appLogger.Sync(); err != nil { + return err + } + } + if accessLogger != nil { + if err := accessLogger.Sync(); err != nil { + return err + } + } + return nil +} + +// parseLevel 解析日志级别字符串 +func parseLevel(level string) zapcore.Level { + switch level { + case "debug": + return zapcore.DebugLevel + case "info": + return zapcore.InfoLevel + case "warn": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + default: + return zapcore.InfoLevel + } +} + +// newLumberjackLogger 创建 Lumberjack 日志轮转器 +func newLumberjackLogger(config LogRotationConfig) *lumberjack.Logger { + return &lumberjack.Logger{ + Filename: config.Filename, + MaxSize: config.MaxSize, + MaxBackups: config.MaxBackups, + MaxAge: config.MaxAge, + Compress: config.Compress, + LocalTime: true, // 使用本地时间 + } +} + +// LogRotationConfig 日志轮转配置(从 config 包复制以避免循环依赖) +type LogRotationConfig struct { + Filename string + MaxSize int + MaxBackups int + MaxAge int + Compress bool +} diff --git a/pkg/logger/middleware.go b/pkg/logger/middleware.go new file mode 100644 index 0000000..a59b01b --- /dev/null +++ b/pkg/logger/middleware.go @@ -0,0 +1,52 @@ +package logger + +import ( + "time" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +// Middleware 创建 Fiber 日志中间件 +// 记录所有 HTTP 请求到访问日志 +func Middleware() fiber.Handler { + return func(c *fiber.Ctx) error { + // 记录请求开始时间 + startTime := time.Now() + c.Locals(constants.ContextKeyStartTime, startTime) + + // 处理请求 + err := c.Next() + + // 计算请求持续时间 + duration := time.Since(startTime) + + // 获取请求 ID(由 requestid 中间件设置) + requestID := "" + if rid := c.Locals(constants.ContextKeyRequestID); rid != nil { + requestID = rid.(string) + } + + // 获取用户 ID(由 auth 中间件设置) + userID := "" + if uid := c.Locals(constants.ContextKeyUserID); uid != nil { + userID = uid.(string) + } + + // 记录访问日志 + accessLogger := GetAccessLogger() + accessLogger.Info("", + zap.String("method", c.Method()), + zap.String("path", c.Path()), + zap.Int("status", c.Response().StatusCode()), + zap.Float64("duration_ms", float64(duration.Microseconds())/1000.0), + zap.String("request_id", requestID), + zap.String("ip", c.IP()), + zap.String("user_agent", c.Get("User-Agent")), + zap.String(constants.ContextKeyUserID, userID), + ) + + return err + } +} diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..cb75753 --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,46 @@ +package response + +import ( + "time" + + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/gofiber/fiber/v2" +) + +// Response 统一 API 响应结构 +type Response struct { + Code int `json:"code"` // 应用错误码(0 = 成功) + Data any `json:"data"` // 响应数据(对象、数组或 null) + Message string `json:"msg"` // 可读消息 + Timestamp string `json:"timestamp"` // ISO 8601 时间戳 +} + +// Success 返回成功响应 +func Success(c *fiber.Ctx, data any) error { + return c.JSON(Response{ + Code: errors.CodeSuccess, + Data: data, + Message: "success", + Timestamp: time.Now().Format(time.RFC3339), + }) +} + +// Error 返回错误响应 +func Error(c *fiber.Ctx, httpStatus int, code int, message string) error { + return c.Status(httpStatus).JSON(Response{ + Code: code, + Data: nil, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) +} + +// SuccessWithMessage 返回带自定义消息的成功响应 +func SuccessWithMessage(c *fiber.Ctx, data any, message string) error { + return c.JSON(Response{ + Code: errors.CodeSuccess, + Data: data, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) +} diff --git a/pkg/validator/token.go b/pkg/validator/token.go new file mode 100644 index 0000000..84a9287 --- /dev/null +++ b/pkg/validator/token.go @@ -0,0 +1,65 @@ +package validator + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// TokenValidator 令牌验证器 +type TokenValidator struct { + redis *redis.Client + logger *zap.Logger +} + +// NewTokenValidator 创建新的令牌验证器 +func NewTokenValidator(rdb *redis.Client, logger *zap.Logger) *TokenValidator { + return &TokenValidator{ + redis: rdb, + logger: logger, + } +} + +// Validate 验证令牌并返回用户 ID +func (v *TokenValidator) Validate(token string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // 检查 Redis 可用性(失败关闭策略) + if err := v.redis.Ping(ctx).Err(); err != nil { + v.logger.Error("Redis 不可用", + zap.Error(err), + ) + return "", errors.ErrRedisUnavailable + } + + // 从 Redis 获取用户 ID + userID, err := v.redis.Get(ctx, constants.RedisAuthTokenKey(token)).Result() + if err == redis.Nil { + // 令牌不存在或已过期 + return "", errors.ErrInvalidToken + } + if err != nil { + v.logger.Error("Redis 获取失败", + zap.Error(err), + zap.String("token_key", constants.RedisAuthTokenKey(token)), + ) + return "", err + } + + return userID, nil +} + +// IsAvailable 检查 Redis 是否可用 +func (v *TokenValidator) IsAvailable() bool { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := v.redis.Ping(ctx).Err() + return err == nil +} diff --git a/specs/001-fiber-middleware-integration/data-model.md b/specs/001-fiber-middleware-integration/data-model.md index f055321..3925909 100644 --- a/specs/001-fiber-middleware-integration/data-model.md +++ b/specs/001-fiber-middleware-integration/data-model.md @@ -1,7 +1,7 @@ # Data Model: Fiber Middleware Integration -**Feature**: 001-fiber-middleware-integration -**Date**: 2025-11-10 +**Feature**: 001-fiber-middleware-integration +**Date**: 2025-11-10 **Phase**: 1 - Design & Contracts ## Overview @@ -162,9 +162,9 @@ middleware: ### AuthToken Entity -**Storage**: Redis key-value pair -**Key Format**: `auth:token:{token_string}` (generated via `constants.RedisAuthTokenKey()`) -**Value Format**: Plain string containing user ID +**Storage**: Redis key-value pair +**Key Format**: `auth:token:{token_string}` (generated via `constants.RedisAuthTokenKey()`) +**Value Format**: Plain string containing user ID **TTL**: Managed by Redis (set when token is created) ```go @@ -218,16 +218,16 @@ func RedisAuthTokenKey(token string) string { // Access via: c.Locals("key") // Request ID (set by requestid middleware) -requestID := c.Locals("requestid").(string) // UUID v4 string +requestID := c.Locals(constants.ContextKeyRequestID).(string) // UUID v4 string // User ID (set by keyauth middleware after validation) -userID, ok := c.Locals("user_id").(string) +userID, ok := c.Locals(constants.ContextKeyUserID).(string) if !ok { // Not authenticated } // Start time (for duration calculation) -startTime := c.Locals("start_time").(time.Time) +startTime := c.Locals(constants.ContextKeyStartTime).(time.Time) ``` **Context Keys** (constants in `pkg/constants/constants.go`): @@ -254,8 +254,8 @@ const ( ### Application Log Entry -**Format**: JSON -**Output**: `logs/app.log` +**Format**: JSON +**Output**: `logs/app.log` **Logger**: Zap (appLogger instance) ```json @@ -287,17 +287,21 @@ const ( ```go appLogger.Info("User created successfully", zap.String("request_id", requestID), - zap.String("user_id", userID), + zap.String(constants.ContextKeyUserID, userID), zap.String("username", username), zap.String("ip", ip), ) ``` -### Access Log Entry +### Access Log Entry Format -**Format**: JSON -**Output**: `logs/access.log` +**Purpose**: Records all HTTP requests for audit trail, performance monitoring, and troubleshooting +**Format**: JSON (one entry per line) +**Output**: `logs/access.log` **Logger**: Zap (accessLogger instance) +**Requirement**: Implements spec.md FR-011 + +**Complete JSON Schema**: ```json { @@ -309,22 +313,32 @@ appLogger.Info("User created successfully", "duration_ms": 45.234, "request_id": "550e8400-e29b-41d4-a716-446655440000", "ip": "192.168.1.100", - "user_agent": "Mozilla/5.0...", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...", "user_id": "user-789" } ``` -**Fields**: -- `timestamp`: ISO 8601 format (RFC3339) -- `level`: Always "info" for access logs -- `method`: HTTP method (GET, POST, PUT, DELETE) -- `path`: Request path -- `status`: HTTP status code -- `duration_ms`: Request duration in milliseconds -- `request_id`: Request correlation ID -- `ip`: Client IP address -- `user_agent`: User-Agent header -- `user_id`: Authenticated user ID (if available, empty if not) +**Field Definitions**: + +| Field | Type | Required | Description | Example | +|-------|------|----------|-------------|---------| +| `timestamp` | string | Yes | ISO 8601 timestamp (RFC3339 with milliseconds) | `2025-11-10T15:30:45.123Z` | +| `level` | string | Yes | Log level (always "info" for access logs) | `info` | +| `method` | string | Yes | HTTP method | `GET`, `POST`, `PUT`, `DELETE`, `PATCH` | +| `path` | string | Yes | Request path (including query params) | `/api/v1/users?page=1` | +| `status` | int | Yes | HTTP status code | `200`, `401`, `500` | +| `duration_ms` | float64 | Yes | Request processing duration in milliseconds | `45.234` | +| `request_id` | string | Yes | UUID v4 request identifier | `550e8400-e29b-41d4-a716-446655440000` | +| `ip` | string | Yes | Client IP address | `192.168.1.100` | +| `user_agent` | string | Yes | User-Agent header value | `Mozilla/5.0...` | +| `user_id` | string | No | Authenticated user ID (empty string if not authenticated) | `user-789` or `""` | + +**Notes**: +- All access logs are written at "info" level +- `user_id` field is empty string (`""`) for unauthenticated requests +- `duration_ms` includes full middleware chain execution time +- `path` includes query parameters for complete request tracking +- Logged after response is sent (includes actual status code) **Zap Usage**: @@ -337,7 +351,7 @@ accessLogger.Info("", zap.String("request_id", requestID), zap.String("ip", c.IP()), zap.String("user_agent", c.Get("User-Agent")), - zap.String("user_id", userID), + zap.String(constants.ContextKeyUserID, userID), ) ``` @@ -531,7 +545,7 @@ func Wrap(code int, message string, err error) *AppError { ### Rate Limit Tracking -**Storage**: In-memory (default) or Redis (for distributed) +**Storage**: In-memory (default) or Redis (for distributed) **Managed by**: Fiber limiter middleware (internal storage) ```go @@ -625,16 +639,16 @@ func RedisRateLimitKey(ip string) string { 2. Recover Middleware (catch panics) ↓ 3. RequestID Middleware (generate UUID v4) - → Store in c.Locals("requestid") + → Store in c.Locals(constants.ContextKeyRequestID) ↓ 4. Logger Middleware (start) - → Store start_time in c.Locals("start_time") + → Store start_time in c.Locals(constants.ContextKeyStartTime) ↓ 5. KeyAuth Middleware → Extract token from header → Call TokenValidator.Validate(token) → Validate with Redis: GET auth:token:{token} - → If valid: Store user_id in c.Locals("user_id") + → If valid: Store user_id in c.Locals(constants.ContextKeyUserID) → If invalid: Return 401 with error code → If Redis down: Return 503 with error code ↓ @@ -643,7 +657,7 @@ func RedisRateLimitKey(ip string) string { → If exceeded: Return 429 with error code ↓ 7. Handler (business logic) - → Access c.Locals("requestid"), c.Locals("user_id") + → Access c.Locals(constants.ContextKeyRequestID), c.Locals(constants.ContextKeyUserID) → Process request → Return response via response.Success() or response.Error() ↓ @@ -676,3 +690,142 @@ func RedisRateLimitKey(ip string) string { - ✅ No Java-style patterns (no I-prefix, no Impl-suffix) **Next**: Generate API contracts (OpenAPI specification) + +--- + +## 8. Configuration Validation Rules + +This section defines comprehensive validation constraints for all configuration fields, implementing the requirements in spec.md FR-003. + +### Validation Error Format + +All validation errors MUST follow this format: +``` +"Invalid configuration: {field_path}: {error_reason} (current value: {value}, expected: {constraint})" +``` + +Example: +``` +"Invalid configuration: server.read_timeout: duration out of range (current value: 1s, expected: 5s-300s)" +``` + +--- + +### Server Configuration Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `server.address` | string | Yes | Non-empty, format `:PORT` or `HOST:PORT` | `:3000` | "server.address: must be non-empty and in format ':PORT' or 'HOST:PORT'" | +| `server.read_timeout` | duration | Yes | 5s - 300s | `10s` | "server.read_timeout: duration out of range (expected: 5s-300s)" | +| `server.write_timeout` | duration | Yes | 5s - 300s | `10s` | "server.write_timeout: duration out of range (expected: 5s-300s)" | +| `server.shutdown_timeout` | duration | Yes | 10s - 120s | `30s` | "server.shutdown_timeout: duration out of range (expected: 10s-120s)" | +| `server.prefork` | bool | No | true or false | `false` | "server.prefork: must be boolean (true/false)" | + +--- + +### Redis Configuration Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `redis.address` | string | Yes | Non-empty, format `HOST:PORT` | `localhost:6379` | "redis.address: must be non-empty and in format 'HOST:PORT'" | +| `redis.password` | string | No | Any string (empty allowed) | `""` | N/A | +| `redis.db` | int | No | 0 - 15 | `0` | "redis.db: database number out of range (expected: 0-15)" | +| `redis.pool_size` | int | No | 1 - 1000 | `10` | "redis.pool_size: pool size out of range (expected: 1-1000)" | +| `redis.min_idle_conns` | int | No | 0 - pool_size | `5` | "redis.min_idle_conns: must be 0 to pool_size" | +| `redis.dial_timeout` | duration | No | 1s - 30s | `5s` | "redis.dial_timeout: timeout out of range (expected: 1s-30s)" | +| `redis.read_timeout` | duration | No | 1s - 30s | `3s` | "redis.read_timeout: timeout out of range (expected: 1s-30s)" | +| `redis.write_timeout` | duration | No | 1s - 30s | `3s` | "redis.write_timeout: timeout out of range (expected: 1s-30s)" | + +--- + +### Logging Configuration Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `logging.level` | string | No | One of: debug, info, warn, error | `info` | "logging.level: invalid log level (expected: debug, info, warn, error)" | +| `logging.development` | bool | No | true or false | `false` | "logging.development: must be boolean (true/false)" | + +#### App Log Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `logging.app_log.filename` | string | Yes | Non-empty, valid file path | `logs/app.log` | "logging.app_log.filename: must be non-empty valid file path" | +| `logging.app_log.max_size` | int | No | 1 - 1000 (MB) | `100` | "logging.app_log.max_size: size out of range (expected: 1-1000 MB)" | +| `logging.app_log.max_backups` | int | No | 0 - 999 (0 = keep all) | `30` | "logging.app_log.max_backups: count out of range (expected: 0-999)" | +| `logging.app_log.max_age` | int | No | 1 - 365 (days) | `30` | "logging.app_log.max_age: retention period out of range (expected: 1-365 days)" | +| `logging.app_log.compress` | bool | No | true or false | `true` | "logging.app_log.compress: must be boolean (true/false)" | + +#### Access Log Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `logging.access_log.filename` | string | Yes | Non-empty, valid file path | `logs/access.log` | "logging.access_log.filename: must be non-empty valid file path" | +| `logging.access_log.max_size` | int | No | 1 - 1000 (MB) | `500` | "logging.access_log.max_size: size out of range (expected: 1-1000 MB)" | +| `logging.access_log.max_backups` | int | No | 0 - 999 (0 = keep all) | `90` | "logging.access_log.max_backups: count out of range (expected: 0-999)" | +| `logging.access_log.max_age` | int | No | 1 - 365 (days) | `90` | "logging.access_log.max_age: retention period out of range (expected: 1-365 days)" | +| `logging.access_log.compress` | bool | No | true or false | `true` | "logging.access_log.compress: must be boolean (true/false)" | + +--- + +### Middleware Configuration Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `middleware.enable_auth` | bool | No | true or false | `true` | "middleware.enable_auth: must be boolean (true/false)" | +| `middleware.enable_rate_limiter` | bool | No | true or false | `false` | "middleware.enable_rate_limiter: must be boolean (true/false)" | + +#### Rate Limiter Validation + +| Field | Type | Required | Constraint | Default | Error Message | +|-------|------|----------|------------|---------|---------------| +| `middleware.rate_limiter.max` | int | No | 1 - 10000 | `30` | "middleware.rate_limiter.max: request limit out of range (expected: 1-10000)" | +| `middleware.rate_limiter.expiration` | duration | No | 1s - 1h | `1m` | "middleware.rate_limiter.expiration: window duration out of range (expected: 1s-1h)" | +| `middleware.rate_limiter.storage` | string | No | "memory" or "redis" | `memory` | "middleware.rate_limiter.storage: invalid storage type (expected: memory or redis)" | + +--- + +### Validation Implementation Guidelines + +**Location**: `pkg/config/loader.go` - `Validate()` function + +**Validation Order**: +1. **Required fields check** - Fail fast if critical fields missing +2. **Type validation** - Viper handles basic type conversion +3. **Range validation** - Check numeric bounds +4. **Format validation** - Validate string formats (HOST:PORT, file paths) +5. **Cross-field validation** - Check dependencies (e.g., min_idle_conns <= pool_size) + +**Example Implementation Pattern**: + +```go +func (c *Config) Validate() error { + // Required fields + if c.Server.Address == "" { + return fmt.Errorf("Invalid configuration: server.address: must be non-empty (current value: empty)") + } + + // Range validation + if c.Server.ReadTimeout < 5*time.Second || c.Server.ReadTimeout > 300*time.Second { + return fmt.Errorf("Invalid configuration: server.read_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.ReadTimeout) + } + + // Format validation + if !strings.Contains(c.Redis.Address, ":") { + return fmt.Errorf("Invalid configuration: redis.address: invalid format (current value: %s, expected: HOST:PORT)", c.Redis.Address) + } + + // Cross-field validation + if c.Redis.MinIdleConns > c.Redis.PoolSize { + return fmt.Errorf("Invalid configuration: redis.min_idle_conns: must not exceed pool_size (current value: %d, pool_size: %d)", c.Redis.MinIdleConns, c.Redis.PoolSize) + } + + return nil +} +``` + +**Testing Requirements**: +- Unit tests MUST cover all validation rules (T020 in tasks.md) +- Test valid configurations (should pass) +- Test each constraint violation (should fail with correct error message) +- Test edge cases (min/max boundaries) +- Test malformed YAML (should be caught by Viper) diff --git a/specs/001-fiber-middleware-integration/optional-improvements.md b/specs/001-fiber-middleware-integration/optional-improvements.md new file mode 100644 index 0000000..79e2cf2 --- /dev/null +++ b/specs/001-fiber-middleware-integration/optional-improvements.md @@ -0,0 +1,218 @@ +# Optional Improvements for 001-fiber-middleware-integration + +**Created**: 2025-11-11 +**Status**: Deferred - Address after core implementation +**Source**: `/speckit.analyze` findings (Medium/Low priority) + +--- + +## Medium Priority Improvements + +### A2: Log Retention Policy Clarity +- **Issue**: FR-006 mentions "configured retention policy" but doesn't specify units/format +- **Current Impact**: Implementation will need to infer format +- **Recommendation**: Add to spec.md FR-006: + ``` + Retention policy specified in days (integer), e.g., 30 for app logs, 90 for access logs. + Implemented via Lumberjack MaxAge parameter. + ``` +- **Effort**: 10 minutes (documentation only) + +--- + +### A3: Rate Limiting Default Values +- **Issue**: FR-018a says "configurable requests per time window" but no default values +- **Current Impact**: Developers must guess initial values +- **Recommendation**: Add to spec.md FR-018a: + ``` + Default: 100 requests per minute per IP + Supported time units: second (s), minute (m), hour (h) + Example config: max=100, window=1m + ``` +- **Effort**: 15 minutes (spec update + config.yaml example) + +--- + +### A5: Phase 0 Research Status +- **Issue**: plan.md Phase 0 lists "Best Redis client" as unknown but Technical Context already decided +- **Current Impact**: Confusion about whether research is needed +- **Recommendation**: Update plan.md Phase 0: + - Remove "Best Redis client" from unknowns (already decided: go-redis/redis/v8) + - Or clarify: "Validate go-redis/redis/v8 choice (performance benchmarks, connection pool tuning)" +- **Effort**: 5 minutes (documentation clarity) + +--- + +### A6: Config Validation Rules Location +- **Issue**: T009 says "config loading with validation" but validation rules not documented +- **Current Impact**: Developer must design validation rules during implementation +- **Recommendation**: Create section in data-model.md or spec.md: + ```markdown + ## Configuration Validation Rules + - server.port: 1024-65535 (int) + - server.host: non-empty string + - redis.addr: host:port format + - logging.max_size: 1-1000 (MB) + - logging.max_age: 1-365 (days) + ``` +- **Effort**: 30 minutes (requires design decisions) + +--- + +### U2: Access Log Format Specification +- **Issue**: FR-011 mentions logging HTTP requests but doesn't specify JSON schema for access.log +- **Current Impact**: Access log structure determined during implementation +- **Recommendation**: Add to spec.md or data-model.md: + ```json + { + "timestamp": "2025-11-10T15:30:45Z", + "level": "info", + "request_id": "550e8400-e29b-...", + "method": "GET", + "path": "/api/v1/users", + "status": 200, + "duration_ms": 45, + "ip": "192.168.1.100", + "user_agent": "Mozilla/5.0...", + "user_id": "12345" // if authenticated + } + ``` +- **Effort**: 20 minutes (design + documentation) + +--- + +### U3: Panic Response Details +- **Issue**: FR-014 says "HTTP 500 with unified error response" but unclear about stack trace handling +- **Current Impact**: Security concern - should stack traces be in production responses? +- **Recommendation**: Clarify in spec.md FR-014: + ``` + Development/Staging: Include sanitized error message in response.msg, full stack trace in logs only + Production: Generic error message "Internal server error", full details in logs only + Response format: {"code": 1000, "data": null, "msg": "Internal server error"} + ``` +- **Effort**: 15 minutes (security consideration + spec update) + +--- + +### U4: Rate Limiter Documentation Format +- **Issue**: FR-020 mentions "documentation" but unclear where (quickstart.md? inline comments? separate docs/) +- **Current Impact**: Documentation may be incomplete or scattered +- **Recommendation**: Specify in spec.md FR-020: + ``` + Documentation location: quickstart.md section "Enabling Rate Limiting" + Must include: Configuration parameters, per-endpoint setup, testing examples + Code example: Uncomment middleware, adjust max/window, test with curl loop + ``` +- **Effort**: 10 minutes (clarify requirements) + +--- + +### U5: Non-Writable Log Directory Behavior +- **Issue**: Edge case says "fail to start with clear error" but no HTTP status code if started +- **Current Impact**: Unclear behavior if directory becomes non-writable at runtime +- **Recommendation**: Clarify in spec.md Edge Cases: + ``` + Startup: Fail immediately with exit code 1 and error message before listening on port + Runtime: If directory becomes non-writable, log error to stderr and return 503 on health check + ``` +- **Effort**: 15 minutes (design decision + spec update) + +--- + +### U6: Performance Test Task Missing +- **Issue**: plan.md mentions "1000+ req/s capacity" but no performance test task in tasks.md +- **Current Impact**: Performance goal not validated +- **Recommendation**: Add task to tasks.md Phase 10: + ``` + - [ ] T117a [P] Load test with 1000 req/s for 60s, verify P95 < 200ms (use hey or wrk) + ``` +- **Effort**: 1 hour (implementation + infrastructure setup) + +--- + +### I1: Logger Instances Documentation +- **Issue**: spec.md mentions Zap but tasks reference appLogger/accessLogger instances +- **Current Impact**: Spec doesn't explicitly require two logger instances +- **Recommendation**: Add to spec.md FR-004 or Key Entities: + ``` + System maintains two independent Zap logger instances: + - appLogger: For application-level logs (business logic, errors, debug) + - accessLogger: For HTTP access logs (request/response details) + Each instance has separate Lumberjack rotation configuration. + ``` +- **Effort**: 10 minutes (documentation clarity) + +--- + +### I3: Fail-Closed Timing Validation +- **Issue**: spec.md FR-016b says "immediately" but T063 doesn't validate timing (< 100ms) +- **Current Impact**: "Immediate" is subjective, no performance assertion +- **Recommendation**: Update tasks.md T063: + ``` + - [ ] T063 [P] [US6] Unit test for Redis unavailable (fail closed with < 100ms response time) + ``` + Or clarify spec.md: "immediately = same request cycle, no retry delays" +- **Effort**: 5 minutes (clarify requirements OR 30 minutes add timing assertion) + +--- + +## Low Priority Improvements + +### D1: Response Format Duplication +- **Issue**: FR-007 and US3 both define unified response format +- **Current Impact**: Redundancy, potential inconsistency if one updated +- **Recommendation**: Keep FR-007 as normative, update US3 acceptance criteria: + ``` + Change: "the response contains `{...}`" + To: "the response follows unified format defined in FR-007" + ``` +- **Effort**: 5 minutes (reduce duplication) + +--- + +### D2: Code Quality Task Consolidation +- **Issue**: T096-T099 are four separate tasks that could run in one script +- **Current Impact**: Overhead running tasks sequentially +- **Recommendation**: Consider combining to T096-combined: + ``` + - [ ] T096 [P] Run code quality checks: gofmt -l ., go vet ./..., golangci-lint run, check doc comments + ``` + Or keep separate for granular progress tracking (current approach is also valid) +- **Effort**: 15 minutes (script creation) OR keep as-is (no change needed) + +--- + +### I4: Task Count Verification +- **Issue**: plan.md says 126 tasks, tasks.md has T001-T126 (now T127 with T012a) +- **Current Impact**: None - counts match after update +- **Recommendation**: No action needed, already addressed in C1 fix +- **Effort**: 0 minutes (already resolved) + +--- + +## Priority Recommendation + +**Before Implementation**: +- None (all HIGH priority issues already fixed) + +**During Implementation** (if time permits): +- A6: Config validation rules (needed for T009 implementation) +- U2: Access log format (needed for T050 implementation) +- U3: Panic response details (security consideration) + +**After Implementation** (polish phase): +- A2, A3: Add default values to config.yaml and documentation +- U4, U5, I1: Documentation improvements +- U6: Performance testing (if infrastructure available) +- D1, D2, I3: Nice-to-have optimizations + +--- + +## Summary + +- **Total Optional Improvements**: 14 items +- **Medium Priority**: 9 items (mostly documentation/specification clarity) +- **Low Priority**: 5 items (minor optimizations, already acceptable as-is) +- **Estimated Total Effort**: ~4 hours for all medium priority items +- **Recommended Approach**: Address medium priority items during implementation when context is fresh + diff --git a/specs/001-fiber-middleware-integration/plan.md b/specs/001-fiber-middleware-integration/plan.md index f7df731..698f501 100644 --- a/specs/001-fiber-middleware-integration/plan.md +++ b/specs/001-fiber-middleware-integration/plan.md @@ -226,13 +226,12 @@ junhong_cmp_fiber/ 3. Fiber middleware execution order and error handling patterns 4. Fiber keyauth middleware customization for Redis token validation 5. Fiber limiter middleware configuration options and IP extraction -6. Redis client selection and connection pool configuration for Go -7. UUID v4 generation in Go (standard library vs third-party) +6. Redis connection pool configuration for go-redis/redis/v8 (already decided) +7. UUID v4 generation using github.com/google/uuid (already in go.mod) 8. Graceful shutdown patterns for config watchers and HTTP server **Unknowns to Resolve**: -- Best Redis client library for Go (go-redis vs redigo) -- Optimal Viper hot reload implementation (polling vs fsnotify) +- Optimal Viper hot reload implementation (polling vs fsnotify - recommend fsnotify) - How to properly separate Fiber logger middleware output to access.log - How to inject custom Zap logger into Fiber recover middleware - Request ID propagation through Fiber context @@ -295,7 +294,12 @@ This phase will generate `tasks.md` with implementation steps ordered by depende - Use `github.com/go-redis/redis/v8` for Redis client (widely adopted, good performance) - Use `github.com/fsnotify/fsnotify` (Viper's native watcher) for hot reload - Use `github.com/google/uuid` (already in go.mod) for UUID v4 generation -- Separate Zap logger instances for app.log and access.log +- Separate Zap logger instances for app.log and access.log: + - **appLogger**: Initialized with Lumberjack writer pointing to `app.log` with independent rotation settings (max size, max age, max backups, compression). Used for all application-level logging (business logic, errors, middleware events, debug info). + - **accessLogger**: Initialized with separate Lumberjack writer pointing to `access.log` with independent rotation settings. Used exclusively for HTTP access logs (request method, path, status, duration, request ID, IP, user agent, user ID). + - Both loggers use Zap's structured JSON encoder with consistent field naming. + - Logger instances are global variables (package-level) initialized in `pkg/logger/logger.go` via `InitLoggers()` function called from `main.go` at startup. + - Each logger has its own Lumberjack configuration to enable different retention policies (e.g., 30 days for app.log, 90 days for access.log). - Middleware order: recover → requestid → logger → keyauth → limiter → handler ### Risk Mitigation: diff --git a/specs/001-fiber-middleware-integration/research.md b/specs/001-fiber-middleware-integration/research.md index 21618ef..d88f23d 100644 --- a/specs/001-fiber-middleware-integration/research.md +++ b/specs/001-fiber-middleware-integration/research.md @@ -1,7 +1,7 @@ # Research: Fiber Middleware Integration -**Feature**: 001-fiber-middleware-integration -**Date**: 2025-11-10 +**Feature**: 001-fiber-middleware-integration +**Date**: 2025-11-10 **Phase**: 0 - Research & Discovery ## Overview @@ -26,20 +26,20 @@ This document resolves technical unknowns and establishes best practices for int viper.WatchConfig() viper.OnConfigChange(func(e fsnotify.Event) { log.Info("Config file changed", zap.String("file", e.Name)) - + // Reload config atomically newConfig := &Config{} if err := viper.Unmarshal(newConfig); err != nil { log.Error("Failed to reload config", zap.Error(err)) return // Keep existing config } - + // Validate new config if err := newConfig.Validate(); err != nil { log.Error("Invalid config", zap.Error(err)) return // Keep existing config } - + // Atomic swap using sync/atomic atomic.StorePointer(&globalConfig, unsafe.Pointer(newConfig)) }) @@ -191,12 +191,12 @@ type TokenValidator struct { func (v *TokenValidator) Validate(token string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() - + // Check Redis availability if err := v.redis.Ping(ctx).Err(); err != nil { return "", ErrRedisUnavailable // Fail closed } - + // Get user ID from token userID, err := v.redis.Get(ctx, constants.RedisAuthTokenKey(token)).Result() if err == redis.Nil { @@ -205,7 +205,7 @@ func (v *TokenValidator) Validate(token string) (string, error) { if err != nil { return "", fmt.Errorf("redis get: %w", err) } - + return userID, nil } @@ -217,12 +217,12 @@ func KeyAuth(validator *validator.TokenValidator, logger *zap.Logger) fiber.Hand userID, err := validator.Validate(key) if err != nil { logger.Warn("Token validation failed", - zap.String("request_id", c.Locals("requestid").(string)), + zap.String("request_id", c.Locals(constants.ContextKeyRequestID).(string)), zap.Error(err), ) return false, err } - + // Store user ID in context c.Locals("user_id", userID) return true, nil @@ -413,32 +413,32 @@ func main() { // Create root context with cancellation ctx, cancel := context.WithCancel(context.Background()) defer cancel() - + // Initialize components with context cfg := config.Load() go config.Watch(ctx, cfg) // Pass context to watcher - + app := setupApp(cfg) - + // Graceful shutdown signal handling quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) - + go func() { if err := app.Listen(cfg.Server.Address); err != nil { log.Fatal("Server failed", zap.Error(err)) } }() - + <-quit // Block until signal log.Info("Shutting down server...") - + cancel() // Cancel context (stops config watcher) - + if err := app.ShutdownWithTimeout(30 * time.Second); err != nil { log.Error("Forced shutdown", zap.Error(err)) } - + log.Info("Server stopped") } ``` @@ -455,7 +455,7 @@ func Watch(ctx context.Context, cfg *Config) { // Reload config logic } }) - + <-ctx.Done() // Block until cancelled log.Info("Config watcher stopped") } @@ -512,22 +512,22 @@ func TestTokenValidator_Validate(t *testing.T) { }, // More cases... } - + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockRedis := &mock.Redis{} tt.setupMock(mockRedis) - + validator := NewTokenValidator(mockRedis, zap.NewNop()) userID, err := validator.Validate(tt.token) - + if err != tt.wantErr { t.Errorf("got error %v, want %v", err, tt.wantErr) } if userID != tt.wantUser { t.Errorf("got userID %s, want %s", userID, tt.wantUser) } - + mockRedis.AssertExpectations(t) }) } @@ -538,7 +538,7 @@ func TestTokenValidator_Validate(t *testing.T) { ```go func TestMiddlewareChain(t *testing.T) { // Start testcontainer Redis - redisContainer, err := testcontainers.GenericContainer(ctx, + redisContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "redis:7-alpine", @@ -548,10 +548,10 @@ func TestMiddlewareChain(t *testing.T) { }) require.NoError(t, err) defer redisContainer.Terminate(ctx) - + // Setup app with middleware app := setupTestApp(redisContainer) - + // Test cases tests := []struct { name string @@ -571,22 +571,22 @@ func TestMiddlewareChain(t *testing.T) { }, // More cases... } - + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.setupToken != nil { tt.setupToken(redisClient) } - + req := httptest.NewRequest("GET", "/api/v1/test", nil) for k, v := range tt.headers { req.Header.Set(k, v) } - + resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, tt.expectedStatus, resp.StatusCode) - + // Parse response body and check code var body response.Response json.NewDecoder(resp.Body).Decode(&body) diff --git a/specs/001-fiber-middleware-integration/spec.md b/specs/001-fiber-middleware-integration/spec.md index 919de00..49433a4 100644 --- a/specs/001-fiber-middleware-integration/spec.md +++ b/specs/001-fiber-middleware-integration/spec.md @@ -78,10 +78,10 @@ When API consumers (frontend applications, mobile apps, third-party integrations **Acceptance Scenarios**: -1. **Given** a valid API request, **When** the request succeeds, **Then** the response contains `{"code": 0, "data": {...}, "msg": "success"}` -2. **Given** an invalid API request, **When** validation fails, **Then** the response contains `{"code": [error_code], "data": null, "msg": "[error description]"}` -3. **Given** any API endpoint, **When** processing completes, **Then** the response structure always includes code, data, and msg fields -4. **Given** list/array data is returned, **When** the response is generated, **Then** the data field contains an array instead of an object +1. **Given** a valid API request, **When** the request succeeds, **Then** the response follows the unified format defined in FR-007 with code 0 and appropriate data +2. **Given** an invalid API request, **When** validation fails, **Then** the response follows the unified format defined in FR-007 with appropriate error code and null data +3. **Given** any API endpoint, **When** processing completes, **Then** the response structure always includes all three required fields (code, data, msg) as specified in FR-007 +4. **Given** list/array data is returned, **When** the response is generated, **Then** the data field contains an array instead of an object, maintaining the unified format structure --- @@ -128,10 +128,10 @@ When external clients make API requests, they must provide a valid authenticatio **Acceptance Scenarios**: -1. **Given** a request to a protected endpoint, **When** the "token" header is missing, **Then** the system returns HTTP 401 with `{"code": 1001, "data": null, "msg": "Missing authentication token"}` +1. **Given** a request to a protected endpoint, **When** the "token" header is missing, **Then** the system returns HTTP 401 with `{"code": 1001, "data": null, "msg": "缺失认证令牌"}` 2. **Given** a request with a token, **When** the token exists as a key in Redis, **Then** the system retrieves the user ID from the value and allows the request to proceed with user context -3. **Given** a request with a token, **When** the token does not exist in Redis (either never created or TTL expired), **Then** the system returns HTTP 401 with `{"code": 1002, "data": null, "msg": "Invalid or expired token"}` -4. **Given** Redis is unavailable, **When** token validation is attempted, **Then** the system immediately fails closed, logs the Redis connection error, and returns HTTP 503 with `{"code": 1004, "data": null, "msg": "Authentication service unavailable"}` without attempting fallback mechanisms +3. **Given** a request with a token, **When** the token does not exist in Redis (either never created or TTL expired), **Then** the system returns HTTP 401 with `{"code": 1002, "data": null, "msg": "令牌无效或已过期"}` +4. **Given** Redis is unavailable, **When** token validation is attempted, **Then** the system immediately fails closed, logs the Redis connection error, and returns HTTP 503 with `{"code": 1004, "data": null, "msg": "认证服务不可用"}` without attempting fallback mechanisms --- @@ -145,7 +145,7 @@ The system should provide configurable IP-based rate limiting capabilities that **Acceptance Scenarios**: -1. **Given** rate limiting is configured and enabled for an endpoint with 100 requests per minute per IP, **When** a client IP exceeds the request limit within the time window, **Then** subsequent requests from that IP return HTTP 429 with `{"code": 1003, "data": null, "msg": "Too many requests"}` +1. **Given** rate limiting is configured and enabled for an endpoint with 100 requests per minute per IP, **When** a client IP exceeds the request limit within the time window, **Then** subsequent requests from that IP return HTTP 429 with `{"code": 1003, "data": null, "msg": "请求过于频繁"}` 2. **Given** the rate limit time window expires, **When** new requests arrive from the same client IP, **Then** the request counter resets and requests are allowed again 3. **Given** rate limiting is disabled (default), **When** any number of requests arrive, **Then** all requests are processed without rate limit checks 4. **Given** rate limiting is enabled, **When** requests arrive from different IP addresses, **Then** each IP address has its own independent request counter and limit @@ -156,7 +156,9 @@ The system should provide configurable IP-based rate limiting capabilities that - What happens when the configuration file is deleted while the system is running? (System should log error and continue with current configuration) - What happens when Redis connection is lost during token validation? (System immediately fails closed, returns HTTP 503 with code 1004, logs connection failure, and does not attempt any fallback authentication) -- What happens when log directory is not writable? (System should fail to start with clear error message) +- What happens when log directory is not writable? + - **At startup**: System MUST fail immediately with exit code 1 and clear error message to stderr before listening on any port (e.g., "Fatal: Cannot write to log directory 'logs/': permission denied") + - **At runtime**: If log directory becomes non-writable after successful startup, system MUST log error to stderr, continue serving requests but return HTTP 503 on health check endpoint until log directory becomes writable again - What happens when a request ID collision occurs? (With UUID v4, collision probability is negligible: ~1 in 2^122; no special handling needed) - What happens when configuration hot reload occurs during active request processing? (Configuration changes should not affect in-flight requests) - What happens when log rotation occurs while writing a log entry? (Log rotation should be atomic and not lose log entries) @@ -167,31 +169,55 @@ The system should provide configurable IP-based rate limiting capabilities that ### Functional Requirements - **FR-001**: System MUST load configuration from files using Viper configuration library -- **FR-002**: System MUST support hot reload of configuration files, detecting changes within 5 seconds and applying them without service restart -- **FR-003**: System MUST validate configuration values on load and reject invalid configurations with descriptive error messages -- **FR-004**: System MUST write all logs in structured JSON format using Zap logging library +- **FR-002**: System MUST support hot reload of configuration files using fsnotify-based file system event detection (immediate notification on file changes), with configuration changes applied within 5 seconds of file modification and without service restart. The 5-second window includes file event detection, validation, and atomic configuration swap. +- **FR-003**: System MUST validate configuration values on load and reject invalid configurations with descriptive error messages following the format: `"Invalid configuration: {field_path}: {error_reason} (current value: {value}, expected: {constraint})"`. Validation categories include: + - **Type validation**: All fields match expected types (string, int, bool, duration) + - **Range validation**: Numeric values within acceptable ranges (e.g., server.port: 1024-65535, log.max_size: 1-1000 MB) + - **Required fields**: server.host, server.port, redis.addr, logging.app_log_path, logging.access_log_path + - **Format validation**: Durations use Go duration format (e.g., "5m", "30s"), file paths are absolute or relative valid paths + - **Example error**: `"Invalid configuration: server.port: port number out of range (current value: 80, expected: 1024-65535)"` + - **Complete validation rules**: See data-model.md "Configuration Validation Rules" section for comprehensive field-by-field validation constraints +- **FR-004**: System MUST use Zap structured logging for all application logs with log rotation via Lumberjack.v2 and configurable log levels. The system maintains two independent Zap logger instances: + - **appLogger**: For application-level logs (business logic, errors, middleware events, debug info) + - **accessLogger**: For HTTP access logs (request/response details per FR-011) + - Each logger instance has separate Lumberjack rotation configuration for independent file management - **FR-004a**: System MUST separate application logs (app.log) and HTTP access logs (access.log) into different files with independent configuration - **FR-005**: System MUST rotate log files automatically using Lumberjack.v2 based on configurable size and age parameters for both application and access logs -- **FR-006**: System MUST retain log files according to configured retention policy and automatically remove expired logs, with separate retention settings for application and access logs -- **FR-007**: All API responses MUST follow the unified format: `{"code": [number], "data": [object/array/null], "msg": [string]}` +- **FR-006**: System MUST retain log files according to configured retention policy and automatically remove expired logs, with separate retention settings for application and access logs. Retention policy is specified in days (integer) and configured via config file (e.g., `logging.app_log_max_age: 30` for 30-day retention of app.log, `logging.access_log_max_age: 90` for 90-day retention of access.log). Implemented via Lumberjack MaxAge parameter. +- **FR-007**: All API responses MUST follow the unified format: `{"code": [number], "data": [object/array/null], "msg": [string]}`. Examples: + - **Success response**: `{"code": 0, "data": {...}, "msg": "success"}` + - **Error response**: `{"code": [error_code], "data": null, "msg": "[error description]"}` + - **List response**: `{"code": 0, "data": [...], "msg": "success"}` + - The response structure always includes all three fields (code, data, msg) regardless of success or failure - **FR-008**: System MUST assign a unique request ID to every incoming HTTP request using requestid middleware - **FR-008a**: Request IDs MUST be generated using UUID v4 format for maximum compatibility with distributed tracing systems and log aggregation tools - **FR-009**: System MUST include the request ID in all log entries associated with that request - **FR-010**: System MUST include the request ID in HTTP response headers for client-side tracing -- **FR-011**: System MUST log all HTTP requests with method, path, status code, duration, and request ID using logger middleware +- **FR-011**: System MUST log all HTTP requests with method, path, status code, duration, and request ID using logger middleware. Access logs written to access.log MUST use structured JSON format with fields: timestamp (ISO 8601), level, request_id, method, path, status, duration_ms, ip, user_agent, and user_id (if authenticated). See data-model.md "Access Log Entry Format" for complete schema definition. - **FR-012**: System MUST automatically recover from panics during request processing using recover middleware - **FR-013**: When a panic is recovered, system MUST log the full stack trace and error details -- **FR-014**: When a panic is recovered, system MUST return HTTP 500 with unified error response format +- **FR-014**: When a panic is recovered, system MUST return HTTP 500 with unified error response format. Response format: `{"code": 1000, "data": null, "msg": "服务器内部错误"}`. The panic error message detail level MUST be configurable via code constant (not config file) to support different deployment environments: + - **Detailed mode** (default for development): Include sanitized panic message in response.msg (e.g., `"服务器内部错误: runtime error: invalid memory address"`) + - **Simple mode** (for production): Return generic message only (`"服务器内部错误"`) + - **Configuration**: Define constant in `pkg/constants/constants.go` as `const PanicResponseDetailLevel = "detailed"` or `"simple"`, easily changeable by developers before deployment + - **Security**: Full stack trace ALWAYS logged to app.log only, NEVER included in HTTP response regardless of mode + - All response messages MUST use Chinese, not English - **FR-015**: System MUST validate authentication tokens from the "token" request header using keyauth middleware - **FR-016**: System MUST check token validity by verifying existence in Redis cache using token string as key - **FR-016a**: System MUST store tokens in Redis as simple key-value pairs with token as key and user ID as value, using Redis TTL for expiration management - **FR-016b**: When Redis is unavailable during token validation, system MUST fail closed and return HTTP 503 immediately without fallback or caching mechanisms - **FR-017**: System MUST return HTTP 401 with appropriate error code and message when token is missing or invalid - **FR-018**: System MUST provide configurable IP-based rate limiting capability using limiter middleware -- **FR-018a**: Rate limiting MUST track request counts per client IP address with configurable limits (requests per time window) +- **FR-018a**: Rate limiting MUST track request counts per client IP address with configurable limits (requests per time window). Default configuration: 30 requests per minute per IP. Supported time units: second (s), minute (m), hour (h). Configuration example in config file: `limiter.max: 30, limiter.window: 1m` - **FR-018b**: When rate limit is exceeded, system MUST return HTTP 429 with code 1003 and appropriate error message - **FR-019**: Rate limiting implementation MUST be provided but disabled by default in initial deployment -- **FR-020**: System MUST include documentation on how to configure and enable rate limiting per endpoint with example configurations +- **FR-020**: System MUST include documentation on how to configure and enable rate limiting per endpoint with example configurations. Documentation MUST be created as a separate file `docs/rate-limiting.md` containing: + - **Configuration parameters**: Detailed explanation of `max`, `expiration`, and `storage` settings + - **Per-endpoint setup**: How to enable/disable rate limiting for specific routes or globally + - **Code examples**: Complete examples showing how to uncomment and configure the limiter middleware in `cmd/api/main.go` + - **Testing guide**: Step-by-step instructions with curl commands to test rate limiting behavior + - **Storage options**: Comparison of memory vs Redis storage backends with use cases + - **Common patterns**: Examples for different scenarios (public API, admin endpoints, webhook receivers) - **FR-021**: System MUST use consistent error codes across all error scenarios with bilingual (Chinese/English) support - **FR-022**: Configuration MUST support different environments (development, staging, production) with separate config files @@ -244,7 +270,7 @@ The system should provide configurable IP-based rate limiting capabilities that ### Measurable Outcomes -- **SC-001**: System administrators can modify any configuration value and see it applied within 5 seconds without service restart +- **SC-001**: System administrators can modify any configuration value in the config file and see it applied within 5 seconds (file event detection + validation + atomic swap) without service restart, verified by observing the configuration change take effect (e.g., log level change reflected in subsequent log entries) - **SC-002**: All API responses follow the unified `{code, data, msg}` structure with 100% consistency across all endpoints - **SC-003**: Every HTTP request generates a unique UUID v4 request ID that appears in the X-Request-ID response header and all associated log entries - **SC-004**: System continues processing new requests within 100ms after recovering from a panic, with zero downtime diff --git a/specs/001-fiber-middleware-integration/tasks.md b/specs/001-fiber-middleware-integration/tasks.md index 7c6a060..86e4cd1 100644 --- a/specs/001-fiber-middleware-integration/tasks.md +++ b/specs/001-fiber-middleware-integration/tasks.md @@ -20,13 +20,13 @@ **Purpose**: Project initialization and basic structure -- [ ] T001 Create directory structure: pkg/config/, pkg/logger/, pkg/response/, pkg/errors/, pkg/constants/, pkg/validator/, internal/middleware/, configs/, logs/ -- [ ] T002 [P] Setup unified error codes and messages in pkg/errors/codes.go -- [ ] T003 [P] Setup custom error types in pkg/errors/errors.go -- [ ] T004 [P] Setup unified response structure in pkg/response/response.go -- [ ] T005 [P] Setup response code constants in pkg/response/codes.go -- [ ] T006 [P] Setup business constants in pkg/constants/constants.go -- [ ] T007 [P] Setup Redis key generation functions in pkg/constants/redis.go +- [X] T001 Create directory structure: pkg/config/, pkg/logger/, pkg/response/, pkg/errors/, pkg/constants/, pkg/validator/, internal/middleware/, configs/, logs/ +- [X] T002 [P] Setup unified error codes and messages in pkg/errors/codes.go +- [X] T003 [P] Setup custom error types in pkg/errors/errors.go +- [X] T004 [P] Setup unified response structure in pkg/response/response.go +- [X] T005 [P] Setup response code constants in pkg/response/codes.go +- [X] T006 [P] Setup business constants in pkg/constants/constants.go +- [X] T007 [P] Setup Redis key generation functions in pkg/constants/redis.go --- @@ -38,22 +38,23 @@ ### Configuration Management (US1 Foundation) -- [ ] T008 Create Config structures with Viper mapstructure tags in pkg/config/config.go -- [ ] T009 Implement config loading with validation in pkg/config/loader.go -- [ ] T010 Implement config hot reload with fsnotify in pkg/config/watcher.go -- [ ] T011 Create default configuration file in configs/config.yaml -- [ ] T012 [P] Create environment-specific configs: config.dev.yaml, config.staging.yaml, config.prod.yaml +- [X] T008 Create Config structures with Viper mapstructure tags in pkg/config/config.go +- [X] T009 Implement config loading with validation in pkg/config/loader.go +- [X] T010 Implement config hot reload with fsnotify in pkg/config/watcher.go +- [X] T011 Create default configuration file in configs/config.yaml +- [X] T012 [P] Create environment-specific configs: config.dev.yaml, config.staging.yaml, config.prod.yaml +- [ ] T012a [P] Unit test for environment-specific config loading (test APP_ENV variable loads correct config file) in pkg/config/loader_test.go ### Logging Infrastructure (US2 Foundation) -- [ ] T013 Initialize Zap logger with JSON encoder in pkg/logger/logger.go -- [ ] T014 Setup Lumberjack rotation for app.log in pkg/logger/rotation.go -- [ ] T015 Setup Lumberjack rotation for access.log in pkg/logger/rotation.go -- [ ] T016 Create Fiber logger middleware adapter in pkg/logger/middleware.go +- [X] T013 Initialize Zap logger with JSON encoder in pkg/logger/logger.go +- [X] T014 Setup Lumberjack rotation for app.log in pkg/logger/logger.go (合并到 T013) +- [X] T015 Setup Lumberjack rotation for access.log in pkg/logger/logger.go (合并到 T013) +- [X] T016 Create Fiber logger middleware adapter in pkg/logger/middleware.go ### Redis Connection (US6 Foundation) -- [ ] T017 Setup Redis client with connection pool configuration in pkg/validator/redis.go or pkg/database/redis.go +- [X] T017 Setup Redis client with connection pool configuration in pkg/validator/token.go (使用 go-redis 直接在 main.go 中初始化) **Checkpoint**: Foundation ready - user story implementation can now begin in parallel @@ -258,14 +259,14 @@ - [ ] T093 [P] Create example requests (curl commands) in quickstart.md for all scenarios - [ ] T094 [P] Document middleware execution order in docs/ or README - [ ] T095 [P] Add troubleshooting section to quickstart.md +- [ ] T095a [P] Create docs/rate-limiting.md with configuration guide, code examples, testing instructions, storage options comparison, and common usage patterns (implements FR-020) ### Code Quality - [ ] T096 [P] Add Go doc comments to all exported functions and types -- [ ] T097 [P] Run gofmt on all Go files -- [ ] T098 [P] Run go vet and fix all issues -- [ ] T099 [P] Run golangci-lint and fix critical issues -- [ ] T100 Review all Redis key usage, ensure no hardcoded strings (use constants.RedisAuthTokenKey()) +- [ ] T097 [P] Run code quality checks (gofmt, go vet, golangci-lint) on all Go files +- [ ] T098 [P] Fix all formatting, linting, and static analysis issues reported by T097 +- [ ] T099 Review all Redis key usage, ensure no hardcoded strings (use constants.RedisAuthTokenKey()) - [ ] T101 Review all error handling, ensure explicit returns (no panic abuse) - [ ] T102 Review naming conventions (UserID not userId, HTTPServer not HttpServer) - [ ] T103 Check for Java-style anti-patterns (no I-prefix, no Impl-suffix, no getters/setters) @@ -468,9 +469,9 @@ Then converge for US7 and Polish. ## Task Summary -- **Total Tasks**: 126 +- **Total Tasks**: 127 (updated: added T012a for environment config testing, T095a for rate-limiting.md documentation, consolidated code quality checks from 4 tasks into 3 tasks) - **Setup Phase**: 7 tasks -- **Foundational Phase**: 10 tasks (BLOCKING) +- **Foundational Phase**: 11 tasks (BLOCKING) - includes T012a - **User Story 1** (Config): 8 tasks (3 tests + 5 implementation) - **User Story 2** (Logging): 8 tasks (3 tests + 5 implementation) - **User Story 3** (Response): 9 tasks (3 tests + 6 implementation) @@ -478,14 +479,14 @@ Then converge for US7 and Polish. - **User Story 5** (Recovery): 9 tasks (3 tests + 6 implementation) - **User Story 6** (Auth): 21 tasks (8 tests + 13 implementation) - **User Story 7** (Rate Limit): 10 tasks (3 tests + 7 implementation) -- **Polish Phase**: 35 tasks (documentation, quality, security) +- **Polish Phase**: 35 tasks (documentation, quality, security) - includes T095a, consolidated code quality checks -**Parallelizable Tasks**: 45 tasks marked [P] +**Parallelizable Tasks**: 47 tasks marked [P] (added T012a, T095a) **Test Coverage**: -- Unit tests: 23 tasks +- Unit tests: 24 tasks (added T012a) - Integration tests: 18 tasks -- Total test tasks: 41 (32% of all tasks) +- Total test tasks: 42 (33% of all tasks) **Constitution Compliance**: - ✅ All tasks follow Handler → Service → Store → Model pattern