Files
junhong_cmp_fiber/openspec/changes/archive/2026-02-02-unify-account-management-api/design.md
huang 80f560df33
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s
refactor(account): 统一账号管理API、完善权限检查和操作审计
- 合并 customer_account 和 shop_account 路由到统一的 account 接口
- 新增统一认证接口 (auth handler)
- 实现越权防护中间件和权限检查工具函数
- 新增操作审计日志模型和服务
- 更新数据库迁移 (版本 39: account_operation_log 表)
- 补充集成测试覆盖权限检查和审计日志场景
2026-02-02 17:23:20 +08:00

18 KiB
Raw Blame History

统一账号管理接口设计

Context

现状问题

当前系统存在三套独立的账号管理体系:

  1. AccountService + AccountHandler:管理"通用账号"和"平台账号",功能重复
  2. ShopAccountService + ShopAccountHandler:管理代理账号,功能不全(缺少角色管理)
  3. CustomerAccountService + CustomerAccountHandler管理企业账号命名错误customer vs enterprise

安全现状

Critical 漏洞:所有 Service 的 Create 方法缺少目标资源归属权限检查。攻击场景:

// 代理用户 Ashop_id=100发起请求
POST /api/admin/shop-accounts
{ "shop_id": 200, "username": "hacker", ... }

// 当前实现:只检查店铺存在,直接创建成功 ❌

已有防护机制

  • GORM Callback 自动过滤pkg/gorm/callback.go):所有查询自动应用数据权限过滤
    • 代理用户:WHERE shop_id IN (自己店铺+下级店铺)
    • 企业用户:WHERE enterprise_id = 当前企业ID
    • 平台/超管:跳过过滤
  • 递归查询下级店铺ShopStore.GetSubordinateShopIDs支持7级层级Redis 缓存30分钟

约束条件

  • 必须遵循 Handler → Service → Store → Model 分层
  • 禁止外键约束,表关联通过 ID 字段手动维护
  • 所有业务逻辑在 Service 层Handler 只做参数验证和路由
  • 错误处理使用 pkg/errors 统一错误码
  • 审计日志异步写入,不阻塞主流程

Goals / Non-Goals

