# agent-wallet Specification

## Purpose
代理钱包系统，提供店铺级别的主钱包和分佣钱包管理，支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离，独立的数据表和代码实现。

## ADDED Requirements

### Requirement: 代理钱包实体定义

系统 SHALL 定义代理钱包（AgentWallet）实体，管理店铺级别的钱包，支持主钱包和分佣钱包两种类型。

**核心概念**：
- **主钱包（main）**：店铺的主要资金账户，用于预充值和购买套餐
- **分佣钱包（commission）**：店铺的佣金账户，用于接收分佣和提现

**实体字段**：
- `id`：钱包 ID（主键，BIGINT，自增）
- `shop_id`：店铺 ID（BIGINT，关联 tb_shop.id，唯一约束之一）
- `wallet_type`：钱包类型（VARCHAR(20)，枚举值："main"-主钱包 | "commission"-分佣钱包，唯一约束之一）
- `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，多租户过滤用，与 shop_id 相同）
- `enterprise_id_tag`：企业 ID 标签（BIGINT，多租户过滤用，可空）
- `created_at`：创建时间（TIMESTAMP，自动填充）
- `updated_at`：更新时间（TIMESTAMP，自动填充）
- `deleted_at`：删除时间（TIMESTAMP，可空，软删除）

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

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

**表名**：`tb_agent_wallet`

#### Scenario: 创建店铺主钱包

- **WHEN** 店铺（ID 为 10）首次充值
- **THEN** 系统创建代理钱包记录，`shop_id` 为 10，`wallet_type` 为 "main"，`balance` 为 0，`status` 为 1（正常），`shop_id_tag` 为 10

#### Scenario: 创建店铺分佣钱包

- **WHEN** 店铺（ID 为 10）首次获得佣金
- **THEN** 系统创建代理钱包记录，`shop_id` 为 10，`wallet_type` 为 "commission"，`balance` 为 0，`status` 为 1（正常）

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

- **WHEN** 代理钱包余额为 100000 分（1000 元），冻结余额为 30000 分（300 元）
- **THEN** 系统计算可用余额为 70000 分（700 元）

#### Scenario: 防止同一店铺创建重复钱包类型

- **WHEN** 店铺（ID 为 10）已有 wallet_type 为 "main" 的钱包，尝试再次创建 wallet_type 为 "main" 的钱包
- **THEN** 系统拒绝创建，返回错误信息"该店铺已存在主钱包"

---

### Requirement: 代理钱包交易记录

系统 SHALL 记录所有代理钱包余额变动，包括充值、扣款、退款、分佣、提现等操作，确保完整的审计追踪。

**实体字段**：
- `id`：交易记录 ID（主键，BIGINT，自增）
- `agent_wallet_id`：代理钱包 ID（BIGINT，关联 tb_agent_wallet.id）
- `shop_id`：店铺 ID（BIGINT，冗余字段，便于按店铺查询）
- `user_id`：操作人用户 ID（BIGINT，关联 tb_account.id）
- `transaction_type`：交易类型（VARCHAR(20)，枚举值："recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现）
- `amount`：变动金额（BIGINT，单位：分，正数为增加，负数为减少）
- `balance_before`：变动前余额（BIGINT，单位：分）
- `balance_after`：变动后余额（BIGINT，单位：分）
- `status`：交易状态（INT，1-成功 2-失败 3-处理中，默认 1）
- `reference_type`：关联业务类型（VARCHAR(50)，如 "order" | "commission" | "withdrawal" | "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_agent_wallet_transaction`

**索引**：
- `idx_agent_tx_wallet (agent_wallet_id, created_at)`：按钱包查询交易历史
- `idx_agent_tx_shop (shop_id, created_at)`：按店铺汇总交易
- `idx_agent_tx_ref (reference_type, reference_id)`：按关联业务查询
- `idx_agent_tx_type (transaction_type, created_at)`：按交易类型统计

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

- **WHEN** 店铺（ID 为 10）主钱包充值 100000 分（1000 元）
- **THEN** 系统创建代理钱包交易记录，`transaction_type` 为 "recharge"，`amount` 为 100000，`balance_before` 为 0，`balance_after` 为 100000，`status` 为 1（成功），`shop_id` 为 10

