# Change: 修复钱包和标签系统的多租户设计缺陷

## Why

在代码审查中发现两个严重的设计缺陷：

### 1. 钱包系统：归属主体设计不符合业务逻辑

**问题**：当前钱包绑定到 `user_id`，但业务中：
- **企业客户**：没有钱包（公对公支付，后台直接为企业的卡购买套餐）
- **个人客户**：卡/设备可能转手给不同用户，如果钱包绑定用户，转手后新用户将无法使用原钱包余额
- **代理商**：钱包用于预存款采购套餐，但当前设计将钱包绑定到代理账号，无法正确处理店铺层级关系

**根本矛盾**：系统中卡/设备支持多对多关系（一卡多用户、一用户多卡），但钱包却是一对多关系（一个用户多个钱包）。

**正确的业务逻辑**：
- 个人客户场景：钱包应该跟着**卡/设备**走（资源维度），而非用户
- 代理商场景：钱包应该跟着**店铺**走，而非代理账号

### 2. 标签系统：缺少多租户隔离

**问题**：标签表 `tb_tag` 没有 `enterprise_id` 或 `shop_id` 字段，导致：
- 企业 A 创建"测试标签"后，企业 B 无法创建同名标签（全局唯一冲突）
- 企业 A 可以看到企业 B 的所有标签（数据泄露）
- 企业 A 的用户可以为企业 B 的设备打标签（权限漏洞）

**资源-标签关联表** `tb_resource_tag` 也缺少隔离字段，无法限制跨租户操作。

---

## What Changes

### 1. 钱包系统重构

**核心改动**：将钱包归属从 `user_id` 改为 `resource_type + resource_id` 的多态设计。

**模型变更**：
```go
// 删除字段
- user_id

// 新增字段
+ resource_type (iot_card | device | shop)
+ resource_id (资源ID)
```

**业务规则**：
- **个人客户的卡钱包**：`resource_type=iot_card, resource_id=卡ID`
- **个人客户的设备钱包**：`resource_type=device, resource_id=设备ID`（设备的多卡共享钱包）
- **代理商钱包**：`resource_type=shop, resource_id=店铺ID`

**唯一约束变更**：
```sql
-- 旧约束（删除）
(user_id, wallet_type, currency)

-- 新约束
(resource_type, resource_id, wallet_type, currency)
```

### 2. 标签系统添加多租户隔离

**模型变更**：
```go
// tb_tag 新增字段
+ enterprise_id (企业ID，可空)
+ shop_id (店铺ID，可空)

// tb_resource_tag 新增字段
+ enterprise_id (企业ID，可空)
+ shop_id (店铺ID，可空)
```

**唯一约束变更**：
```sql
-- 旧约束（删除）
(name) WHERE deleted_at IS NULL

-- 新约束（三个独立索引）
1. 企业标签：(enterprise_id, name) WHERE enterprise_id IS NOT NULL
2. 店铺标签：(shop_id, name) WHERE shop_id IS NOT NULL
3. 全局标签：(name) WHERE enterprise_id IS NULL AND shop_id IS NULL
```

**业务规则**：
- 企业创建的标签：`enterprise_id = 企业ID`，只有该企业可见
- 店铺（代理）创建的标签：`shop_id = 店铺ID`，该店铺及下级店铺可见
- 平台创建的标签：`enterprise_id = NULL, shop_id = NULL`，所有用户可见

### 3. GORM Callback 更新

**添加标签和资源标签的数据权限过滤**：
```go
// pkg/gorm/callback.go 中添加
if tableName == "tb_tag" || tableName == "tb_resource_tag" {
    switch userType {
    case UserTypeAgent:
        db = db.Where("shop_id IN (?) OR (enterprise_id IS NULL AND shop_id IS NULL)", subordinateShopIDs)
    case UserTypeEnterprise:
        db = db.Where("enterprise_id = ? OR (enterprise_id IS NULL AND shop_id IS NULL)", enterpriseID)
    }
}
```

---

## Impact

### 影响的表
- ✅ `tb_wallet` - **BREAKING**：删除 `user_id`，添加 `resource_type` 和 `resource_id`
- ✅ `tb_wallet_transaction` - 无变更（保留 `user_id` 用于审计）
- ✅ `tb_recharge_record` - 无变更（保留 `user_id` 用于审计）
- ✅ `tb_tag` - 添加 `enterprise_id` 和 `shop_id`
- ✅ `tb_resource_tag` - 添加 `enterprise_id` 和 `shop_id`

### 影响的代码模块
- ✅ `internal/model/wallet.go` - 模型定义变更
- ✅ `internal/model/tag.go` - 模型定义变更
- ✅ `pkg/gorm/callback.go` - 添加标签表的数据权限过滤
- ⚠️ `internal/store/postgres/wallet_store.go` - 需要创建（当前不存在）
- ⚠️ `internal/store/postgres/tag_store.go` - 需要创建（当前不存在）
- ⚠️ `internal/service/wallet/` - 需要创建（当前不存在）
- ⚠️ `internal/service/tag/` - 需要创建（当前不存在）

