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

495 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 统一账号管理接口设计
## 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是否需要支持操作撤销功能
**当前决策**:不支持,审计日志只做记录和查询
**理由**
- 账号操作撤销逻辑复杂(如删除账号后重新激活)
- 现有需求不明确
- 可以通过手动操作实现(如重新创建账号)
- 后续如有需求再单独设计