# 统一账号管理接口设计 ## Context ### 现状问题 当前系统存在三套独立的账号管理体系: 1. **AccountService** + **AccountHandler**:管理"通用账号"和"平台账号",功能重复 2. **ShopAccountService** + **ShopAccountHandler**:管理代理账号,功能不全(缺少角色管理) 3. **CustomerAccountService** + **CustomerAccountHandler**:管理企业账号,命名错误(customer vs enterprise) ### 安全现状 **Critical 漏洞**:所有 Service 的 Create 方法缺少目标资源归属权限检查。攻击场景: ```go // 代理用户 A(shop_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:三层越权防护架构 **第一层:路由层中间件(粗粒度拦截)** ```go // 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 层业务检查(细粒度验证)** ```go // 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 内部) **接口设计**: ```go // 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 需要相同权限检查时需重复实现 - 方案 B:在 `pkg/utils` 中实现 - ❌ 拒绝原因:utils 包应该是纯函数,不应依赖 Store 接口 **理由**: - ✅ `pkg/middleware` 是权限相关逻辑的自然归属 - ✅ 可以被多个 Service 复用(AccountService、RoleService 等) - ✅ 通过接口依赖 Store,遵循依赖倒置原则,便于测试 ### 决策 4:操作审计日志设计 **表结构**: ```sql 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 设计**: ```go // 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)) } }() } ``` **集成方式**: ```go // 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:统一错误返回策略 **原则**:越权访问统一返回"无权限操作该资源或资源不存在" **实现**: ```go // 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 - 业务逻辑独立,不适合合并 **实现**: ```go // 新路由:/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 2**:AccountService 重构 - 扩展 AccountService,添加权限检查 - 集成审计日志记录 - 删除 ShopAccountService、CustomerAccountService - 单元测试覆盖 3. **Day 3**:Handler 和路由重构 - 扩展 AccountHandler - 删除 ShopAccountHandler、CustomerAccountHandler - 重构路由注册逻辑 - 集成测试覆盖 ### 阶段 2:测试和文档(预计 2 天) 4. **Day 4**:全面测试 - 集成测试:account_permission_test.go(越权防护) - 集成测试:account_audit_test.go(审计日志) - 回归测试:确保现有功能不受影响 - 性能测试:验证 P95 < 200ms 5. **Day 5**:文档和交接 - 生成新的 OpenAPI 文档 - 编写迁移指南(新旧路由映射) - 前端对接会议,说明 Breaking Changes - 准备回滚方案 ### 阶段 3:部署和监控(预计 1 天) 6. **Day 6**:灰度发布 - 执行数据库迁移(创建审计日志表) - 部署后端新版本 - 前端更新接口调用 - 监控错误率和响应时间 7. **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:是否需要支持操作撤销功能? **当前决策**:不支持,审计日志只做记录和查询 **理由**: - 账号操作撤销逻辑复杂(如删除账号后重新激活) - 现有需求不明确 - 可以通过手动操作实现(如重新创建账号) - 后续如有需求再单独设计