# 钱包、换卡、标签系统 - 字段详细说明

## 一、钱包系统字段说明

### 1. tb_wallet（钱包表）

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 钱包唯一标识 | 1 |
| user_id | BIGINT | 是 | 无 | 所属用户ID，关联 tb_account.id | 123 |
| wallet_type | VARCHAR(20) | 是 | 无 | 钱包类型：`user`=用户钱包，`agent`=代理钱包 | "user" |
| balance | BIGINT | 是 | 0 | 可用余额（单位：分），1元=100分 | 500000（5000元） |
| frozen_balance | BIGINT | 是 | 0 | 冻结余额（单位：分），用于待结算的分佣、提现等 | 10000（100元） |
| currency | VARCHAR(10) | 是 | 'CNY' | 币种代码，ISO 4217 标准 | "CNY" |
| status | INT | 是 | 1 | 钱包状态：1=正常，2=冻结，3=关闭 | 1 |
| version | INT | 是 | 0 | 乐观锁版本号，每次更新余额时+1，防止并发冲突 | 5 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 最后更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 11:30:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间，NULL表示未删除 | NULL |

**业务规则**：
- 同一用户在同一币种下只能有一个同类型的钱包（唯一约束）
- `balance + frozen_balance` = 总资产
- 余额扣减时必须使用乐观锁（WHERE version = ?）

---

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

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 交易记录唯一标识 | 1 |
| wallet_id | BIGINT | 是 | 无 | 钱包ID，关联 tb_wallet.id | 123 |
| user_id | BIGINT | 是 | 无 | 用户ID，冗余存储便于查询 | 456 |
| transaction_type | VARCHAR(20) | 是 | 无 | 交易类型：`recharge`=充值，`deduct`=扣款，`refund`=退款，`commission`=分佣，`withdrawal`=提现 | "recharge" |
| amount | BIGINT | 是 | 无 | 变动金额（单位：分），正数表示增加，负数表示减少 | 50000（+500元） |
| balance_before | BIGINT | 是 | 无 | 变动前余额（单位：分） | 100000（1000元） |
| balance_after | BIGINT | 是 | 无 | 变动后余额（单位：分） | 150000（1500元） |
| status | INT | 是 | 1 | 交易状态：1=成功，2=失败，3=处理中 | 1 |
| reference_type | VARCHAR(50) | 否 | NULL | 关联业务类型：`order`=订单，`commission`=分佣，`withdrawal`=提现，`topup`=充值 | "order" |
| reference_id | BIGINT | 否 | NULL | 关联业务ID，如订单ID、分佣ID | 789 |
| remark | TEXT | 否 | NULL | 备注信息，人工输入或系统生成 | "购买套餐扣款" |
| metadata | JSONB | 否 | NULL | 扩展信息（JSON格式），存储第三方交易号、手续费等 | {"fee": 50, "channel": "alipay"} |
| creator | BIGINT | 否 | 无 | 创建人ID（系统创建时为0） | 0 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 交易时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |

**业务规则**：
- 每次余额变动必须创建一条交易记录
- `balance_after = balance_before + amount`
- 交易记录只能新增，不能修改或删除（审计要求）

**metadata 扩展字段示例**：
```json
{
  "payment_channel": "alipay",           // 支付渠道
  "transaction_no": "2025011310000001",  // 第三方交易号
  "fee": 50,                             // 手续费（分）
  "operator": "admin",                   // 操作人
  "ip": "192.168.1.100"                  // 操作IP
}
```

