# Design: refactor-data-permission-filter

## Context

当前系统使用 GORM Callback 在查询前自动注入数据权限过滤条件。该机制存在以下问题：

1. **隐式行为**：开发者不知道查询被加了什么条件，SQL 调试困难
2. **跳过率高**：20+ 处使用 `SkipDataPermission`，说明自动过滤不适用于大量场景
3. **特殊处理多**：`tb_shop`、`tb_tag`、`tb_enterprise_card_authorization` 等需要特殊逻辑
4. **重复查询**：同一请求中 Service 层 `CanManageShop` 和 Callback 都调用 `GetSubordinateShopIDs`
5. **原生 SQL 失效**：Callback 无法处理原生 SQL，需要在 Store 里手动重复写过滤逻辑

**涉及的 Store 统计：**
- 需要改动：16 个 Store、85+ 个查询方法
- 无需改动：32 个 Store（系统全局表、关联表等）

## Goals / Non-Goals

**Goals:**
- 数据权限过滤行为显式可控，便于调试
- 单次请求内下级店铺 ID 只查询一次（中间件预计算）
- 保持现有的数据隔离行为不变（代理看自己+下级，企业看自己，平台看全部）
- NULL `shop_id` 记录对代理用户不可见（保持现有行为）

**Non-Goals:**
- 不改变数据权限的业务规则
- 不修改 Redis 缓存策略（仍保持 30 分钟过期）
- 不处理个人客户的数据权限（保持 `customer_id` / `creator` 字段的现有逻辑）

## Decisions

### Decision 1: 中间件预计算 vs 懒加载

**选择**：中间件预计算

**方案对比：**

| 方案 | 优点 | 缺点 |
|------|------|------|
| 中间件预计算 | 单次请求只查一次；代码简单 | 所有请求都计算，即使不需要 |
| 懒加载（首次使用时计算） | 按需计算 | 需要加锁防止并发重复计算；代码复杂 |

**理由**：
1. 绝大多数 API 都需要数据权限过滤，"不需要"是少数情况
2. `GetSubordinateShopIDs` 有 Redis 缓存，命中率高，预计算开销小
3. 代码更简单，不需要处理并发问题

### Decision 2: Helper 函数设计

**选择**：多个专用函数而非通用函数

```go
// 专用函数（选择）
func ApplyShopFilter(ctx context.Context, query *gorm.DB) *gorm.DB
func ApplyEnterpriseFilter(ctx context.Context, query *gorm.DB) *gorm.DB
func ApplyOwnerShopFilter(ctx context.Context, query *gorm.DB) *gorm.DB

// 通用函数（否决）
func ApplyDataPermission(ctx context.Context, query *gorm.DB, field string) *gorm.DB
```

**理由**：
1. 字段名固定（`shop_id`、`enterprise_id`、`owner_shop_id`），无需参数化
2. 专用函数调用更清晰，IDE 自动补全友好
3. 不同字段的过滤逻辑可能有细微差异，专用函数更灵活

### Decision 3: UserContextInfo 扩展 vs 新增 DataScope 结构体

**选择**：扩展 UserContextInfo

```go
// 扩展现有结构体（选择）
type UserContextInfo struct {
    UserID              uint
    UserType            int
    ShopID              uint
    EnterpriseID        uint
    CustomerID          uint
    SubordinateShopIDs  []uint   // 新增
}

// 新增结构体（否决）
type DataScope struct {
    SubordinateShopIDs []uint
}
```

**理由**：
1. 减少 Context 中的 key 数量
2. 用户信息和权限范围本来就是强关联的
3. 现有代码已经大量使用 `UserContextInfo`，扩展更自然

### Decision 4: AuthConfig 传入 ShopStore vs 全局注册

**选择**：AuthConfig 传入

```go
// AuthConfig 传入（选择）
type AuthConfig struct {
    TokenExtractor func(c *fiber.Ctx) string
    TokenValidator func(token string) (*UserContextInfo, error)
    SkipPaths      []string
    ShopStore      ShopStoreInterface  // 新增
}

// 全局注册（否决）
var globalShopStore ShopStoreInterface
func RegisterShopStore(s ShopStoreInterface) { ... }
```

