# 钱包、换卡、标签系统 - 数据模型设计

## 概述

本次变更新增了三个核心业务模块的数据模型设计：

1. **钱包系统**：用户和代理的资金账户管理
2. **换卡记录系统**：物联卡更换历史追溯
3. **标签系统**：设备、IoT卡、号卡的分类管理

## 一、钱包系统

### 1.1 表结构

#### tb_wallet（钱包表）

**业务说明**：每个用户/代理拥有一个或多个钱包（按币种区分），支持充值、消费、提现等操作。

| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| user_id | BIGINT | 用户ID | idx_wallet_user |
| wallet_type | VARCHAR(20) | 钱包类型（user/agent） | idx_wallet_user_type_currency (UNIQUE) |
| balance | BIGINT | 余额（分） | - |
| frozen_balance | BIGINT | 冻结余额（分） | - |
| currency | VARCHAR(10) | 币种（默认CNY） | idx_wallet_user_type_currency (UNIQUE) |
| status | INT | 钱包状态（1=正常 2=冻结 3=关闭） | idx_wallet_status |
| version | INT | 版本号（乐观锁） | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间（软删除） | - |

**关键设计点**：
- 使用 `version` 字段实现乐观锁，防止并发余额冲突
- `frozen_balance` 用于冻结资金（如待结算的分佣）
- 唯一索引：`(user_id, wallet_type, currency) WHERE deleted_at IS NULL`

#### tb_wallet_transaction（钱包交易记录表）

**业务说明**：记录所有钱包余额变动，用于对账和审计。

| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| wallet_id | BIGINT | 钱包ID | idx_wallet_tx_wallet |
| user_id | BIGINT | 用户ID | idx_wallet_tx_user |
| transaction_type | VARCHAR(20) | 交易类型（recharge/deduct/refund/commission/withdrawal） | - |
| amount | BIGINT | 变动金额（分），正数为增加，负数为减少 | - |
| balance_before | BIGINT | 变动前余额（分） | - |
| balance_after | BIGINT | 变动后余额（分） | - |
| status | INT | 交易状态（1=成功 2=失败 3=处理中） | - |
| reference_type | VARCHAR(50) | 关联业务类型（order/commission/withdrawal/topup） | idx_wallet_tx_ref |
| reference_id | BIGINT | 关联业务ID | idx_wallet_tx_ref |
| remark | TEXT | 备注 | - |
| metadata | JSONB | 扩展信息 | - |
| creator | BIGINT | 创建人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间（软删除） | - |

**关键设计点**：
- 记录 `balance_before` 和 `balance_after` 便于对账
- `reference_type` + `reference_id` 关联业务对象
- 使用 JSONB 存储扩展信息（如第三方交易号、手续费等）

#### tb_recharge_record（充值记录表）

**业务说明**：用户和代理的钱包充值订单，记录支付流程。

| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| user_id | BIGINT | 用户ID | idx_recharge_user |
| wallet_id | BIGINT | 钱包ID | - |
| recharge_no | VARCHAR(50) | 充值订单号（唯一） | idx_recharge_no (UNIQUE) |
| amount | BIGINT | 充值金额（分） | - |
| payment_method | VARCHAR(20) | 支付方式（alipay/wechat/bank/offline） | - |
| payment_channel | VARCHAR(50) | 支付渠道 | - |
| payment_transaction_id | VARCHAR(100) | 第三方支付交易号 | - |
| status | INT | 充值状态（1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款） | idx_recharge_status |
| paid_at | TIMESTAMP | 支付时间 | - |
| completed_at | TIMESTAMP | 完成时间 | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间（软删除） | - |

**关键设计点**：
- `recharge_no` 作为唯一订单号，用于幂等性控制
- 状态流转：待支付 → 已支付 → 已完成

### 1.2 业务流程