### 影响的规范
- ✅ `openspec/specs/wallet/spec.md` - 钱包归属规则变更
- ✅ `openspec/specs/tag/spec.md` - 标签多租户隔离规则

### 数据迁移风险
- 🔴 **高风险**：钱包表结构破坏性变更，需要数据迁移脚本
- 🟡 **中风险**：标签表添加字段，需要根据 `creator` 字段推断归属
- 🟢 **低风险**：资源标签表添加字段，可以从关联资源推断归属

### 兼容性
- ❌ **不兼容**：钱包查询逻辑需要全面重写（从 `user_id` 改为 `resource_type + resource_id`）
- ✅ **向后兼容**：标签查询逻辑向后兼容（添加隔离过滤即可）

---

## Migration Plan

### 阶段 1：数据库迁移（停服维护）

1. **备份数据**：完整备份 `tb_wallet`、`tb_tag`、`tb_resource_tag` 表
2. **钱包数据迁移**：
   - 根据 `wallet_type` 判断资源类型
   - 代理钱包：查找代理账号的 `shop_id`，设置 `resource_type=shop, resource_id=shop_id`
   - 用户钱包：需要业务人员确认归属（暂时标记为待处理）
3. **标签数据迁移**：
   - 根据 `creator` 字段查找账号的 `enterprise_id` 或 `shop_id`
   - 设置标签归属
4. **资源标签数据迁移**：
   - 从 `creator` 字段推断归属
   - 或从关联的资源推断归属
5. **执行 DDL**：添加字段、删除字段、更新索引

### 阶段 2：代码部署

1. **部署新版本代码**（包含模型和查询逻辑变更）
2. **验证核心功能**：
   - 代理钱包查询和扣费
   - 企业标签创建和查询
   - 个人客户标签隔离

### 阶段 3：数据清理

1. **处理待确认的钱包**：业务人员确认后迁移
2. **验证数据一致性**：对比迁移前后的数据总量
3. **清理临时标记**

---

## Rollback Plan

如果迁移失败，可以回滚：

1. **停止新版本服务**
2. **执行回滚 SQL**：
   ```sql
   -- 恢复 tb_wallet
   ALTER TABLE tb_wallet DROP COLUMN resource_type, DROP COLUMN resource_id;
   ALTER TABLE tb_wallet ADD COLUMN user_id BIGINT;
   -- 从备份表恢复数据
   
   -- 恢复 tb_tag 和 tb_resource_tag
   ALTER TABLE tb_tag DROP COLUMN enterprise_id, DROP COLUMN shop_id;
   ALTER TABLE tb_resource_tag DROP COLUMN enterprise_id, DROP COLUMN shop_id;
   -- 从备份表恢复数据
   ```
3. **启动旧版本服务**

---

## Testing Strategy

### 单元测试
- ✅ 钱包 Store 层：按 `resource_type + resource_id` 查询
- ✅ 标签 Store 层：按 `enterprise_id` 或 `shop_id` 过滤
- ✅ GORM Callback：标签表自动注入隔离条件

### 集成测试
- ✅ 代理钱包：充值、扣费、冻结、解冻
- ✅ 个人客户卡钱包：充值、转手后余额查询
- ✅ 企业标签：创建、查询、隔离验证
- ✅ 跨租户标签：验证企业 A 无法看到企业 B 的标签

### 数据迁移测试
- ✅ 在测试环境执行完整迁移流程
- ✅ 验证迁移前后数据一致性
- ✅ 验证回滚流程

---

## Open Questions

1. **用户钱包迁移策略**：
   - 当前系统中是否存在 `wallet_type=user` 的钱包？
   - 如果存在，这些钱包应该归属哪个资源（卡还是设备）？
   - 需要业务人员提供迁移规则

2. **历史订单处理**：
   - 历史订单中的钱包支付记录如何关联到新的钱包？
   - 是否需要在 `tb_order` 中添加 `wallet_id` 字段？

3. **钱包交易记录**：
   - `tb_wallet_transaction` 是否保留 `user_id` 字段用于审计？
   - 建议：保留 `user_id`，同时添加 `wallet_id` 外键

4. **标签系统迁移**：
   - 如果 `creator` 字段为 NULL 或无效，标签应该归属谁？
   - 建议：归属为全局标签（`enterprise_id = NULL, shop_id = NULL`）

---

## Timeline

- **提案评审**：1 天
- **详细设计和 SQL 编写**：1 天
- **代码实现**：2 天
- **测试环境验证**：1 天
- **生产环境迁移**：1 天（含停服维护）
- **总计**：6 个工作日