#### Scenario: 分佣发放创建交易记录

- **WHEN** 店铺（ID 为 10）的分佣钱包收到佣金 50000 分（500 元）
- **THEN** 系统创建代理钱包交易记录，`transaction_type` 为 "commission"，`amount` 为 50000，`balance_before` 为 200000，`balance_after` 为 250000，`reference_type` 为 "commission"，`reference_id` 为分佣记录 ID

#### Scenario: 提现创建交易记录

- **WHEN** 店铺（ID 为 10）从分佣钱包提现 30000 分（300 元）
- **THEN** 系统创建代理钱包交易记录，`transaction_type` 为 "withdrawal"，`amount` 为 -30000，`balance_before` 为 250000，`balance_after` 为 220000，`reference_type` 为 "withdrawal"，`reference_id` 为提现申请 ID

#### Scenario: 按店铺查询交易历史

- **WHEN** 管理员查询店铺（ID 为 10）的所有钱包交易记录，按时间倒序
- **THEN** 系统使用索引 `idx_agent_tx_shop` 查询，返回该店铺的主钱包和分佣钱包的所有交易记录，按 `created_at` 降序排序

---

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

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

**实体字段**：
- `id`：充值记录 ID（主键，BIGINT，自增）
- `user_id`：操作人用户 ID（BIGINT，关联 tb_account.id）
- `agent_wallet_id`：代理钱包 ID（BIGINT，关联 tb_agent_wallet.id）
- `shop_id`：店铺 ID（BIGINT，冗余字段，便于查询）
- `recharge_no`：充值订单号（VARCHAR(50)，唯一，格式：ARCH+时间戳+随机数）
- `amount`：充值金额（BIGINT，单位：分，≥ 1）
- `payment_method`：支付方式（VARCHAR(20)，枚举值："alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下）
- `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_agent_recharge_record`

**充值金额限制**：
- 最小充值金额：1 分
- 最大充值金额：100000000 分（1000000 元）

**索引**：
- `idx_agent_recharge_user (user_id, created_at)`：按用户查询充值记录
- `idx_agent_recharge_shop (shop_id, created_at)`：按店铺查询充值记录
- `idx_agent_recharge_status (status, created_at)`：按状态过滤充值记录
- `idx_agent_recharge_no (recharge_no)`：按订单号查询

#### Scenario: 创建代理充值订单

- **WHEN** 店铺（ID 为 10）的管理员发起充值 100000 分（1000 元），选择支付宝支付
- **THEN** 系统创建代理充值记录，生成唯一的 `recharge_no`（如 "ARCH20260224123456789012"），`amount` 为 100000，`payment_method` 为 "alipay"，`status` 为 1（待支付），`shop_id` 为 10

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

- **WHEN** 店铺管理员尝试充值 0 分
- **THEN** 系统拒绝创建充值订单，返回错误信息"充值金额超出允许范围"

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

- **WHEN** 店铺管理员完成支付宝支付
- **THEN** 系统将充值记录状态从 1（待支付）变更为 2（已支付），记录 `paid_at` 时间和 `payment_transaction_id`

#### Scenario: 充值到账

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

---

### Requirement: 代理钱包余额操作

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

**操作类型**：
- **充值**：增加钱包余额
- **扣款**：减少钱包余额（如购买套餐）
- **退款**：增加钱包余额（如订单退款）
- **冻结**：将部分余额转为冻结状态（如提现申请中）
- **解冻**：将冻结余额转回可用余额（如提现取消）

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

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

#### Scenario: 代理钱包充值

- **WHEN** 店铺主钱包当前余额为 100000 分，充值 50000 分
- **THEN** 系统将钱包余额更新为 150000 分，`version` 从 1 变更为 2，创建交易记录（`transaction_type` 为 "recharge"，`amount` 为 50000）

#### Scenario: 代理钱包扣款

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

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

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

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

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

#### Scenario: 冻结余额用于提现

- **WHEN** 店铺分佣钱包余额为 100000 分，申请提现 30000 分
- **THEN** 系统将钱包的 `frozen_balance` 增加 30000 分，可用余额减少 30000 分，`version` 增加 1

#### Scenario: 解冻余额（提现取消）