---

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

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 充值记录唯一标识 | 1 |
| user_id | BIGINT | 是 | 无 | 充值用户ID | 123 |
| wallet_id | BIGINT | 是 | 无 | 充值目标钱包ID | 456 |
| recharge_no | VARCHAR(50) | 是 | 无 | 充值订单号（唯一），格式：RCH+时间戳+随机数 | "RCH20250113100000001" |
| amount | BIGINT | 是 | 无 | 充值金额（单位：分） | 100000（1000元） |
| payment_method | VARCHAR(20) | 是 | 无 | 支付方式：`alipay`=支付宝，`wechat`=微信，`bank`=银行转账，`offline`=线下 | "alipay" |
| payment_channel | VARCHAR(50) | 否 | NULL | 支付渠道（第三方平台），如"支付宝-即时到账" | "alipay_direct" |
| payment_transaction_id | VARCHAR(100) | 否 | NULL | 第三方支付交易号，用于对账 | "2025011322001412345678" |
| status | INT | 是 | 1 | 充值状态：1=待支付，2=已支付，3=已完成，4=已关闭，5=已退款 | 3 |
| paid_at | TIMESTAMP | 否 | NULL | 支付完成时间 | 2025-01-13 10:05:00 |
| completed_at | TIMESTAMP | 否 | NULL | 充值完成时间（余额到账） | 2025-01-13 10:05:30 |
| creator | BIGINT | 否 | 无 | 创建人ID | 123 |
| updater | BIGINT | 否 | 无 | 更新人ID | 0 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:05:30 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |

**业务规则**：
- `recharge_no` 全局唯一，用于幂等性控制
- 状态流转：待支付 → 已支付 → 已完成
- 超时未支付订单自动关闭（30分钟）

---

## 二、换卡记录系统字段说明

### tb_card_replacement_record（换卡记录表）

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 换卡记录唯一标识 | 1 |
| replacement_no | VARCHAR(50) | 是 | 无 | 换卡单号（唯一），格式：REP+时间戳+随机数 | "REP20250113100000001" |
| old_card_id | BIGINT | 是 | 无 | 老卡ID，关联 tb_iot_card.id | 123 |
| old_iccid | VARCHAR(50) | 是 | 无 | 老卡ICCID（冗余存储），即使卡删除也能追溯 | "898600..." |
| new_card_id | BIGINT | 是 | 无 | 新卡ID，关联 tb_iot_card.id | 456 |
| new_iccid | VARCHAR(50) | 是 | 无 | 新卡ICCID（冗余存储） | "898600..." |
| old_owner_type | VARCHAR(20) | 是 | 无 | 老卡所有者类型：`platform`=平台，`agent`=代理，`user`=用户，`device`=设备 | "user" |
| old_owner_id | BIGINT | 是 | 无 | 老卡所有者ID | 789 |
| old_agent_id | BIGINT | 否 | NULL | 老卡代理ID（如果有） | 100 |
| new_owner_type | VARCHAR(20) | 是 | 无 | 新卡所有者类型 | "user" |
| new_owner_id | BIGINT | 是 | 无 | 新卡所有者ID | 789 |
| new_agent_id | BIGINT | 否 | NULL | 新卡代理ID | 100 |
| package_snapshot | JSONB | 否 | NULL | 套餐快照（JSON格式），记录换卡时的套餐状态 | 见下方示例 |
| replacement_reason | VARCHAR(20) | 是 | 无 | 换卡原因：`damaged`=损坏，`lost`=丢失，`malfunction`=故障，`upgrade`=升级，`other`=其他 | "damaged" |
| remark | TEXT | 否 | NULL | 备注说明 | "卡片物理损坏，无法识别" |
| status | INT | 是 | 1 | 换卡状态：1=待审批，2=已通过，3=已拒绝，4=已完成 | 4 |
| approved_by | BIGINT | 否 | NULL | 审批人ID | 1 |
| approved_at | TIMESTAMP | 否 | NULL | 审批时间 | 2025-01-13 11:00:00 |
| completed_at | TIMESTAMP | 否 | NULL | 完成时间 | 2025-01-13 11:30:00 |
| creator | BIGINT | 否 | 无 | 创建人ID | 789 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 11:30:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |

**package_snapshot 字段结构**：
```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": "卡损坏，套餐转移至新卡"
}
```

**字段说明**：
- `package_id`: 套餐ID
- `package_name`: 套餐名称
- `package_type`: 套餐类型（formal=正式套餐，addon=附加套餐）
- `data_quota`: 流量额度（KB）
- `data_used`: 已使用流量（KB）
- `valid_from`: 套餐生效时间
- `valid_to`: 套餐失效时间
- `price`: 套餐价格（分）
- `remaining_days`: 剩余天数
- `transfer_reason`: 转移原因

