All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s
- 合并 customer_account 和 shop_account 路由到统一的 account 接口 - 新增统一认证接口 (auth handler) - 实现越权防护中间件和权限检查工具函数 - 新增操作审计日志模型和服务 - 更新数据库迁移 (版本 39: account_operation_log 表) - 补充集成测试覆盖权限检查和审计日志场景
495 lines
18 KiB
Markdown
495 lines
18 KiB
Markdown
# 统一账号管理接口设计
|
||
|
||
## 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:是否需要支持操作撤销功能?
|
||
**当前决策**:不支持,审计日志只做记录和查询
|
||
|
||
**理由**:
|
||
- 账号操作撤销逻辑复杂(如删除账号后重新激活)
|
||
- 现有需求不明确
|
||
- 可以通过手动操作实现(如重新创建账号)
|
||
- 后续如有需求再单独设计
|