- **WHEN** 店铺分佣钱包冻结余额为 30000 分，用户取消提现申请
- **THEN** 系统将钱包的 `frozen_balance` 减少 30000 分，可用余额增加 30000 分，`version` 增加 1

---

### Requirement: 代理钱包数据校验

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

**校验规则**：
- `shop_id`：必填，≥ 1，必须是有效的店铺 ID
- `wallet_type`：必填，枚举值 "main" | "commission"
- `balance`：必填，≥ 0
- `frozen_balance`：必填，≥ 0，≤ balance
- `currency`：必填，长度 1-10 字符，默认 "CNY"
- `status`：必填，枚举值 1-3
- `version`：必填，≥ 0

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

- **WHEN** 创建代理钱包，`shop_id` 为 0
- **THEN** 系统拒绝创建，返回错误信息"店铺 ID 无效，必须 ≥ 1"

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

- **WHEN** 创建代理钱包，`wallet_type` 为 "invalid"
- **THEN** 系统拒绝创建，返回错误信息"钱包类型无效，必须是 main 或 commission"

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

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

#### Scenario: 余额为负数

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

---

### Requirement: 代理钱包归属店铺规则

系统 SHALL 确保代理钱包归属店铺，不支持转手，店铺的多个员工账号共享钱包。

**归属规则**：
- 代理钱包归属店铺（shop_id），不归属个人用户
- 同一店铺的所有员工账号共享该店铺的主钱包和分佣钱包
- 店铺钱包不支持转手，归属关系固定

#### Scenario: 店铺的多个员工账号共享钱包

- **WHEN** 店铺（ID 为 10）有 3 个员工账号（账号 ID 为 201、202、203），店铺主钱包余额为 500000 分
- **THEN** 3 个员工账号登录后查询店铺主钱包，余额都是 500000 分，可以共享使用

#### Scenario: 员工账号只能访问自己店铺的钱包

- **WHEN** 员工账号（ID 为 201，归属店铺 10）尝试访问店铺 20 的钱包
- **THEN** 系统拒绝访问，返回错误信息"无权限访问该店铺的钱包"

---

### Requirement: 代理钱包 Redis 缓存策略

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

**缓存 Key 定义**：
- 余额缓存：`agent_wallet:balance:{shop_id}:{wallet_type}`
- 分布式锁：`agent_wallet:lock:{shop_id}:{wallet_type}`

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

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

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

```go
func RedisAgentWalletBalanceKey(shopID uint, walletType string) string
func RedisAgentWalletLockKey(shopID uint, walletType string) string
```

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

- **WHEN** 查询店铺（ID 为 10）主钱包余额，缓存中存在该余额
- **THEN** 系统直接从 Redis 返回余额，不查询数据库

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

- **WHEN** 店铺（ID 为 10）主钱包余额增加 50000 分
- **THEN** 系统删除 Redis 缓存 Key `agent_wallet:balance:10:main`，下次查询时重新加载

#### Scenario: 使用分布式锁防止并发冻结

- **WHEN** 两个并发请求同时尝试冻结店铺（ID 为 10）主钱包的余额
- **THEN** 系统使用 Redis 分布式锁 `agent_wallet:lock:10:main`，第一个请求获得锁，第二个请求等待或失败

---

### Requirement: 批量查询店铺主钱包余额

系统 SHALL 在 `AgentWalletStore` 中提供 `GetShopMainWalletBatch(ctx, shopIDs []uint) map[uint]*AgentWallet` 方法，一次查询多个店铺的主钱包（`wallet_type=main`）记录，返回以 `shop_id` 为 key 的 map。

**实现要求**：
- 使用 `WHERE shop_id IN (?) AND wallet_type = 'main'` 单次查询，不得逐条查询
- 不在 map 中的 shop_id 表示该店铺暂无主钱包，调用方按零值处理
- 与现有 `GetShopCommissionSummaryBatch` 对称设计

#### Scenario: 批量查询多个店铺的主钱包

- **WHEN** 传入 shopIDs `[1, 2, 3]`，其中 shop 3 无主钱包记录
- **THEN** 返回 map `{1: &wallet1, 2: &wallet2}`，shop 3 不在 map 中

#### Scenario: 传入空列表

- **WHEN** 传入空 `shopIDs`
- **THEN** 直接返回空 map，不执行 DB 查询

---