**业务规则**：
- 换卡申请需要审批（除非设置为自动通过）
- 老卡状态变为"已停用"，新卡状态变为"已激活"
- 套餐信息转移到新卡，剩余流量和有效期保持不变

---

## 三、标签系统字段说明

### 1. tb_tag（标签表）

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 标签唯一标识 | 1 |
| name | VARCHAR(100) | 是 | 无 | 标签名称（全局唯一） | "重点客户" |
| color | VARCHAR(20) | 否 | NULL | 标签颜色（十六进制），用于前端展示 | "#FF5733" |
| usage_count | INT | 是 | 0 | 使用次数，每次打标签+1，取消标签-1 | 25 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |

**业务规则**：
- 标签名称全局唯一（不区分大小写）
- `usage_count` 用于展示热门标签（按使用次数降序）
- 标签删除时，关联的资源标签也会软删除

---

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

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 关联记录唯一标识 | 1 |
| resource_type | VARCHAR(20) | 是 | 无 | 资源类型：`device`=设备，`iot_card`=IoT卡，`number_card`=号卡 | "iot_card" |
| resource_id | BIGINT | 是 | 无 | 资源ID（根据 resource_type 关联不同的表） | 123 |
| tag_id | BIGINT | 是 | 无 | 标签ID，关联 tb_tag.id | 5 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |

**资源类型映射**：

| resource_type | 关联表 | 说明 |
|---------------|--------|------|
| device | tb_device | 设备 |
| iot_card | tb_iot_card | IoT卡 |
| number_card | tb_number_card | 号卡 |

**业务规则**：
- 同一资源不能重复打同一个标签（唯一约束）
- 打标签时，标签的 `usage_count` 自动+1
- 取消标签时，标签的 `usage_count` 自动-1

---

## 四、修改字段说明

### 1. tb_carrier（运营商表）- 新增字段

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| carrier_type | VARCHAR(20) | 是 | 'CMCC' | 运营商类型（固定枚举）：`CMCC`=中国移动，`CUCC`=中国联通，`CTCC`=中国电信，`CBN`=广电 | "CMCC" |
| channel_name | VARCHAR(100) | 否 | NULL | 渠道名称（可自定义），如"广东移动-企业渠道" | "广东移动-企业渠道" |
| channel_code | VARCHAR(50) | 否 | NULL | 渠道编码（可自定义），用于对接渠道API | "GD_CMCC_ENT" |

**业务规则**：
- 同一运营商类型可以有多个渠道（通过 channel_code 区分）
- `carrier_type + channel_code` 组合唯一

---

### 2. tb_order（订单表）- 新增字段

| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| wallet_payment_amount | BIGINT | 是 | 0 | 钱包支付金额（单位：分） | 30000（300元） |
| online_payment_amount | BIGINT | 是 | 0 | 在线支付金额（单位：分） | 20000（200元） |

**业务规则**：
- 订单总金额 `amount = wallet_payment_amount + online_payment_amount`
- 支持纯钱包支付、纯在线支付、混合支付三种模式
- `payment_method` 字段保留，标识主要支付方式

**支付模式示例**：

| 模式 | wallet_payment_amount | online_payment_amount | payment_method | 说明 |
|------|----------------------|----------------------|----------------|------|
| 纯钱包支付 | 50000 | 0 | wallet | 全部从钱包扣款 |
| 纯在线支付 | 0 | 50000 | online | 全部在线支付 |
| 混合支付 | 30000 | 20000 | wallet | 钱包不足，补充在线支付 |

---

## 五、数据类型说明

### 1. 金额字段（BIGINT）

- 单位：**分**（1元 = 100分）
- 类型：BIGINT（范围：-9223372036854775808 ~ 9223372036854775807）
- 最大金额：约 92,233,720,368,547,758 元（足够使用）

**示例**：
```go
amount := int64(100000) // 1000元 = 100000分
fmt.Println(amount / 100) // 输出：1000（元）
```

