# card-wallet Specification

## Purpose
卡钱包系统，提供物联网卡和设备级别的钱包管理，支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离，独立的数据表和代码实现。

## ADDED Requirements

### Requirement: 卡钱包实体定义

系统 SHALL 定义卡钱包（CardWallet）实体，管理物联网卡和设备级别的钱包，支持资源转手场景。

**核心概念**：
- **物联网卡钱包**：归属单张物联网卡，卡转手时钱包跟着卡走
- **设备钱包**：归属设备（含1-4张卡），设备的多张卡共享钱包，设备转手时钱包跟着设备走

**实体字段**：
- `id`：钱包 ID（主键，BIGINT，自增）
- `resource_type`：资源类型（VARCHAR(20)，枚举值："iot_card"-物联网卡 | "device"-设备，唯一约束之一）
- `resource_id`：资源 ID（BIGINT，关联 tb_iot_card.id 或 tb_device.id，唯一约束之一）
- `balance`：余额（BIGINT，单位：分，默认 0，≥ 0）
- `frozen_balance`：冻结余额（BIGINT，单位：分，默认 0，≥ 0）
- `currency`：币种（VARCHAR(10)，默认 "CNY"）
- `status`：钱包状态（INT，1-正常 2-冻结 3-关闭，默认 1）
- `version`：版本号（INT，默认 0，乐观锁字段，用于防止并发扣款）
- `shop_id_tag`：店铺 ID 标签（BIGINT，多租户过滤用）
- `enterprise_id_tag`：企业 ID 标签（BIGINT，多租户过滤用，可空）
- `created_at`：创建时间（TIMESTAMP，自动填充）
- `updated_at`：更新时间（TIMESTAMP，自动填充）
- `deleted_at`：删除时间（TIMESTAMP，可空，软删除）

**唯一约束**：`(resource_type, resource_id)` 在 `deleted_at IS NULL` 条件下唯一

**可用余额计算**：可用余额 = balance - frozen_balance

**表名**：`tb_card_wallet`

#### Scenario: 创建物联网卡钱包

- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录（首次登录），为该卡充值
- **THEN** 系统创建卡钱包记录，`resource_type` 为 "iot_card"，`resource_id` 为卡 ID，`balance` 为 0，`status` 为 1（正常）

#### Scenario: 创建设备钱包

- **WHEN** 个人客户通过设备号 "DEV-001" 登录（首次登录），该设备绑定 3 张卡，为设备充值
- **THEN** 系统创建卡钱包记录，`resource_type` 为 "device"，`resource_id` 为设备 ID，设备的 3 张卡共享该钱包

#### Scenario: 计算可用余额

- **WHEN** 卡钱包余额为 10000 分（100 元），冻结余额为 3000 分（30 元）
- **THEN** 系统计算可用余额为 7000 分（70 元）

#### Scenario: 防止同一资源创建重复钱包

- **WHEN** 物联网卡（ID 为 100）已有钱包，尝试再次创建钱包
- **THEN** 系统拒绝创建，返回错误信息"该资源已存在钱包"

---

### Requirement: 卡钱包交易记录

系统 SHALL 记录所有卡钱包余额变动，包括充值、套餐扣费、退款等操作，确保完整的审计追踪。

**实体字段**：
- `id`：交易记录 ID（主键，BIGINT，自增）
- `card_wallet_id`：卡钱包 ID（BIGINT，关联 tb_card_wallet.id）
- `resource_type`：资源类型（VARCHAR(20)，冗余字段，便于查询）
- `resource_id`：资源 ID（BIGINT，冗余字段，便于查询）
- `user_id`：操作人用户 ID（BIGINT，关联 tb_account.id）
- `transaction_type`：交易类型（VARCHAR(20)，枚举值："recharge"-充值 | "deduct"-扣款 | "refund"-退款）
- `amount`：变动金额（BIGINT，单位：分，正数为增加，负数为减少）
- `balance_before`：变动前余额（BIGINT，单位：分）
- `balance_after`：变动后余额（BIGINT，单位：分）
- `status`：交易状态（INT，1-成功 2-失败 3-处理中，默认 1）
- `reference_type`：关联业务类型（VARCHAR(50)，如 "order" | "topup"，可空）
- `reference_id`：关联业务 ID（BIGINT，可空）
- `remark`：备注（TEXT，可空）
- `metadata`：扩展信息（JSONB，如套餐信息、支付方式等，可空）
- `creator`：创建人 ID（BIGINT）
- `shop_id_tag`：店铺 ID 标签（BIGINT，多租户过滤用）
- `enterprise_id_tag`：企业 ID 标签（BIGINT，多租户过滤用，可空）
- `created_at`：创建时间（TIMESTAMP，自动填充）
- `updated_at`：更新时间（TIMESTAMP，自动填充）
- `deleted_at`：删除时间（TIMESTAMP，可空，软删除）

