# 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`,第一个请求获得锁,第二个请求等待或失败