### 2. 时间字段（TIMESTAMP）

- 时区：数据库存储为 UTC 时间，应用层转换为本地时间
- 格式：`2025-01-13 10:00:00`

### 3. JSONB 字段

- PostgreSQL 专有类型，高效存储和查询 JSON 数据
- 支持索引和查询操作

**查询示例**：
```sql
-- 查询 metadata 中 fee 大于 100 的交易
SELECT * FROM tb_wallet_transaction 
WHERE metadata->>'fee' > '100';

-- 查询套餐快照中剩余天数小于 10 的换卡记录
SELECT * FROM tb_card_replacement_record 
WHERE (package_snapshot->>'remaining_days')::int < 10;
```

---

## 六、索引说明

### 1. 唯一索引

| 表名 | 索引名 | 字段 | 条件 |
|------|--------|------|------|
| tb_wallet | idx_wallet_user_type_currency | (user_id, wallet_type, currency) | WHERE deleted_at IS NULL |
| tb_recharge_record | idx_recharge_no | (recharge_no) | WHERE deleted_at IS NULL |
| tb_card_replacement_record | idx_card_replacement_no | (replacement_no) | WHERE deleted_at IS NULL |
| tb_tag | idx_tag_name | (name) | WHERE deleted_at IS NULL |
| tb_resource_tag | idx_resource_tag_unique | (resource_type, resource_id, tag_id) | WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_type_channel | (carrier_type, channel_code) | WHERE deleted_at IS NULL |

### 2. 普通索引

| 表名 | 索引名 | 字段 | 用途 |
|------|--------|------|------|
| tb_wallet | idx_wallet_user | (user_id, deleted_at) | 按用户查询钱包 |
| tb_wallet | idx_wallet_status | (status, deleted_at) | 按状态查询钱包 |
| tb_wallet_transaction | idx_wallet_tx_wallet | (wallet_id, created_at DESC) | 按钱包查询交易记录 |
| tb_wallet_transaction | idx_wallet_tx_user | (user_id, created_at DESC) | 按用户查询交易记录 |
| tb_wallet_transaction | idx_wallet_tx_ref | (reference_type, reference_id) | 按关联业务查询交易 |
| tb_recharge_record | idx_recharge_user | (user_id, created_at DESC) | 按用户查询充值记录 |
| tb_recharge_record | idx_recharge_status | (status, created_at DESC) | 按状态查询充值记录 |
| tb_card_replacement_record | idx_card_replacement_old_card | (old_card_id, created_at DESC) | 按老卡查询换卡记录 |
| tb_card_replacement_record | idx_card_replacement_new_card | (new_card_id, created_at DESC) | 按新卡查询换卡记录 |
| tb_card_replacement_record | idx_card_replacement_old_owner | (old_owner_type, old_owner_id) | 按老卡所有者查询 |
| tb_card_replacement_record | idx_card_replacement_new_owner | (new_owner_type, new_owner_id) | 按新卡所有者查询 |
| tb_card_replacement_record | idx_card_replacement_status | (status, created_at DESC) | 按状态查询换卡记录 |
| tb_tag | idx_tag_usage | (usage_count DESC, deleted_at) | 查询热门标签 |
| tb_resource_tag | idx_resource_tag_resource | (resource_type, resource_id, deleted_at) | 查询资源的标签 |
| tb_resource_tag | idx_resource_tag_tag | (tag_id, deleted_at) | 查询标签的资源 |
| tb_resource_tag | idx_resource_tag_composite | (resource_type, tag_id, deleted_at) | 按资源类型和标签查询 |

---

## 七、字段验证规则

### 1. 钱包字段验证

| 字段 | 验证规则 |
|------|---------|
| balance | ≥ 0，不能为负 |
| frozen_balance | ≥ 0，不能为负 |
| wallet_type | 必须为 `user` 或 `agent` |
| currency | 符合 ISO 4217 标准（如 CNY、USD） |
| status | 必须为 1、2、3 |
| version | ≥ 0 |

### 2. 交易字段验证