#### 充值流程
```
1. 用户发起充值请求 → 创建 RechargeRecord（status=1 待支付）
2. 调用支付网关 → 获取支付链接
3. 用户完成支付 → 支付回调更新 RechargeRecord（status=2 已支付）
4. 系统处理充值 → 创建 WalletTransaction（type=recharge）
5. 更新 Wallet 余额 → 使用乐观锁（version+1）
6. 充值完成 → 更新 RechargeRecord（status=3 已完成）
```

#### 消费流程
```
1. 用户购买套餐 → 检查钱包余额
2. 冻结金额 → 增加 frozen_balance
3. 订单完成 → 扣减 frozen_balance 和 balance
4. 创建 WalletTransaction（type=deduct, reference_type=order）
```

---

## 二、换卡记录系统

### 2.1 表结构

#### tb_card_replacement_record（换卡记录表）

**业务说明**：记录物联卡更换历史，包含套餐快照便于追溯。

| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| replacement_no | VARCHAR(50) | 换卡单号（唯一） | idx_card_replacement_no (UNIQUE) |
| old_card_id | BIGINT | 老卡ID | idx_card_replacement_old_card |
| old_iccid | VARCHAR(50) | 老卡ICCID（冗余存储） | - |
| new_card_id | BIGINT | 新卡ID | idx_card_replacement_new_card |
| new_iccid | VARCHAR(50) | 新卡ICCID（冗余存储） | - |
| old_owner_type | VARCHAR(20) | 老卡所有者类型 | idx_card_replacement_old_owner |
| old_owner_id | BIGINT | 老卡所有者ID | idx_card_replacement_old_owner |
| old_agent_id | BIGINT | 老卡代理ID | - |
| new_owner_type | VARCHAR(20) | 新卡所有者类型 | idx_card_replacement_new_owner |
| new_owner_id | BIGINT | 新卡所有者ID | idx_card_replacement_new_owner |
| new_agent_id | BIGINT | 新卡代理ID | - |
| package_snapshot | JSONB | 套餐快照 | - |
| replacement_reason | VARCHAR(20) | 换卡原因（damaged/lost/malfunction/upgrade/other） | - |
| remark | TEXT | 备注 | - |
| status | INT | 换卡状态（1=待审批 2=已通过 3=已拒绝 4=已完成） | idx_card_replacement_status |
| approved_by | BIGINT | 审批人ID | - |
| approved_at | TIMESTAMP | 审批时间 | - |
| completed_at | TIMESTAMP | 完成时间 | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间（软删除） | - |

**关键设计点**：
- 冗余存储 `old_iccid` 和 `new_iccid`，即使卡被删除也能追溯
- `package_snapshot` 使用 JSONB 存储套餐快照（套餐ID、名称、剩余流量、有效期等）
- 支持审批流程（待审批 → 已通过 → 已完成）

### 2.2 套餐快照结构

```json
{
  "package_id": 123,
  "package_name": "月包50GB",
  "package_type": "formal",
  "data_quota": 51200000,
  "data_used": 10240000,
  "valid_from": "2025-01-01T00:00:00Z",
  "valid_to": "2025-01-31T23:59:59Z",
  "price": 5000,
  "remaining_days": 20,
  "transfer_reason": "卡损坏，套餐转移至新卡"
}
```

### 2.3 业务流程

```
1. 用户申请换卡 → 创建 CardReplacementRecord（status=1 待审批）
2. 记录老卡套餐信息 → 生成 package_snapshot
3. 平台审批 → 更新 status=2（已通过）或 3（已拒绝）
4. 执行换卡操作 → 更新卡所有权、停用老卡、激活新卡
5. 转移套餐 → 将套餐绑定到新卡
6. 完成换卡 → 更新 status=4（已完成）
```

---

## 三、标签系统

### 3.1 表结构

#### tb_tag（标签表）

**业务说明**：定义可复用的标签，支持自定义颜色。

| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| name | VARCHAR(100) | 标签名称（唯一） | idx_tag_name (UNIQUE) |
| color | VARCHAR(20) | 标签颜色（十六进制） | - |
| usage_count | INT | 使用次数 | idx_tag_usage |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间（软删除） | - |

