refactor: align framework cleanup with new bootstrap flow
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
422
openspec/changes/refactor-framework-cleanup/design.md
Normal file
422
openspec/changes/refactor-framework-cleanup/design.md
Normal file
@@ -0,0 +1,422 @@
|
||||
## 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 后立即注册
|
||||
Reference in New Issue
Block a user