**理由**：
1. 显式依赖注入，便于测试
2. 避免全局变量，符合 Go 最佳实践
3. 与现有 `TokenValidator` 传入方式一致

### Decision 5: nil vs 空切片表示"不限制"

**选择**：nil 表示不限制

```go
// nil 表示不限制（选择）
if shopIDs := GetSubordinateShopIDs(ctx); shopIDs != nil {
    query = query.Where("shop_id IN ?", shopIDs)
}

// 空切片表示不限制（否决）
if len(shopIDs) > 0 {
    query = query.Where("shop_id IN ?", shopIDs)
}
```

**理由**：
1. 语义更清晰：nil = 未设置/不限制，`[]uint{}` = 设置了但列表为空
2. 空切片 `WHERE shop_id IN ()` 在 SQL 中是无效语法
3. 便于区分"平台用户不限制"和"代理用户但店铺列表为空（异常情况）"

## Implementation

### 文件结构

```
pkg/middleware/
├── auth.go              # 扩展 UserContextInfo，修改 Auth 中间件
├── data_scope.go        # 新增：Helper 函数
└── permission_helper.go # 修改：CanManageShop 等函数签名

pkg/gorm/
└── callback.go          # 移除 RegisterDataPermissionCallback
```

### 核心代码结构

**1. UserContextInfo 扩展（auth.go）**

```go
type UserContextInfo struct {
    UserID              uint
    UserType            int
    ShopID              uint
    EnterpriseID        uint
    CustomerID          uint
    SubordinateShopIDs  []uint   // 新增：代理用户的下级店铺ID列表，nil表示不限制
}
```

**2. Auth 中间件改造（auth.go）**

```go
func Auth(config AuthConfig) fiber.Handler {
    return func(c *fiber.Ctx) error {
        // ... 现有 token 验证逻辑 ...

        // 新增：预计算 SubordinateShopIDs
        if config.ShopStore != nil &&
           userInfo.UserType == constants.UserTypeAgent &&
           userInfo.ShopID > 0 {
            shopIDs, err := config.ShopStore.GetSubordinateShopIDs(c.UserContext(), userInfo.ShopID)
            if err != nil {
                // 降级处理
                shopIDs = []uint{userInfo.ShopID}
                logger.Warn("获取下级店铺失败，降级为只包含自己", zap.Error(err))
            }
            userInfo.SubordinateShopIDs = shopIDs
        }

        SetUserToFiberContext(c, userInfo)
        return c.Next()
    }
}
```

**3. Helper 函数（data_scope.go）**

```go
// GetSubordinateShopIDs 获取当前用户可管理的店铺ID列表
// 返回 nil 表示不受限制（平台用户/超管）
func GetSubordinateShopIDs(ctx context.Context) []uint {
    if ctx == nil {
        return nil
    }
    if ids, ok := ctx.Value(constants.ContextKeySubordinateShopIDs).([]uint); ok {
        return ids
    }
    return nil
}

// ApplyShopFilter 应用店铺数据权限过滤
// 平台用户/超管：不添加条件
// 代理用户：WHERE shop_id IN (subordinateShopIDs)
func ApplyShopFilter(ctx context.Context, query *gorm.DB) *gorm.DB {
    shopIDs := GetSubordinateShopIDs(ctx)
    if shopIDs == nil {
        return query
    }
    return query.Where("shop_id IN ?", shopIDs)
}

// ApplyEnterpriseFilter 应用企业数据权限过滤
// 非企业用户：不添加条件
// 企业用户：WHERE enterprise_id = ?
func ApplyEnterpriseFilter(ctx context.Context, query *gorm.DB) *gorm.DB {
    userType := GetUserTypeFromContext(ctx)
    if userType != constants.UserTypeEnterprise {
        return query
    }
    enterpriseID := GetEnterpriseIDFromContext(ctx)
    if enterpriseID == 0 {
        return query.Where("1 = 0") // 企业用户但无企业ID，返回空
    }
    return query.Where("enterprise_id = ?", enterpriseID)
}

// ApplyOwnerShopFilter 应用归属店铺数据权限过滤
// 用于 Enterprise 等使用 owner_shop_id 的表
func ApplyOwnerShopFilter(ctx context.Context, query *gorm.DB) *gorm.DB {
    shopIDs := GetSubordinateShopIDs(ctx)
    if shopIDs == nil {
        return query
    }
    return query.Where("owner_shop_id IN ?", shopIDs)
}
```