**表名**：`tb_card_wallet_transaction`

**索引**：
- `idx_card_tx_wallet (card_wallet_id, created_at)`：按钱包查询交易历史
- `idx_card_tx_resource (resource_type, resource_id, created_at)`：按资源查询交易
- `idx_card_tx_ref (reference_type, reference_id)`：按关联业务查询
- `idx_card_tx_type (transaction_type, created_at)`：按交易类型统计

#### Scenario: 充值创建交易记录

- **WHEN** 物联网卡（ICCID "8986001234567890"）充值 10000 分（100 元）
- **THEN** 系统创建卡钱包交易记录，`transaction_type` 为 "recharge"，`amount` 为 10000，`balance_before` 为 0，`balance_after` 为 10000，`status` 为 1（成功）

#### Scenario: 套餐扣费创建交易记录

- **WHEN** 物联网卡（ICCID "8986001234567890"）购买套餐，钱包支付扣款 3000 分（30 元）
- **THEN** 系统创建卡钱包交易记录，`transaction_type` 为 "deduct"，`amount` 为 -3000，`balance_before` 为 10000，`balance_after` 为 7000，`reference_type` 为 "order"，`reference_id` 为订单 ID

#### Scenario: 订单退款创建交易记录

- **WHEN** 物联网卡订单（ID 为 1001）退款 3000 分（30 元）
- **THEN** 系统创建卡钱包交易记录，`transaction_type` 为 "refund"，`amount` 为 3000，`balance_before` 为 7000，`balance_after` 为 10000，`reference_type` 为 "order"，`reference_id` 为 1001

#### Scenario: 按资源查询交易历史

- **WHEN** 个人客户查询物联网卡（ICCID "8986001234567890"）的交易历史
- **THEN** 系统使用索引 `idx_card_tx_resource` 查询，返回该卡的所有钱包交易记录，按 `created_at` 降序排序

---

### Requirement: 卡充值记录管理

系统 SHALL 记录所有卡充值操作，包括充值订单号、金额、支付方式、支付状态等信息。

**实体字段**：
- `id`：充值记录 ID（主键，BIGINT，自增）
- `user_id`：操作人用户 ID（BIGINT，关联 tb_account.id）
- `card_wallet_id`：卡钱包 ID（BIGINT，关联 tb_card_wallet.id）
- `resource_type`：资源类型（VARCHAR(20)，冗余字段）
- `resource_id`：资源 ID（BIGINT，冗余字段）
- `recharge_no`：充值订单号（VARCHAR(50)，唯一，格式：CRCH+时间戳+随机数）
- `amount`：充值金额（BIGINT，单位：分，≥ 100）
- `payment_method`：支付方式（VARCHAR(20)，枚举值："alipay"-支付宝 | "wechat"-微信）
- `payment_channel`：支付渠道（VARCHAR(50)，可空）
- `payment_transaction_id`：第三方支付交易号（VARCHAR(100)，可空）
- `status`：充值状态（INT，1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款，默认 1）
- `paid_at`：支付时间（TIMESTAMP，可空）
- `completed_at`：完成时间（TIMESTAMP，可空）
- `shop_id_tag`：店铺 ID 标签（BIGINT，多租户过滤用）
- `enterprise_id_tag`：企业 ID 标签（BIGINT，多租户过滤用，可空）
- `created_at`：创建时间（TIMESTAMP，自动填充）
- `updated_at`：更新时间（TIMESTAMP，自动填充）
- `deleted_at`：删除时间（TIMESTAMP，可空，软删除）

**表名**：`tb_card_recharge_record`

**充值金额限制**：
- 最小充值金额：100 分（1 元）
- 最大充值金额：10000000 分（100000 元）

**索引**：
- `idx_card_recharge_user (user_id, created_at)`：按用户查询充值记录
- `idx_card_recharge_resource (resource_type, resource_id, created_at)`：按资源查询充值记录
- `idx_card_recharge_status (status, created_at)`：按状态过滤充值记录
- `idx_card_recharge_no (recharge_no)`：按订单号查询

