# 统一账号管理接口设计

## 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：是否需要支持操作撤销功能？
**当前决策**：不支持，审计日志只做记录和查询

**理由**：
- 账号操作撤销逻辑复杂（如删除账号后重新激活）
- 现有需求不明确
- 可以通过手动操作实现（如重新创建账号）
- 后续如有需求再单独设计