| 字段 | 验证规则 |
|------|---------|
| amount | 不能为 0 |
| balance_after | 必须等于 `balance_before + amount` |
| transaction_type | 必须为 recharge/deduct/refund/commission/withdrawal |
| status | 必须为 1、2、3 |

### 3. 充值字段验证

| 字段 | 验证规则 |
|------|---------|
| amount | > 0，单次充值 ≥ 1元（100分） |
| recharge_no | 格式：RCH + 17位数字 |
| payment_method | 必须为 alipay/wechat/bank/offline |
| status | 必须为 1、2、3、4、5 |

### 4. 换卡字段验证

| 字段 | 验证规则 |
|------|---------|
| replacement_no | 格式：REP + 17位数字 |
| old_card_id | 必须是已存在的卡ID |
| new_card_id | 必须是已存在且状态为"在库"的卡ID |
| replacement_reason | 必须为 damaged/lost/malfunction/upgrade/other |
| status | 必须为 1、2、3、4 |

### 5. 标签字段验证

| 字段 | 验证规则 |
|------|---------|
| name | 长度 ≤ 100，不能为空，全局唯一 |
| color | 必须符合十六进制颜色格式（#RRGGBB） |
| usage_count | ≥ 0 |
| resource_type | 必须为 device/iot_card/number_card |

---

## 八、字段使用注意事项

### 1. 金额计算

```go
// 错误示例：使用浮点数
price := 19.99 // ❌ 浮点数精度问题
total := price * 100 // ❌ 结果：1999.0000000002

// 正确示例：使用整数
price := int64(1999) // ✅ 直接使用分为单位
total := price * 2 // ✅ 结果：3998
```

### 2. 乐观锁使用

```go
// 查询钱包
wallet, _ := walletStore.GetByID(ctx, walletID)

// 扣款（带乐观锁）
result := db.Model(&Wallet{}).
    Where("id = ? AND version = ?", walletID, wallet.Version).
    Updates(map[string]interface{}{
        "balance": gorm.Expr("balance - ?", amount),
        "version": gorm.Expr("version + 1"),
    })

if result.RowsAffected == 0 {
    return errors.New("余额变更失败，请重试") // 并发冲突
}
```

### 3. JSONB 查询

```go
// 查询 metadata 中的字段
var transactions []WalletTransaction
db.Where("metadata->>'payment_channel' = ?", "alipay").Find(&transactions)

// 查询嵌套字段
db.Where("package_snapshot->>'package_type' = ?", "formal").Find(&records)
```

### 4. 软删除查询

```go
// 默认查询（自动排除软删除）
db.Find(&wallets) // WHERE deleted_at IS NULL

// 包含软删除记录
db.Unscoped().Find(&wallets)

// 只查询软删除记录
db.Where("deleted_at IS NOT NULL").Unscoped().Find(&wallets)
```

---

## 九、常见问题

### Q1: 为什么金额使用整数而不是浮点数？
**A**: 浮点数存在精度问题（0.1 + 0.2 != 0.3），在金融系统中可能导致账务错误。使用整数（分为单位）可以避免精度问题。

### Q2: 为什么要冗余存储 ICCID？
**A**: 换卡记录需要长期保存，即使物联卡被删除，也需要能追溯历史记录。冗余存储 ICCID 可以避免关联查询失败。

### Q3: 为什么标签要记录 usage_count？
**A**: 用于展示热门标签，提升用户体验。每次打标签/取消标签时更新，避免每次查询时统计。

### Q4: 软删除后为什么还能创建同名标签？
**A**: 唯一索引包含 `WHERE deleted_at IS NULL` 条件，软删除后该记录不再参与唯一性检查，允许创建同名标签。

### Q5: 乐观锁和悲观锁有什么区别？
**A**: 
- 乐观锁：假设冲突少，使用 version 字段判断，适合读多写少场景
- 悲观锁：假设冲突多，使用 `SELECT ... FOR UPDATE` 锁定行，适合写多场景

钱包系统使用乐观锁，因为余额查询频繁，扣款相对较少。
