All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s
- 合并 customer_account 和 shop_account 路由到统一的 account 接口 - 新增统一认证接口 (auth handler) - 实现越权防护中间件和权限检查工具函数 - 新增操作审计日志模型和服务 - 更新数据库迁移 (版本 39: account_operation_log 表) - 补充集成测试覆盖权限检查和审计日志场景
18 KiB
18 KiB
统一账号管理接口设计
Context
现状问题
当前系统存在三套独立的账号管理体系:
- AccountService + AccountHandler:管理"通用账号"和"平台账号",功能重复
- ShopAccountService + ShopAccountHandler:管理代理账号,功能不全(缺少角色管理)
- CustomerAccountService + CustomerAccountHandler:管理企业账号,命名错误(customer vs enterprise)
安全现状
Critical 漏洞:所有 Service 的 Create 方法缺少目标资源归属权限检查。攻击场景:
// 代理用户 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
- 统一架构:合并三套账号管理为一个 AccountService,消除代码重复
- 安全加固:修复 Create 越权漏洞,添加三层防护机制
- 操作审计:记录所有账号管理操作,满足合规要求
- 简化路由:统一路由结构
/api/admin/accounts/{type}/*,语义清晰 - 认证统一:合并后台和 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 需要相同权限检查时需重复实现
- 方案 B:在
pkg/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+ 个接口路径变更,前端需要同步更新,可能遗漏导致功能异常
缓解措施:
- 提供完整的新旧路由映射表(在 proposal.md 中已列出)
- 生成新的 OpenAPI 文档,前端通过文档更新
- 后端先部署,前端更新后再切流量
- 保留一周观察期,发现问题立即回滚
风险 2:操作审计日志丢失
风险:异步写入失败导致审计日志丢失,无法追溯操作记录
缓解措施:
- 写入失败记录 Error 级别日志,包含完整审计信息
- 通过访问日志(access.log)兜底,可以追溯请求记录
- 后续迭代升级为 Asynq 任务队列,支持重试和持久化
风险 3:权限检查性能影响
风险:每次 Create 操作需要调用 GetSubordinateShopIDs,可能影响性能
当前缓解:
- GetSubordinateShopIDs 已有 Redis 缓存(30分钟),命中率高
- 代理账号创建频率低(< 10 次/分钟),性能影响 < 5ms
未来优化:
- 如果成为瓶颈,可以预加载下级店铺 ID 到 context
- 超级管理员和平台用户跳过此检查,不受影响
权衡 1:审计日志查询接口延后
权衡:本次只实现日志记录,不实现查询接口
理由:
- 查询接口需要设计复杂的筛选条件(按时间、操作人、目标账号等)
- 需要考虑权限控制(代理只能查看自己店铺的日志)
- 优先保证核心功能(账号管理)稳定上线
- 后续迭代专门实现审计日志查询功能
权衡 2:删除而非标记废弃旧接口
权衡:激进策略,直接删除旧接口,而非保留并标记 deprecated
理由:
- 旧接口数量多(20+),保留会导致代码库臃肿
- 新旧接口功能完全重复,维护成本高
- 前端有资源配合同步更新(用户已确认)
- Breaking Change 在提案中已充分说明
后果:
- 前端必须同步更新,无法渐进迁移
- 发现问题需要立即回滚整个版本
- 需要充分测试后再上线
Migration Plan
阶段 1:代码重构(预计 3 天)
-
Day 1:权限检查和审计日志基础设施
- 创建
pkg/middleware/permission_helper.go - 创建审计日志 Model、Store、Service
- 创建数据库迁移文件
- 单元测试覆盖
- 创建
-
Day 2:AccountService 重构
- 扩展 AccountService,添加权限检查
- 集成审计日志记录
- 删除 ShopAccountService、CustomerAccountService
- 单元测试覆盖
-
Day 3:Handler 和路由重构
- 扩展 AccountHandler
- 删除 ShopAccountHandler、CustomerAccountHandler
- 重构路由注册逻辑
- 集成测试覆盖
阶段 2:测试和文档(预计 2 天)
-
Day 4:全面测试
- 集成测试:account_permission_test.go(越权防护)
- 集成测试:account_audit_test.go(审计日志)
- 回归测试:确保现有功能不受影响
- 性能测试:验证 P95 < 200ms
-
Day 5:文档和交接
- 生成新的 OpenAPI 文档
- 编写迁移指南(新旧路由映射)
- 前端对接会议,说明 Breaking Changes
- 准备回滚方案
阶段 3:部署和监控(预计 1 天)
-
Day 6:灰度发布
- 执行数据库迁移(创建审计日志表)
- 部署后端新版本
- 前端更新接口调用
- 监控错误率和响应时间
-
Day 7:全量观察
- 监控审计日志写入情况
- 监控 API 错误率(重点关注 403 错误)
- 验证权限检查有效性
- 准备随时回滚
回滚策略
触发条件:
- API 错误率 > 5%
- P95 响应时间 > 300ms
- 发现严重安全漏洞
- 前端无法在 1 天内完成迁移
回滚步骤:
- 回滚后端代码到上一个版本
- 前端回滚到旧接口调用
- 审计日志表保留(不删除数据)
- 总结问题,重新规划迁移
Open Questions
Q1:是否需要批量迁移现有账号数据?
当前状态:无需迁移,数据模型不变
说明:
- Account 表结构不变
- user_type 字段已经区分四种账号类型
- 只是接口和代码重构,不涉及数据迁移
Q2:审计日志是否需要定期归档?
当前决策:暂不归档,后续根据数据增长情况决定
说明:
- 初期数据量小(< 10万条/月)
- PostgreSQL JSONB 查询性能足够
- 如果后续数据量大(> 100万条),可以:
- 按月分表(tb_account_operation_log_202601)
- 或归档到对象存储
Q3:是否需要支持操作撤销功能?
当前决策:不支持,审计日志只做记录和查询
理由:
- 账号操作撤销逻辑复杂(如删除账号后重新激活)
- 现有需求不明确
- 可以通过手动操作实现(如重新创建账号)
- 后续如有需求再单独设计