#### Scenario: 创建卡充值订单

- **WHEN** 个人客户为物联网卡（ICCID "8986001234567890"）发起充值 10000 分（100 元），选择微信支付
- **THEN** 系统创建卡充值记录，生成唯一的 `recharge_no`（如 "CRCH20260224123456789012"），`amount` 为 10000，`payment_method` 为 "wechat"，`status` 为 1（待支付），`resource_type` 为 "iot_card"

#### Scenario: 充值金额低于最小限制

- **WHEN** 个人客户尝试充值 50 分（0.5 元）
- **THEN** 系统拒绝创建充值订单，返回错误信息"充值金额不能低于 1 元"

#### Scenario: 充值支付完成

- **WHEN** 个人客户完成微信支付
- **THEN** 系统将充值记录状态从 1（待支付）变更为 2（已支付），记录 `paid_at` 时间和 `payment_transaction_id`

#### Scenario: 充值到账

- **WHEN** 充值记录状态为 2（已支付），系统处理充值到账
- **THEN** 系统将卡钱包余额增加 10000 分，创建卡钱包交易记录，将充值记录状态变更为 3（已完成），记录 `completed_at` 时间

---

### Requirement: 卡钱包余额操作

系统 SHALL 支持卡钱包余额的充值、扣款、退款等操作，使用乐观锁防止并发问题。

**操作类型**：
- **充值**：增加钱包余额
- **扣款**：减少钱包余额（如购买套餐）
- **退款**：增加钱包余额（如订单退款）

**并发控制**：
- 使用 `version` 字段实现乐观锁
- 每次更新余额时，检查 `version` 是否匹配
- 如果 `version` 不匹配，说明有并发更新，操作失败并重试

**操作约束**：
- 扣款时，检查可用余额（balance - frozen_balance）是否充足
- 所有余额变动必须创建交易记录

#### Scenario: 卡钱包充值

- **WHEN** 卡钱包当前余额为 10000 分，充值 5000 分
- **THEN** 系统将钱包余额更新为 15000 分，`version` 从 1 变更为 2，创建交易记录（`transaction_type` 为 "recharge"，`amount` 为 5000）

#### Scenario: 卡钱包扣款

- **WHEN** 卡钱包当前余额为 15000 分，购买套餐扣款 3000 分
- **THEN** 系统检查可用余额（15000 - 0 = 15000）≥ 3000，将钱包余额更新为 12000 分，`version` 从 2 变更为 3，创建交易记录（`transaction_type` 为 "deduct"，`amount` 为 -3000）

#### Scenario: 余额不足扣款失败

- **WHEN** 卡钱包当前余额为 2000 分，购买套餐需要扣款 3000 分
- **THEN** 系统检查可用余额（2000 - 0 = 2000）< 3000，拒绝扣款，返回错误信息"余额不足"

#### Scenario: 并发扣款乐观锁生效

- **WHEN** 卡钱包当前余额为 10000 分，version 为 1，两个并发请求同时扣款 3000 分和 5000 分
- **THEN** 第一个请求成功，余额变为 7000 分，version 变为 2；第二个请求因 version 不匹配失败，需重新读取最新余额（7000 分）和 version（2）后重试

#### Scenario: 订单退款

- **WHEN** 卡钱包当前余额为 7000 分，订单退款 3000 分
- **THEN** 系统将钱包余额更新为 10000 分，`version` 增加 1，创建交易记录（`transaction_type` 为 "refund"，`amount` 为 3000）

---

### Requirement: 卡钱包数据校验

系统 SHALL 对卡钱包数据进行校验，确保数据完整性和一致性。

**校验规则**：
- `resource_type`：必填，枚举值 "iot_card" | "device"
- `resource_id`：必填，≥ 1，必须是有效的资源 ID
- `balance`：必填，≥ 0
- `frozen_balance`：必填，≥ 0，≤ balance
- `currency`：必填，长度 1-10 字符，默认 "CNY"
- `status`：必填，枚举值 1-3
- `version`：必填，≥ 0

#### Scenario: 创建钱包时 resource_type 无效

- **WHEN** 创建卡钱包，`resource_type` 为 "invalid"
- **THEN** 系统拒绝创建，返回错误信息"资源类型无效，必须是 iot_card 或 device"

#### Scenario: 创建钱包时 resource_id 无效

- **WHEN** 创建卡钱包，`resource_type` 为 "iot_card"，`resource_id` 为 0
- **THEN** 系统拒绝创建，返回错误信息"资源 ID 无效，必须 ≥ 1"

