## Context 当前项目处于框架搭建阶段,存在多处技术债务需要清理: - 两套 Auth 实现产生于不同开发阶段,未整合 - 示例代码(user/order)是早期测试用途,现已有真实 RBAC 代码 - pkg/errors 和 pkg/response 设计时职责划分不清晰 - DataPermissionScope 实现完整但从未集成使用 **约束条件**: - 必须保持 Go 惯用模式,避免 Java 风格过度抽象 - main.go 在未来开发中应该不需要修改 - 数据权限过滤必须支持绕过机制 ## Goals / Non-Goals ### Goals - 清理所有示例和重复代码,使框架干净整洁 - 统一认证、错误处理、响应格式的实现方式 - 实现数据权限的 GORM 自动化过滤 - 将组件初始化从 main.go 解耦,支持未来扩展 - 在关键扩展点添加 TODO 标记 ### Non-Goals - 不实现完整的 DI 框架(保持 Go 简洁风格) - 不实现自动注册机制(使用显式工厂模式) - 不重构现有 RBAC 业务逻辑 - 不添加新的业务功能 ## Decisions ### Decision 1: Auth 中间件合并策略 **选择**:重新设计合并版本 **实现方案**: ```go // pkg/middleware/auth.go - 合并版本 type AuthConfig struct { TokenExtractor func(*fiber.Ctx) string // 自定义 token 提取 SkipPaths []string // 跳过认证的路径 Validator func(string) (*UserInfo, error) // token 验证函数 } func Auth(cfg AuthConfig) fiber.Handler { return func(c *fiber.Ctx) error { // 1. 检查跳过路径 // 2. 提取 token // 3. 验证 token // 4. 设置用户上下文(同时设置 Locals 和 Context) // 5. 错误统一返回 AppError,由全局 ErrorHandler 处理 } } ``` **理由**: - 合并两者优点:pkg 版本的可配置性 + internal 版本的统一错误格式 - 统一使用 `return errors.New()` 让全局 ErrorHandler 处理 - 消除错误格式不一致问题 ### Decision 2: 组件注册解耦策略 **选择**:Bootstrap 包 + 按模块拆分 + 工厂函数模式 **实现方案**: ``` internal/bootstrap/ ├── bootstrap.go # 主入口,编排初始化流程 ├── dependencies.go # Dependencies 结构体定义 ├── stores.go # 所有 Store 初始化逻辑 ├── services.go # 所有 Service 初始化逻辑 ├── handlers.go # 所有 Handler 初始化逻辑 └── types.go # Handlers 结构体定义 ``` **bootstrap.go** - 主入口编排: ```go // Bootstrap 初始化所有组件并返回 Handlers func Bootstrap(deps *Dependencies) (*Handlers, error) { // 1. 初始化 GORM Callback(必须在 Store 之前) if err := registerGORMCallbacks(deps.DB); err != nil { return nil, err } // 2. 初始化 Stores stores := initStores(deps) // 3. 初始化 Services services := initServices(stores) // 4. 初始化 Handlers handlers := initHandlers(services) return handlers, nil } ``` **stores.go** - Store 层初始化: ```go type Stores struct { Account *postgres.AccountStore Role *postgres.RoleStore Permission *postgres.PermissionStore AccountRole *postgres.AccountRoleStore RolePermission *postgres.RolePermissionStore // TODO: 新增 Store 在此添加字段 } func initStores(deps *Dependencies) *Stores { return &Stores{ Account: postgres.NewAccountStore(deps.DB, deps.Redis), Role: postgres.NewRoleStore(deps.DB), Permission: postgres.NewPermissionStore(deps.DB), AccountRole: postgres.NewAccountRoleStore(deps.DB), RolePermission: postgres.NewRolePermissionStore(deps.DB), // TODO: 新增 Store 在此初始化 } } ``` **services.go** - Service 层初始化: ```go type Services struct { Account *accountSvc.Service Role *roleSvc.Service Permission *permissionSvc.Service // TODO: 新增 Service 在此添加字段 } func initServices(stores *Stores) *Services { return &Services{ Account: accountSvc.New(stores.Account, stores.Role, stores.AccountRole), Role: roleSvc.New(stores.Role, stores.Permission, stores.RolePermission), Permission: permissionSvc.New(stores.Permission), // TODO: 新增 Service 在此初始化 } } ``` **handlers.go** - Handler 层初始化: ```go func initHandlers(services *Services) *Handlers { return &Handlers{ Account: handler.NewAccountHandler(services.Account), Role: handler.NewRoleHandler(services.Role), Permission: handler.NewPermissionHandler(services.Permission), // TODO: 新增 Handler 在此初始化 } } ``` **types.go** - 类型定义: ```go // Handlers 封装所有 HTTP 处理器 type Handlers struct { Account *handler.AccountHandler Role *handler.RoleHandler Permission *handler.PermissionHandler // TODO: 新增 Handler 在此添加字段 } ``` **dependencies.go** - 基础依赖: ```go // Dependencies 封装所有基础依赖 type Dependencies struct { DB *gorm.DB Redis *redis.Client Logger *zap.Logger } ``` **main.go 简化后**: ```go func main() { // 初始化基础依赖 deps := initDependencies() // 一行完成所有业务组件初始化 handlers, err := bootstrap.Bootstrap(deps) // 设置路由 routes.Setup(app, handlers) // 启动服务 app.Listen(":8080") } ``` **理由**: - **按层次拆分**:stores.go、services.go、handlers.go 职责清晰 - **易于扩展**:每层只需在对应文件中添加初始化代码 - **文件大小可控**:每个文件 < 100 行,避免单文件臃肿 - **main.go 零修改**:新增业务只修改 bootstrap 内部文件 - **符合 Go 风格**:显式依赖注入,不使用复杂的 DI 框架 - **TODO 标记清晰**:每层都有明确的扩展点标记 ### Decision 3: 数据权限 GORM Callback 实现 **选择**:GORM Callback 自动化 + Context 绕过机制 **实现方案**: ```go // pkg/gorm/callback.go type contextKey string const SkipDataPermissionKey contextKey = "skip_data_permission" // SkipDataPermission 返回跳过数据权限过滤的 Context func SkipDataPermission(ctx context.Context) context.Context { return context.WithValue(ctx, SkipDataPermissionKey, true) } // RegisterDataPermissionCallback 注册 GORM Callback func RegisterDataPermissionCallback(db *gorm.DB, accountStore AccountStoreInterface) { db.Callback().Query().Before("gorm:query").Register("data_permission", func(tx *gorm.DB) { ctx := tx.Statement.Context // 检查是否跳过 if skip, ok := ctx.Value(SkipDataPermissionKey).(bool); ok && skip { return } // 检查 root 用户 if middleware.IsRootUser(ctx) { return } // 获取用户下级 ID 并应用过滤 userID := middleware.GetUserIDFromContext(ctx) subordinateIDs, _ := accountStore.GetSubordinateIDs(ctx, userID) // 只对包含 owner_id 字段的表应用过滤 if hasOwnerIDField(tx.Statement.Schema) { tx.Where("owner_id IN ?", subordinateIDs) } }) } ``` **使用方式**: ```go // 正常查询 - 自动应用数据权限过滤 db.WithContext(ctx).Find(&accounts) // 绕过权限过滤(如管理员操作、内部同步) ctx = gorm.SkipDataPermission(ctx) db.WithContext(ctx).Find(&accounts) ``` **理由**: - 完全自动化,开发者无需手动调用 Scope - 通过 Context 控制绕过,符合 Go 惯用模式 - 只对包含 owner_id 的表生效,安全可控 - 删除现有未使用的 scopes.go 代码 ### Decision 4: 简化 AppError 结构 **选择**:删除 AppError.HTTPStatus 字段和 WithHTTPStatus() 方法 **问题分析**: ```go type AppError struct { Code int // 业务错误码 Message string // 错误消息 HTTPStatus int // 冗余:总是从 Code 映射得到 Err error } ``` **冗余之处**: - HTTPStatus 字段总是通过 `GetHTTPStatus(code)` 从 Code 映射得到 - 存储 HTTPStatus 字段导致字段冗余 - WithHTTPStatus() 方法允许手动覆盖,可能导致状态码不一致 **优化方案**: ```go type AppError struct { Code int // 业务错误码 Message string // 错误消息 Err error // 底层错误(可选) } // 删除 WithHTTPStatus() 方法 // ErrorHandler 中直接调用 GetHTTPStatus(e.Code) 获取状态码 ``` **理由**: - **减少字段冗余**:HTTPStatus 可以实时计算,不需要存储 - **消除不一致风险**:禁止手动设置状态码,确保 Code 和 HTTPStatus 始终匹配 - **简化 AppError**:只保留核心字段(Code, Message, Err) - **保持职责分离**:AppError 只负责错误表示,HTTPStatus 由 ErrorHandler 处理 ### Decision 5: 错误处理统一策略 **选择**:删除 response.Error(),统一使用全局 ErrorHandler **当前格式分析**: ```go // pkg/errors/handler.go - 已经统一使用 msg c.Status(httpStatus).JSON(fiber.Map{ "code": code, "data": nil, "msg": message, // 当前已是 msg "timestamp": time.Now().Format(time.RFC3339), }) // pkg/response/response.go - 已经统一使用 msg type Response struct { Code int `json:"code"` Data any `json:"data"` Message string `json:"msg"` // JSON 标签是 msg Timestamp string `json:"timestamp"` } ``` **问题**: - `response.Error()` 函数允许手动构造错误响应,导致两种错误处理方式混用 - 需要手动传递 `httpStatus` 参数,容易出错 **解决方案**: ```go // pkg/response/response.go // 删除 Error() 函数,只保留: func Success(c *fiber.Ctx, data interface{}) error func SuccessWithMessage(c *fiber.Ctx, data interface{}, message string) error func SuccessWithPagination(c *fiber.Ctx, items any, total int64, page, size int) error ``` **Handler 统一写法**: ```go func (h *AccountHandler) Create(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数格式错误") } if err := h.service.Create(ctx, &req); err != nil { return err // 直接返回,由 ErrorHandler 处理 } return response.Success(c, account) } ``` **统一响应格式**(仅包含 4 个字段): ```json { "code": 0, "msg": "success", "data": {...}, "timestamp": "2025-11-19T..." } ``` **理由**: - **消除两种错误处理方式**:Handler 只能返回 error,不能手动构造错误响应 - **格式已统一**:错误和成功响应都使用 `msg` 字段 - **简化开发**:错误码到 HTTP 状态码的映射由 ErrorHandler 统一处理 - **避免字段冗余**:不返回 `httpstatus` 字段(HTTP 状态码已在响应头中) ## Risks / Trade-offs ### Risk 1: GORM Callback 性能开销 **风险**:每次查询都执行 Callback 可能影响性能 **缓解**: - GetSubordinateIDs 已实现 Redis 缓存(30分钟) - 通过 Schema 检查只对需要的表生效 - 监控查询性能,必要时优化 ### Risk 2: 删除代码可能影响未知依赖 **风险**:示例代码可能被测试或文档引用 **缓解**: - 搜索确认无任何引用 - 删除后运行完整测试 - 项目处于框架搭建阶段,风险可控 ### Risk 3: Bootstrap 多文件维护成本 **风险**:拆分成多个文件后,需要在多处添加新业务模块 **缓解**: - TODO 注释明确标记所有扩展点 - 保持文件结构简单清晰(stores.go, services.go, handlers.go) - 每个文件只负责一层初始化,职责单一 - 每个文件保持 < 100 行,易于理解和维护 ## Migration Plan 1. **Phase 1(清理)**: - 删除示例代码(user/order) - 合并 Auth 实现 - 验证现有功能不受影响 2. **Phase 2(解耦)**: - 创建 bootstrap 包 - 重构 main.go - 验证启动流程正常 3. **Phase 3(自动化)**: - 实现 GORM Callback - 删除 scopes.go - 添加绕过机制测试 4. **Phase 4(规范化)**: - 统一错误格式 - 删除 Error() 函数 - 更新所有 Handler 写法 **回滚策略**: - 使用 Git 分支,每个 Phase 可独立回滚 - 保留删除代码的备份(或通过 Git 历史恢复) ## Open Questions 1. **owner_id 字段检测**:如何优雅地检测表是否需要数据权限过滤? - 方案 A:检查 Schema 是否有 owner_id 字段 - 方案 B:使用接口标记(如 `DataPermissionAware`) - 建议:先用方案 A,必要时再重构 2. **多租户支持**:shop_id 过滤是否也应该自动化? - 当前 DataPermissionScope 支持 shop_id - 建议:本次只自动化 owner_id,shop_id 作为 TODO 3. **Callback 注册时机**:应该在哪里注册 GORM Callback? - 建议:在 bootstrap 包初始化 DB 后立即注册