**关键设计点**：
- `usage_count` 记录标签使用次数，用于展示热门标签
- 标签名称全局唯一（软删除排除）

#### tb_resource_tag（资源-标签关联表）

**业务说明**：统一管理设备、IoT卡、号卡与标签的多对多关系。

| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| resource_type | VARCHAR(20) | 资源类型（device/iot_card/number_card） | idx_resource_tag_unique (UNIQUE) |
| resource_id | BIGINT | 资源ID | idx_resource_tag_unique (UNIQUE) |
| tag_id | BIGINT | 标签ID | idx_resource_tag_unique (UNIQUE) |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间（软删除） | - |

**关键设计点**：
- 唯一约束：`(resource_type, resource_id, tag_id) WHERE deleted_at IS NULL`
- 支持按资源类型、资源ID、标签ID 多维度查询

### 3.2 支持的资源类型

| 资源类型 | 说明 | 关联表 |
|---------|------|--------|
| device | 设备 | tb_device |
| iot_card | IoT卡 | tb_iot_card |
| number_card | 号卡 | tb_number_card |

### 3.3 业务流程

```
1. 创建标签 → 插入 tb_tag（usage_count=0）
2. 为资源打标签 → 插入 tb_resource_tag
3. 增加标签使用次数 → UPDATE tb_tag SET usage_count = usage_count + 1
4. 删除资源标签 → 软删除 tb_resource_tag
5. 减少标签使用次数 → UPDATE tb_tag SET usage_count = usage_count - 1
```

---

## 四、修改现有表

### 4.1 tb_carrier（运营商表）

**新增字段**：

| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| carrier_type | VARCHAR(20) | 运营商类型（CMCC/CUCC/CTCC/CBN） | 'CMCC' |
| channel_name | VARCHAR(100) | 渠道名称（可自定义） | NULL |
| channel_code | VARCHAR(50) | 渠道编码（可自定义） | NULL |

**新增索引**：
- `idx_carrier_type_channel`: `(carrier_type, channel_code) WHERE deleted_at IS NULL` (UNIQUE)

**设计说明**：
- `carrier_type` 为固定枚举（四大运营商）
- `channel_name` 和 `channel_code` 可自定义（如"移动-广东渠道"）
- 同一运营商可以有多个渠道

### 4.2 tb_order（订单表）

**新增字段**：

| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| wallet_payment_amount | BIGINT | 钱包支付金额（分） | 0 |
| online_payment_amount | BIGINT | 在线支付金额（分） | 0 |

**设计说明**：
- 支持混合支付：`amount = wallet_payment_amount + online_payment_amount`
- `payment_method` 字段保留，用于标识主要支付方式

---

## 五、索引策略

### 5.1 唯一索引

所有唯一索引都必须包含 `WHERE deleted_at IS NULL` 条件，支持软删除后重复创建。

### 5.2 查询索引

- 钱包相关：按用户ID、钱包ID、时间范围查询
- 换卡记录：按卡ID、所有者、状态查询
- 标签：按资源类型、资源ID、标签ID查询

### 5.3 索引维护

- 定期分析慢查询日志，优化索引
- 使用 `EXPLAIN ANALYZE` 验证查询计划

---

## 六、数据一致性保证

### 6.1 乐观锁（钱包）

```sql
UPDATE tb_wallet 
SET balance = balance - 1000, version = version + 1
WHERE id = 123 AND version = 5;
```

如果 `version` 不匹配，表示并发冲突，需要重试。

### 6.2 事务保证

所有涉及多表操作的业务逻辑必须在事务中执行：
- 充值：RechargeRecord + WalletTransaction + Wallet
- 换卡：CardReplacementRecord + IotCard（老卡、新卡）+ PackageUsage

### 6.3 幂等性

- 充值订单：使用 `recharge_no` 唯一约束
- 钱包交易：使用 `request_id` Redis 锁

---

## 七、性能优化

### 7.1 缓存策略