Goals

  1. 统一架构:合并三套账号管理为一个 AccountService消除代码重复
  2. 安全加固:修复 Create 越权漏洞,添加三层防护机制
  3. 操作审计:记录所有账号管理操作,满足合规要求
  4. 简化路由:统一路由结构 /api/admin/accounts/{type}/*,语义清晰
  5. 认证统一:合并后台和 H5 认证为 /api/auth/*

Non-Goals

  • 修改 GORM Callback 自动过滤逻辑(已经完善,保持不变)
  • 重构角色和权限管理接口(不在本次范围)
  • 修改个人客户认证接口(业务逻辑独立,保持不变)
  • 添加实时审计日志查询接口(本次只做记录,查询接口后续迭代)

Decisions

决策 1路由结构设计

选择:按账号类型分组的 RESTful 风格

/api/admin/accounts/platform/*   (平台账号)
/api/admin/accounts/shop/*        (代理账号)
/api/admin/accounts/enterprise/*  (企业账号)

备选方案

  • 方案 A单一路由 + query 参数(如 /api/admin/accounts?type=platform
    • 拒绝原因:语义不清,不符合 RESTful 规范,前端调用复杂
  • 方案 B保留三个独立路由/platform-accounts/shop-accounts
    • 拒绝原因:与统一架构目标冲突,未解决重复问题

理由

  • 语义清晰,账号类型一目了然
  • 符合 RESTful 规范,易于理解和文档化
  • 便于路由层添加类型专用中间件(如企业账号拦截)
  • 前端调用直观,便于维护

决策 2三层越权防护架构

第一层:路由层中间件(粗粒度拦截)

// internal/routes/account.go
func registerEnterpriseAccountRoutes(router fiber.Router, ...) {
    accounts := router.Group("/accounts/enterprise")
    
    // 企业账号禁止访问账号管理接口
    accounts.Use(func(c *fiber.Ctx) error {
        userType := middleware.GetUserTypeFromContext(c.UserContext())
        if userType == constants.UserTypeEnterprise {
            return errors.New(errors.CodeForbidden, "无权限访问账号管理功能")
        }
        return c.Next()
    })
    
    // 注册路由...
}

第二层Service 层业务检查(细粒度验证)

// internal/service/account/service.go
func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) error {
    // 1. 基础认证检查
    currentUserID := middleware.GetUserIDFromContext(ctx)
    if currentUserID == 0 {
        return errors.New(errors.CodeUnauthorized, "未授权访问")
    }
    
    userType := middleware.GetUserTypeFromContext(ctx)
    
    // 2. 类型级权限检查
    // 企业账号禁止创建账号
    if userType == constants.UserTypeEnterprise {
        return errors.New(errors.CodeForbidden, "企业账号不允许创建账号")
    }
    
    // 代理账号不能创建平台账号
    if userType == constants.UserTypeAgent && req.UserType == constants.UserTypePlatform {
        return errors.New(errors.CodeForbidden, "无权限创建平台账号")
    }
    
    // 3. 资源级权限检查(核心:修复越权漏洞)
    if req.UserType == constants.UserTypeAgent && req.ShopID != nil {
        if err := middleware.CanManageShop(ctx, *req.ShopID, s.shopStore); err != nil {
            return err  // 返回"无权限管理该店铺的账号"
        }
    }
    
    if req.UserType == constants.UserTypeEnterprise && req.EnterpriseID != nil {
        if err := middleware.CanManageEnterprise(ctx, *req.EnterpriseID, s.enterpriseStore); err != nil {
            return err  // 返回"无权限管理该企业的账号"
        }
    }
    
    // 4. 创建账号...
}

第三层GORM Callback 自动过滤(兜底)

  • 已有实现,保持不变
  • 所有 List/Get 操作自动过滤
  • 防止直接 SQL 注入绕过应用层检查

理由

  • 多层防御,单层失效不会导致全局崩溃
  • 第一层快速拦截明显越权,节省资源
  • 第二层精确验证业务逻辑,覆盖所有场景
  • 第三层兜底,防止绕过应用层检查

决策 3权限检查辅助函数设计

位置pkg/middleware/permission_helper.go(而非 Service 内部)

接口设计

// CanManageShop 检查当前用户是否有权管理目标店铺的账号
// 返回 nil 表示有权限,返回 error 表示无权限
func CanManageShop(ctx context.Context, targetShopID uint, shopStore ShopStoreInterface) error

// CanManageEnterprise 检查当前用户是否有权管理目标企业的账号
func CanManageEnterprise(ctx context.Context, targetEnterpriseID uint, 
    enterpriseStore EnterpriseStoreInterface, shopStore ShopStoreInterface) error

备选方案

  • 方案 A在 AccountService 内部实现为私有方法
    • 拒绝原因:无法复用,其他 Service 需要相同权限检查时需重复实现
  • 方案 Bpkg/utils 中实现
    • 拒绝原因utils 包应该是纯函数,不应依赖 Store 接口

理由

  • pkg/middleware 是权限相关逻辑的自然归属
  • 可以被多个 Service 复用AccountService、RoleService 等)
  • 通过接口依赖 Store遵循依赖倒置原则便于测试

决策 4操作审计日志设计

表结构

CREATE TABLE tb_account_operation_log (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    -- 操作主体
    operator_id BIGINT NOT NULL,           -- 操作人 ID
    operator_type INT NOT NULL,            -- 操作人类型 (1=超管 2=平台 3=代理 4=企业)
    operator_name VARCHAR(255) NOT NULL,   -- 操作人用户名
    
    -- 操作对象
    target_account_id BIGINT,              -- 目标账号 ID可选删除操作后可能查不到
    target_username VARCHAR(255),          -- 目标账号用户名
    target_user_type INT,                  -- 目标账号类型
    
    -- 操作内容
    operation_type VARCHAR(50) NOT NULL,   -- create/update/delete/assign_roles/remove_role
    operation_desc TEXT NOT NULL,          -- 操作描述(中文)
    
    -- 变更详情JSON 格式)
    before_data JSONB,                     -- 变更前数据update 操作)
    after_data JSONB,                      -- 变更后数据create/update 操作)
    
    -- 请求上下文
    request_id VARCHAR(255),               -- 请求 ID关联访问日志
    ip_address VARCHAR(50),                -- 操作 IP
    user_agent TEXT                        -- User-Agent
);

CREATE INDEX idx_account_log_operator ON tb_account_operation_log(operator_id, created_at);
CREATE INDEX idx_account_log_target ON tb_account_operation_log(target_account_id, created_at);
CREATE INDEX idx_account_log_created ON tb_account_operation_log(created_at DESC);

异步写入策略

  • 使用 Goroutine 异步写入,不阻塞主流程
  • 写入失败只记录错误日志,不影响业务操作
  • 未来可扩展为 Asynq 任务队列(支持重试)

Service 设计

// internal/service/account_audit/service.go
type Service struct {
    store *postgres.AccountOperationLogStore
}

func (s *Service) LogOperation(ctx context.Context, log *model.AccountOperationLog) {
    // 异步写入,不阻塞主流程
    go func() {
        if err := s.store.Create(context.Background(), log); err != nil {
            logger.GetAppLogger().Error("写入账号操作日志失败",
                zap.Uint("operator_id", log.OperatorID),
                zap.String("operation_type", log.OperationType),
                zap.Error(err))
        }
    }()
}

集成方式

// AccountService.Create 中集成
func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*model.Account, error) {
    // 1. 权限检查...
    
    // 2. 创建账号...
    account, err := s.accountStore.Create(ctx, account)
    if err != nil {
        return nil, err
    }
    
    // 3. 记录审计日志(异步)
    s.auditService.LogOperation(ctx, &model.AccountOperationLog{
        OperatorID:       currentUserID,
        OperatorType:     currentUserType,
        OperatorName:     currentUsername,
        TargetAccountID:  &account.ID,
        TargetUsername:   account.Username,
        TargetUserType:   account.UserType,
        OperationType:    "create",
        OperationDesc:    fmt.Sprintf("创建账号: %s", account.Username),
        AfterData:        toJSON(account),
        RequestID:        middleware.GetRequestIDFromContext(ctx),
        IPAddress:        middleware.GetIPFromContext(ctx),
        UserAgent:        middleware.GetUserAgentFromContext(ctx),
    })
    
    return account, nil
}

理由

  • JSONB 字段存储完整变更数据,便于审计和回溯
  • 异步写入不影响业务性能
  • 关联 request_id 可以串联访问日志和审计日志
  • 索引优化支持按操作人、目标账号、时间快速查询

决策 5统一错误返回策略

原则:越权访问统一返回"无权限操作该资源或资源不存在"

实现

// Update 操作
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountRequest) error {
    // 1. GetByID 会被 GORM Callback 自动过滤
    account, err := s.accountStore.GetByID(ctx, id)
    if err != nil {
        if err == gorm.ErrRecordNotFound {
            // ✅ 统一返回:可能是越权,也可能是真不存在
            return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
        }
        return errors.Wrap(errors.CodeInternalError, err, "获取账号失败")
    }
    
    // 2. 二次权限验证(虽然 GetByID 已过滤,但显式检查更安全)
    userType := middleware.GetUserTypeFromContext(ctx)
    if userType == constants.UserTypeAgent {
        if account.ShopID == nil {
            return errors.New(errors.CodeForbidden, "无权限操作该账号")
        }
        if err := middleware.CanManageShop(ctx, *account.ShopID, s.shopStore); err != nil {
            return err
        }
    }
    
    // 3. 更新操作...
}

理由

  • 防止信息泄露(攻击者无法通过错误消息判断资源是否存在)
  • 统一用户体验(所有越权场景返回相同错误消息)
  • 符合安全最佳实践OWASP 推荐)

决策 6认证接口统一策略

保守合并:只合并后台和 H5 认证,保留个人客户认证

理由

  • 后台和 H5 认证逻辑完全相同:
    • 都是基于用户名+密码登录
    • 都返回 Access Token + Refresh Token
    • 都使用 Redis 存储 Token
    • 都支持相同的用户类型(超管、平台、代理、企业)
  • 个人客户认证逻辑不同:
    • 支持微信授权登录OAuth
    • 支持手机号+验证码登录
    • Token 使用 JWT 而非 Redis
    • 业务逻辑独立,不适合合并

实现

// 新路由:/api/auth/*
POST   /api/auth/login          // 统一登录(后台+H5
POST   /api/auth/logout         // 统一登出
POST   /api/auth/refresh-token  // 刷新 Token
GET    /api/auth/me             // 获取用户信息
PUT    /api/auth/password       // 修改密码

// 保留:/api/c/v1/*(个人客户认证)
POST   /api/c/v1/login/send-code   // 发送验证码
POST   /api/c/v1/login             // 手机号登录
POST   /api/c/v1/wechat/auth       // 微信授权登录

向后兼容处理

  • 旧接口立即删除(激进策略)
  • 前端需要同步更新所有认证接口调用
  • 通过 API 文档和 Breaking Changes 公告通知前端

Risks / Trade-offs

风险 1前端大规模接口迁移

风险20+ 个接口路径变更,前端需要同步更新,可能遗漏导致功能异常

缓解措施

  1. 提供完整的新旧路由映射表(在 proposal.md 中已列出)
  2. 生成新的 OpenAPI 文档,前端通过文档更新
  3. 后端先部署,前端更新后再切流量
  4. 保留一周观察期,发现问题立即回滚

风险 2操作审计日志丢失

风险:异步写入失败导致审计日志丢失,无法追溯操作记录

缓解措施

  1. 写入失败记录 Error 级别日志,包含完整审计信息
  2. 通过访问日志access.log兜底可以追溯请求记录
  3. 后续迭代升级为 Asynq 任务队列,支持重试和持久化

风险 3权限检查性能影响

风险:每次 Create 操作需要调用 GetSubordinateShopIDs可能影响性能

当前缓解

  • GetSubordinateShopIDs 已有 Redis 缓存30分钟命中率高
  • 代理账号创建频率低(< 10 次/分钟),性能影响 < 5ms

未来优化

  • 如果成为瓶颈,可以预加载下级店铺 ID 到 context
  • 超级管理员和平台用户跳过此检查,不受影响

权衡 1审计日志查询接口延后

权衡:本次只实现日志记录,不实现查询接口

理由

  • 查询接口需要设计复杂的筛选条件(按时间、操作人、目标账号等)
  • 需要考虑权限控制(代理只能查看自己店铺的日志)
  • 优先保证核心功能(账号管理)稳定上线
  • 后续迭代专门实现审计日志查询功能

权衡 2删除而非标记废弃旧接口

权衡:激进策略,直接删除旧接口,而非保留并标记 deprecated

理由

  • 旧接口数量多20+),保留会导致代码库臃肿
  • 新旧接口功能完全重复,维护成本高
  • 前端有资源配合同步更新(用户已确认)
  • Breaking Change 在提案中已充分说明

后果

  • 前端必须同步更新,无法渐进迁移
  • 发现问题需要立即回滚整个版本
  • 需要充分测试后再上线

Migration Plan

阶段 1代码重构预计 3 天)

  1. Day 1:权限检查和审计日志基础设施

    • 创建 pkg/middleware/permission_helper.go
    • 创建审计日志 Model、Store、Service
    • 创建数据库迁移文件
    • 单元测试覆盖
  2. Day 2AccountService 重构

    • 扩展 AccountService添加权限检查
    • 集成审计日志记录
    • 删除 ShopAccountService、CustomerAccountService
    • 单元测试覆盖
  3. Day 3Handler 和路由重构

    • 扩展 AccountHandler
    • 删除 ShopAccountHandler、CustomerAccountHandler
    • 重构路由注册逻辑
    • 集成测试覆盖

阶段 2测试和文档预计 2 天)

  1. Day 4:全面测试

    • 集成测试account_permission_test.go越权防护
    • 集成测试account_audit_test.go审计日志
    • 回归测试:确保现有功能不受影响
    • 性能测试:验证 P95 < 200ms
  2. Day 5:文档和交接

    • 生成新的 OpenAPI 文档
    • 编写迁移指南(新旧路由映射)
    • 前端对接会议,说明 Breaking Changes
    • 准备回滚方案

阶段 3部署和监控预计 1 天)

  1. Day 6:灰度发布

    • 执行数据库迁移(创建审计日志表)
    • 部署后端新版本
    • 前端更新接口调用
    • 监控错误率和响应时间
  2. Day 7:全量观察

    • 监控审计日志写入情况
    • 监控 API 错误率(重点关注 403 错误)
    • 验证权限检查有效性
    • 准备随时回滚

回滚策略

触发条件

  • API 错误率 > 5%
  • P95 响应时间 > 300ms
  • 发现严重安全漏洞
  • 前端无法在 1 天内完成迁移

回滚步骤

  1. 回滚后端代码到上一个版本
  2. 前端回滚到旧接口调用
  3. 审计日志表保留(不删除数据)
  4. 总结问题,重新规划迁移

Open Questions

Q1是否需要批量迁移现有账号数据

当前状态:无需迁移,数据模型不变

说明

  • Account 表结构不变
  • user_type 字段已经区分四种账号类型
  • 只是接口和代码重构,不涉及数据迁移

Q2审计日志是否需要定期归档

当前决策:暂不归档,后续根据数据增长情况决定

说明

  • 初期数据量小(< 10万条/月)
  • PostgreSQL JSONB 查询性能足够
  • 如果后续数据量大(> 100万条可以
    • 按月分表tb_account_operation_log_202601
    • 或归档到对象存储

Q3是否需要支持操作撤销功能

当前决策:不支持,审计日志只做记录和查询

理由

  • 账号操作撤销逻辑复杂(如删除账号后重新激活)
  • 现有需求不明确
  • 可以通过手动操作实现(如重新创建账号)
  • 后续如有需求再单独设计