refactor(account): 统一账号管理API、完善权限检查和操作审计
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s

- 合并 customer_account 和 shop_account 路由到统一的 account 接口
- 新增统一认证接口 (auth handler)
- 实现越权防护中间件和权限检查工具函数
- 新增操作审计日志模型和服务
- 更新数据库迁移 (版本 39: account_operation_log 表)
- 补充集成测试覆盖权限检查和审计日志场景
This commit is contained in:
2026-02-02 17:23:20 +08:00
parent 5851cc6403
commit 80f560df33
58 changed files with 10743 additions and 4915 deletions

View File

@@ -0,0 +1,494 @@
# 统一账号管理接口设计
## Context
### 现状问题
当前系统存在三套独立的账号管理体系:
1. **AccountService** + **AccountHandler**:管理"通用账号"和"平台账号",功能重复
2. **ShopAccountService** + **ShopAccountHandler**:管理代理账号,功能不全(缺少角色管理)
3. **CustomerAccountService** + **CustomerAccountHandler**管理企业账号命名错误customer vs enterprise
### 安全现状
**Critical 漏洞**:所有 Service 的 Create 方法缺少目标资源归属权限检查。攻击场景:
```go
// 代理用户 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三层越权防护架构
**第一层:路由层中间件(粗粒度拦截)**
```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是否需要支持操作撤销功能
**当前决策**:不支持,审计日志只做记录和查询
**理由**
- 账号操作撤销逻辑复杂(如删除账号后重新激活)
- 现有需求不明确
- 可以通过手动操作实现(如重新创建账号)
- 后续如有需求再单独设计