**4. Store 层调用示例**

```go
// 改造前
func (s *DeviceStore) List(ctx context.Context, opts *QueryOptions) ([]*model.Device, error) {
    query := s.db.WithContext(ctx).Model(&model.Device{})
    // ... GORM Callback 自动添加 WHERE shop_id IN (...) ...
    return ...
}

// 改造后
func (s *DeviceStore) List(ctx context.Context, opts *QueryOptions) ([]*model.Device, error) {
    query := s.db.WithContext(ctx).Model(&model.Device{})
    query = middleware.ApplyShopFilter(ctx, query)  // 显式调用
    // ...
    return ...
}
```

## Risks / Trade-offs

### Risk 1: Store 层遗漏过滤调用

**风险**：改造过程中可能遗漏某些查询方法，导致数据泄露

**缓解措施**：
1. 按照 proposal 中的 Store 清单逐一检查
2. 代码审查重点关注权限过滤
3. 可考虑在开发环境添加检测中间件，对未过滤的敏感表查询打印告警

### Risk 2: 预计算开销

**风险**：每个请求都预计算 `SubordinateShopIDs`，即使某些请求不需要

**缓解措施**：
1. `GetSubordinateShopIDs` 有 Redis 缓存（30分钟），命中率高
2. Redis 查询通常 < 1ms
3. 实际影响可忽略

### Risk 3: 改造期间的并行开发冲突

**风险**：改造涉及 16 个 Store 文件，可能与其他开发任务冲突

**缓解措施**：
1. 分阶段改造：先基础设施，再 Store 层
2. 优先改造高复杂度 Store，降低后期风险
3. 改造期间及时 rebase 和解决冲突

## Migration Plan

### Phase 1: 基础设施（不影响现有功能）

1. 扩展 `UserContextInfo`，添加 `SubordinateShopIDs` 字段
2. 新增 `pkg/middleware/data_scope.go`，实现 Helper 函数
3. 修改 Auth 中间件，预计算 `SubordinateShopIDs`
4. 此阶段 GORM Callback 仍然生效，两套机制并存

### Phase 2: 改造权限检查函数

1. 修改 `CanManageShop`，从 Context 获取数据，移除 `shopStore` 参数
2. 修改 `CanManageEnterprise`，从 Context 获取数据
3. 更新所有调用点

### Phase 3: Store 层改造（按复杂度分批）

1. **低复杂度 Store（9 个）**：agent_wallet、commission_record 等
2. **中复杂度 Store（4 个）**：device、order、shop_package_allocation 等
3. **高复杂度 Store（3 个）**：iot_card、account、enterprise_card_authorization

### Phase 4: 清理

1. 移除 `RegisterDataPermissionCallback` 及其调用
2. 移除 `SkipDataPermission` 函数及所有调用点（20+ 处）
3. 移除 `pkg/gorm/callback.go` 中的相关代码

### Rollback Strategy

如果发现问题，可以：
1. Phase 1-2 期间：直接回滚代码，GORM Callback 仍在工作
2. Phase 3 期间：保留 Callback 代码，只回滚 Store 改动
3. Phase 4 后：需要重新启用 Callback 代码

## Open Questions

1. ~~NULL shop_id 的记录对代理用户是否可见？~~ **已确认：不可见（保持现有行为）**

2. ~~是否需要为开发环境添加"未过滤敏感表查询"的告警机制？~~ **暂不需要，通过代码审查保证**