#### Scenario: 冻结余额超过总余额

- **WHEN** 卡钱包余额为 10000 分，尝试冻结 15000 分
- **THEN** 系统拒绝操作，返回错误信息"冻结余额不能超过总余额"

#### Scenario: 余额为负数

- **WHEN** 尝试将卡钱包余额设置为 -10000 分
- **THEN** 系统拒绝操作，返回错误信息"余额不能为负数"

---

### Requirement: 卡钱包归属资源转手规则

系统 SHALL 支持卡钱包随资源（物联网卡、设备）转手，新用户登录后可以看到钱包余额。

**归属规则**：

| 资源类型 | ResourceType | 适用场景 | 转手规则 |
|---------|-------------|---------|---------|
| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡，卡转手时钱包跟着卡走 |
| 设备 | device | 个人客户购买设备（含1-4张卡） | 钱包归属设备，设备的多张卡共享钱包，设备转手时钱包跟着设备走 |

**资源转手场景**：
- 物联网卡转手：新用户通过 ICCID 登录后可以看到卡的钱包余额
- 设备转手：新用户通过设备号登录后可以看到设备的钱包余额（包含绑定的所有卡）

#### Scenario: 个人客户购买单卡并充值

- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录（首次登录），为该卡充值 10000 分
- **THEN** 系统创建卡钱包记录，`resource_type` 为 "iot_card"，`resource_id` 为卡 ID，`balance` 为 10000

#### Scenario: 个人客户购买设备并充值

- **WHEN** 个人客户通过设备号 "DEV-001" 登录（首次登录），该设备绑定 3 张卡，为设备充值 20000 分
- **THEN** 系统创建卡钱包记录，`resource_type` 为 "device"，`resource_id` 为设备 ID，设备的 3 张卡共享该钱包，`balance` 为 20000

#### Scenario: 卡转手后新用户查询余额

- **WHEN** 个人客户 A（微信 OpenID 为 "wx_a"）的卡（ICCID 为 "8986001234567890"）转手给个人客户 B（微信 OpenID 为 "wx_b"），卡钱包余额为 5000 分
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包，余额为 5000 分，可以继续使用

#### Scenario: 设备转手后新用户查询余额

- **WHEN** 个人客户 A 的设备（设备号 "DEV-001"，绑定 3 张卡）转手给个人客户 B，设备钱包余额为 15000 分
- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包，余额为 15000 分，3 张卡共享该余额

#### Scenario: 设备的多张卡共享钱包

- **WHEN** 设备（设备号 "DEV-001"）绑定 3 张卡（ICCID 为 "111"、"222"、"333"），设备钱包余额为 20000 分
- **THEN** 用户通过任意一张卡的 ICCID 登录，查询钱包余额都是 20000 分（设备级别钱包）

---

### Requirement: 卡钱包 Redis 缓存策略

系统 SHALL 使用 Redis 缓存卡钱包余额，提升查询性能，并使用 Redis 分布式锁防止并发操作冲突。

**缓存 Key 定义**：
- 余额缓存：`card_wallet:balance:{resource_type}:{resource_id}`
- 分布式锁：`card_wallet:lock:{resource_type}:{resource_id}`

**缓存 TTL**：
- 余额缓存：180 秒（3 分钟）
- 分布式锁：10 秒

**缓存更新策略**：
- 余额变动时，删除缓存（Cache-Aside 模式）
- 下次查询时重新加载到缓存

**常量定义位置**：`pkg/constants/wallet.go`

```go
func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string
func RedisCardWalletLockKey(resourceType string, resourceID uint) string
```

#### Scenario: 查询余额时使用缓存

- **WHEN** 查询物联网卡（ICCID "8986001234567890"）钱包余额，缓存中存在该余额
- **THEN** 系统直接从 Redis 返回余额，不查询数据库

#### Scenario: 余额变动后删除缓存

- **WHEN** 物联网卡（ID 为 100）钱包余额增加 5000 分
- **THEN** 系统删除 Redis 缓存 Key `card_wallet:balance:iot_card:100`，下次查询时重新加载

#### Scenario: 使用分布式锁防止并发扣款

- **WHEN** 两个并发请求同时尝试从物联网卡（ID 为 100）钱包扣款
- **THEN** 系统使用 Redis 分布式锁 `card_wallet:lock:iot_card:100`，第一个请求获得锁，第二个请求等待或失败

---