| 数据 | Redis Key | 过期时间 | 说明 |
|------|-----------|----------|------|
| 钱包余额 | `wallet:balance:{wallet_id}` | 5分钟 | 高频查询缓存 |
| 热门标签 | `tag:cache:list` | 1小时 | 标签列表缓存 |
| 资源标签 | `resource:tags:{type}:{id}` | 30分钟 | 资源标签关联缓存 |

### 7.2 批量操作

- 批量查询钱包余额：使用 `IN` 查询
- 批量更新标签使用次数：使用 `CASE WHEN`

### 7.3 分页查询

所有列表查询必须分页：
- 默认 20 条/页
- 最大 100 条/页

---

## 八、安全性设计

### 8.1 权限控制

- 用户只能操作自己的钱包
- 代理可查询下级用户的钱包（通过数据权限过滤）

### 8.2 敏感信息保护

- 不在日志中记录完整的支付交易号
- 钱包余额变更必须记录操作人

### 8.3 风控

- 单次充值金额限制
- 单日充值次数限制
- 异常交易告警

---

## 九、扩展性考虑

### 9.1 多币种支持

- 钱包表已支持 `currency` 字段
- 未来可扩展美元、欧元等币种

### 9.2 多钱包类型

- 当前支持：user（用户）、agent（代理）
- 未来可扩展：enterprise（企业）、platform（平台）

### 9.3 标签扩展

- 当前支持：设备、IoT卡、号卡
- 未来可扩展：订单、用户等资源类型

---

## 十、数据迁移说明

### 10.1 现有订单数据迁移

对于已存在的订单，需要初始化钱包支付字段：

```sql
UPDATE tb_order 
SET wallet_payment_amount = amount 
WHERE payment_method = 'wallet';

UPDATE tb_order 
SET online_payment_amount = amount 
WHERE payment_method IN ('online', 'carrier');
```

### 10.2 运营商数据迁移

根据现有 `carrier_code` 推断 `carrier_type`：

```sql
UPDATE tb_carrier 
SET carrier_type = 'CMCC' 
WHERE carrier_code LIKE '%CMCC%' OR carrier_code LIKE '%移动%';

UPDATE tb_carrier 
SET carrier_type = 'CUCC' 
WHERE carrier_code LIKE '%CUCC%' OR carrier_code LIKE '%联通%';

UPDATE tb_carrier 
SET carrier_type = 'CTCC' 
WHERE carrier_code LIKE '%CTCC%' OR carrier_code LIKE '%电信%';

UPDATE tb_carrier 
SET carrier_type = 'CBN' 
WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
```

---

## 十一、测试要点

### 11.1 钱包系统测试

- [ ] 充值流程完整性测试
- [ ] 并发扣款乐观锁测试
- [ ] 余额不足校验测试
- [ ] 混合支付计算测试
- [ ] 冻结余额解冻测试

### 11.2 换卡系统测试

- [ ] 套餐快照完整性测试
- [ ] 审批流程测试
- [ ] 新旧卡状态变更测试
- [ ] 套餐转移测试

### 11.3 标签系统测试

- [ ] 标签创建唯一性测试
- [ ] 资源标签关联测试
- [ ] 使用次数统计测试
- [ ] 标签删除级联测试

---

## 十二、回滚方案

如果需要回滚，执行 `000007_add_wallet_transfer_tag_tables.down.sql`：

1. 删除新增表
2. 删除新增字段
3. 删除新增索引

**注意**：回滚会丢失所有新表的数据，请谨慎操作。

---

## 十三、变更历史

### 2026-01-13: 钱包和标签系统多租户改造（迁移 #000008）

**变更 ID**: `fix-wallet-tag-multi-tenant`

**变更原因**:

1. **钱包归属设计缺陷**：
   - 原设计：钱包绑定到 `user_id`（用户账号）
   - 问题：个人客户的卡/设备转手时，钱包无法随资源流转
   - 示例：个人客户 A 购买单卡充值 100 元，使用 50 元后转手给个人客户 B，B 登录后看不到剩余 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
```

回滚会从备份表恢复数据，但会丢失备份后的新增数据。
