# card-wallet Specification

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

## Requirements

### Requirement: 资产钱包实体定义

系统 SHALL 定义资产钱包（AssetWallet）实体，管理物联网卡和设备级别的钱包，支持资源转手场景。原 `CardWallet` / `tb_card_wallet` 全量改名为 `AssetWallet` / `tb_asset_wallet`。

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

**实体字段（与原 CardWallet 完全一致，仅表名改变）**：
- `id`：钱包 ID（主键，BIGINT，自增）
- `resource_type`：资源类型（VARCHAR(20)，枚举值："iot_card" | "device"）
- `resource_id`：资源 ID（BIGINT）
- `balance`：余额（BIGINT，单位：分，默认 0）
- `frozen_balance`：冻结余额（BIGINT，单位：分，默认 0）
- `currency`：币种（VARCHAR(10)，默认 "CNY"）
- `status`：钱包状态（INT，1-正常 2-冻结 3-关闭，默认 1）
- `version`：版本号（INT，乐观锁）
- `shop_id_tag`：店铺 ID 标签（多租户过滤）
- `enterprise_id_tag`：企业 ID 标签（可空）
- `created_at` / `updated_at` / `deleted_at`

**表名变更**：`tb_card_wallet` → `tb_asset_wallet`

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

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

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

- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录，为该卡充值
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`，`resource_type` 为 "iot_card"，`resource_id` 为卡 ID

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

- **WHEN** 个人客户通过设备号登录，为设备充值
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`，`resource_type` 为 "device"，设备的所有卡共享该钱包

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

- **WHEN** 钱包余额 10000 分，冻结余额 3000 分
- **THEN** 可用余额 = 7000 分

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

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

---

### Requirement: 资产钱包交易记录

系统 SHALL 记录所有资产钱包余额变动，包括充值、套餐扣费、退款，确保完整收支审计追踪。原 `CardWalletTransaction` / `tb_card_wallet_transaction` 全量改名为 `AssetWalletTransaction` / `tb_asset_wallet_transaction`，同时 `reference_id (bigint)` 字段改为 `reference_no (varchar 50)`。

**实体字段**：
- `id`：交易记录 ID（主键）
- `asset_wallet_id`：资产钱包 ID（关联 `tb_asset_wallet.id`，原 `card_wallet_id`）
- `resource_type`：资源类型（冗余字段）
- `resource_id`：资源 ID（冗余字段）
- `user_id`：操作人用户 ID
- `transaction_type`：交易类型（`recharge` / `deduct` / `refund`）
- `amount`：变动金额（分，充值为正，扣款/退款为负）
- `balance_before`：变动前余额（分）
- `balance_after`：变动后余额（分）
- `status`：交易状态（1-成功 2-失败 3-处理中）
- `reference_type`：关联业务类型（`recharge` 或 `order`，可空）
- `reference_no`：关联业务编号，存储充值单号（`CRCH…`）或订单号（`ORD…`）（VARCHAR(50)，可空）— **原字段 `reference_id (bigint)` 改名并变更类型**
- `remark`：备注（TEXT，可空）
- `metadata`：扩展信息（JSONB，可空）
- `creator`：创建人 ID
- `shop_id_tag` / `enterprise_id_tag`：多租户标签

**表名变更**：`tb_card_wallet_transaction` → `tb_asset_wallet_transaction`

**字段变更**：`reference_id bigint` → `reference_no varchar(50)`

#### Scenario: 充值写入流水记录

- **WHEN** 个人客户完成充值（充值单号 CRCH20260309001，金额 100 元），充值回调成功
- **THEN** 系统在 `tb_asset_wallet_transaction` 写入一条记录：`transaction_type="recharge"`，`amount=10000`，`reference_type="recharge"`，`reference_no="CRCH20260309001"`

#### Scenario: 钱包支付套餐写入扣款流水

- **WHEN** 个人客户使用钱包支付套餐订单（订单号 ORD20260310001，金额 30 元），`WalletPay` 执行成功
- **THEN** 系统在同一事务内向 `tb_asset_wallet_transaction` 写入一条记录：`transaction_type="deduct"`，`amount=-3000`，`reference_type="order"`，`reference_no="ORD20260310001"`，`balance_before` 为扣款前余额，`balance_after` = `balance_before - 3000`

#### Scenario: 充值流水 reference_no 格式

- **WHEN** 系统写入充值流水
- **THEN** `reference_no` 存储充值单号（格式：`CRCH` + 时间戳 + 随机数），而非数据库主键 ID

#### Scenario: 扣款流水 reference_no 格式

- **WHEN** 系统写入扣款流水
- **THEN** `reference_no` 存储订单号（格式：`ORD` + 时间戳 + 6位随机数），而非数据库主键 ID

---

### Requirement: 充值记录表改名

系统 SHALL 将原 `tb_card_recharge_record` 表重命名为 `tb_asset_recharge_record`，对应 Go 类型由 `CardRechargeRecord` 改名为 `AssetRechargeRecord`。H5 充值接口 JSON 响应字段 `wallet_id` 不变（保持向后兼容）。

#### Scenario: H5 充值接口字段不变

- **WHEN** 前端调用 `GET /api/h5/wallets/recharges/:id`，充值记录关联的钱包 ID 为 123
- **THEN** 响应 JSON 中 `wallet_id` 仍为 `123`，JSON 字段名不变（仅 Go 内部字段名从 `CardWalletID` 改为 `AssetWalletID`）

---

### 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 定义**：
- 余额缓存：`asset_wallet:balance:{resource_type}:{resource_id}`
- 分布式锁：`asset_wallet:lock:{resource_type}:{resource_id}`

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

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

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

```go
func RedisAssetWalletBalanceKey(resourceType string, resourceID uint) string
func RedisAssetWalletLockKey(resourceType string, resourceID uint) string
```

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

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

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

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

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

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