feat(wallet,tag): 钱包和标签系统多租户改造

核心变更:
- 钱包表:删除 user_id,添加 resource_type/resource_id(绑定资源而非用户)
- 标签表:添加 enterprise_id/shop_id(实现三级隔离:全局/企业/店铺)
- GORM Callback:自动数据权限过滤
- 迁移脚本:可重复执行,已验证回滚功能

钱包归属重构原因:
- 旧设计:钱包绑定用户账号,个人客户卡/设备转手后新用户无法使用余额
- 新设计:钱包绑定资源(卡/设备/店铺),余额随资源流转

标签三级隔离:
- 平台全局标签:所有用户可见
- 企业标签:仅该企业可见(企业内唯一)
- 店铺标签:该店铺及下级可见(店铺内唯一)

测试覆盖:
- 9 个单元测试验证标签多租户过滤(全部通过)
- 迁移和回滚功能测试通过(测试环境)
- OpenSpec 验证通过

变更 ID: fix-wallet-tag-multi-tenant
迁移版本: 000008
参考: openspec/changes/archive/2026-01-13-fix-wallet-tag-multi-tenant/
This commit is contained in:
2026-01-13 16:52:37 +08:00
parent 6e2dc325d7
commit 2570269c8d
18 changed files with 3145 additions and 41 deletions

View File

@@ -464,3 +464,161 @@ WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
3. 删除新增索引
**注意**:回滚会丢失所有新表的数据,请谨慎操作。
---
## 十三、变更历史
### 2026-01-13: 钱包和标签系统多租户改造(迁移 #000008
**变更 ID**: `fix-wallet-tag-multi-tenant`
**变更原因**:
1. **钱包归属设计缺陷**
- 原设计:钱包绑定到 `user_id`(用户账号)
- 问题:个人客户的卡/设备转手时,钱包无法随资源流转
- 示例:个人客户 A 购买单卡充值 100 元,使用 50 元后转手给个人客户 BB 登录后看不到剩余 50 元余额
2. **标签系统缺少多租户隔离**
- 原设计:标签表无 `enterprise_id``shop_id` 字段
- 问题:企业 A 创建"测试标签"后,企业 B 无法创建同名标签(全局唯一冲突)
- 问题:企业 A 可以看到企业 B 的所有标签(数据泄露)
**核心变更**:
#### 1. 钱包表tb_wallet结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ❌ 删除 | `user_id` | 不再绑定用户账号 |
| ✅ 添加 | `resource_type` | 资源类型:`iot_card`(单卡)/ `device`(设备)/ `shop`(店铺) |
| ✅ 添加 | `resource_id` | 资源 ID |
**钱包归属新设计**
```
个人客户单卡钱包:
resource_type = 'iot_card'
resource_id = 卡ID
→ 卡转手时,新用户通过 ICCID 登录,钱包余额跟随卡流转
个人客户设备钱包(多卡共享):
resource_type = 'device'
resource_id = 设备ID
→ 设备中的 3-4 张卡共享一个钱包
代理商店铺钱包:
resource_type = 'shop'
resource_id = 店铺ID
→ 店铺内多个代理账号共享钱包,支持预存款采购
```
**索引变更**
- 删除:`idx_wallet_user_type_currency``idx_wallet_user`
- 添加:`idx_wallet_resource_type_currency`(唯一)、`idx_wallet_resource`
#### 2. 标签表tb_tag结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ✅ 添加 | `enterprise_id` | 企业 ID企业标签 |
| ✅ 添加 | `shop_id` | 店铺 ID店铺标签 |
**标签三级隔离模型**
| 标签类型 | enterprise_id | shop_id | 可见范围 | 唯一性 |
|---------|--------------|---------|---------|--------|
| 平台全局标签 | NULL | NULL | 所有用户 | 全局唯一 |
| 企业标签 | 企业ID | NULL | 仅该企业 | 企业内唯一 |
| 店铺标签 | NULL | 店铺ID | 该店铺及下级 | 店铺内唯一 |
**示例**
- 企业 A 创建"测试标签"`enterprise_id=5, shop_id=NULL`
- 企业 B 也可以创建"测试标签"`enterprise_id=8, shop_id=NULL`
- 两个标签相互隔离,互不可见
**索引变更**
- 删除:`idx_tag_name`(全局唯一)
- 添加:`idx_tag_enterprise_name`(企业内唯一)
- 添加:`idx_tag_shop_name`(店铺内唯一)
- 添加:`idx_tag_global_name`(全局标签唯一)
#### 3. 资源标签表tb_resource_tag结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ✅ 添加 | `enterprise_id` | 企业 ID从资源推断 |
| ✅ 添加 | `shop_id` | 店铺 ID从资源推断 |
**数据权限自动过滤**
通过 GORM Callback 自动注入过滤条件:
```go
// 代理用户查询标签
WHERE shop_id IN (当前店铺及下级店铺)
OR (enterprise_id IS NULL AND shop_id IS NULL)
// 企业用户查询标签
WHERE enterprise_id = 当前企业ID
OR (enterprise_id IS NULL AND shop_id IS NULL)
// 个人客户查询标签
WHERE enterprise_id IS NULL AND shop_id IS NULL
```
**数据迁移策略**
1. **代理钱包迁移**
```sql
UPDATE tb_wallet w
SET resource_type = 'shop', resource_id = a.shop_id
FROM tb_account a
WHERE w.user_id = a.id AND w.wallet_type = 'agent';
```
2. **用户钱包处理**
- 标记为 `PENDING_USER`,需要业务人员手动确认归属
3. **标签归属推断**
```sql
-- 从 creator 推断企业标签
UPDATE tb_tag t SET enterprise_id = a.enterprise_id
FROM tb_account a WHERE a.id = t.creator;
-- 从 creator 推断店铺标签
UPDATE tb_tag t SET shop_id = a.shop_id
FROM tb_account a WHERE a.id = t.creator AND t.enterprise_id IS NULL;
```
**迁移验证结果**
- ✅ 备份表已创建:`tb_wallet_backup`、`tb_tag_backup`、`tb_resource_tag_backup`
- ✅ 钱包表字段变更成功
- ✅ 标签表字段变更成功
- ✅ 资源标签表字段变更成功
- ✅ 迁移耗时300-960ms
- ✅ 回滚耗时500-960ms
- ✅ 可重复执行(已处理备份表冲突)
**参考文档**
- OpenSpec 变更提案:`openspec/changes/fix-wallet-tag-multi-tenant/proposal.md`
- 技术设计文档:`openspec/changes/fix-wallet-tag-multi-tenant/design.md`
- 实施清单:`openspec/changes/fix-wallet-tag-multi-tenant/tasks.md`
**测试覆盖**
- ✅ 9 个单元测试验证标签多租户过滤(`pkg/gorm/callback_test.go`
- ✅ 迁移和回滚功能验证通过
- ✅ OpenSpec 验证通过(`openspec validate --strict`
**回滚方案**
如需回滚,执行:
```bash
./scripts/migrate.sh down 1
```
回滚会从备份表恢复数据,但会丢失备份后的新增数据。