29 KiB
Wallet Service Architecture
本文定义钱包服务的目标架构、账务模型、核心流程和落地顺序。当前 wallet-service 只有单币种 coin 扣费能力,新需求需要升级为多资产账户、外部支付订单、币商充值、主播奖励和提现审核的一体化账务边界。
资源、资源组、用户资源赠送和礼物从资源库选择的边界见 Resource And Resource Group Architecture。本文只保留钱包账务和礼物价格结算边界。
Goals
wallet-service是所有用户资产余额、流水、幂等和提现冻结的唯一 owner。room-service只同步调用钱包完成送礼扣费和主播积分入账,不直接修改余额。- MQ 只消费钱包 outbox 事件,不作为余额事实来源。
- Redis 只能做读缓存、风控计数或短期状态,不能作为余额、充值或提现恢复来源。
- 所有资金和虚拟资产使用整数最小单位,不能使用浮点数。
- 钱包按
app_code隔离资产、交易、幂等、礼物价格和 outbox;不同 App 下相同user_id或command_id不能互相影响。
Asset Model
| Asset | Meaning | Spendable | Withdrawable | Owner |
|---|---|---|---|---|
COIN |
金币,用户送礼消费资产 | yes | no | wallet-service |
COIN_SELLER_COIN |
币商专用金币库存,只能转给玩家形成普通 COIN |
yes, seller transfer only | no | wallet-service |
DIAMOND |
钻石,不可直接消费,只能按政策兑换金币或美元余额 | no | no | wallet-service |
GIFT_POINT |
主播礼物积分,达到政策后结算美元奖励 | no | no | wallet-service |
HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD |
主播可提现美元余额,建议单位为 cent 或 micro cent | no | yes | wallet-service |
钻石和积分都不是直接消费资产。钻石兑换金币或美元余额时必须落兑换订单,记录政策版本、汇率和结果。主播积分达到政策后,由结算任务发起美元余额奖励入账,用户提现只能提现 HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD。
资源组可以通过资源域的 wallet_asset 组成员直接给用户发放 COIN 或 DIAMOND,但这仍然是钱包入账流水,不写用户资源权益;钻石后续兑换仍必须走独立兑换订单。
Current Implementation Snapshot
| Capability | Status | Current Evidence | Gap |
|---|---|---|---|
DebitGift RPC |
DONE |
DebitGift 只接收礼物 ID、数量、房间区域和可选价格版本,返回 transaction_id/coin_spent/gift_point_added/heat_value/price_version |
充值、兑换、提现仍需新增 RPC 和状态机 |
| 多资产账户 | PARTIAL |
wallet_accounts(app_code,user_id,asset_type,available_amount,frozen_amount,version) |
已能表达冻结余额;提现冻结/解冻/出账命令未落地 |
| 交易和分录 | DONE |
交易写 wallet_transactions、wallet_entries,不保留旧流水表 |
充值、兑换、提现的分录类型仍需补齐 |
| 命令幂等 | DONE |
wallet_transactions.command_id + request_hash |
重复 provider receipt 也要幂等 |
| 余额不足保护 | DONE |
MySQL 事务锁 sender account,余额不足失败 | 需要并发压测覆盖多资产和冻结余额 |
| 礼物价格 | DONE |
wallet_gift_prices 提供服务端 coin_price/gift_point_amount/heat_value,客户端不提交礼物单价 |
礼物资源、资源组和赠送边界见资源架构文档 |
| 充值 | PARTIAL |
币商转账已写 wallet_recharge_records,provider order 未实现 |
Apple、Google、线下订单和 provider 校验仍需补齐 |
| 币商转金币 | DONE |
TransferCoinFromSeller 扣 COIN_SELLER_COIN、加玩家 COIN,并按区域充值政策记录美元充值金额 |
限额、线下订单和风控审计仍需补齐 |
| 钻石兑换 | TODO |
无 diamond exchange order | 需要政策快照、兑换状态机和原子出入账 |
| 主播积分/奖励 | PARTIAL |
送礼实时给收礼人加 GIFT_POINT |
周期结算任务和 HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD 奖励入账未落地 |
| 提现 | TODO |
无 withdraw request | 需要冻结、审核、人工打款、失败回滚 |
| 钱包 outbox | PARTIAL |
wallet_outbox 已和交易同事务写入 WalletBalanceChanged/WalletGiftDebited/WalletCoinSellerTransferred/WalletRechargeRecorded |
outbox worker 和 MQ 投递适配未落地 |
这张表是需求边界,不是上线状态承诺。后续开发必须先补账务事实层,再补 HTTP 管理入口和活动联动。
Requirement Scope
P0: Gift Billing Foundation
DebitGift只接收服务端定价所需字段,不保留历史兼容字段。- 钱包按服务端礼物价格结算
coin_spent、gift_point_added和heat_value,客户端不能提交礼物单价。 - 送礼扣费必须校验
gift_configs和gift_config_regions,只允许房间visible_region_id或 GLOBAL 区域可用的 active 礼物进入账务。 - 同一个事务内完成 sender
COIN扣减、targetGIFT_POINT入账、交易记录、分录和 outbox。 room-service只消费钱包返回的已结算值更新房间热度、本地榜和房间 outbox。- 扣费失败时
room-service不进入 Room Cell 状态变更。
P1: Balance And Admin Operations
- 增加多资产余额查询,客户端只能看到自己的余额,后台可以按权限查询用户资产和流水。
- 增加后台手动充值、扣减和调账命令,所有操作必须记录操作人、原因、凭证和审批来源。
- 增加账务对账查询:按用户、资产、交易、外部订单和时间窗口查询 transaction/entry。
- 任何后台命令都必须有独立
command_id,重复提交返回原结果,不允许重复入账。
P2: Provider Recharge
- Apple/Google 充值必须落
payment_orders,订单状态和账务入账不能只依赖客户端回调。 - provider receipt、transaction id、purchase token 必须全局唯一,重复通知只能返回原订单结果。
- provider 校验失败不能产生资产分录,校验成功和资产入账必须在同一个 MySQL 事务提交。
- 商品配置必须是服务端资产包配置,客户端传入的商品 ID 只能用于匹配,不决定到账金额。
P3: Coin Seller And Exchange
- 币商库存是币商自己的
COIN_SELLER_COIN账户余额,币商给用户转币必须先扣币商再加用户。 - 币商转账按双方同一区域的 active 充值政策换算充值金额,例如
coin_amount=80000、usd_minor_amount=100表示 80000 金币记 1 美元充值。 - 充值金额使用美元最小单位
usd_minor_amount入账;金币数量不能精确换算到最小单位时直接拒绝,避免隐式四舍五入。 - 币商转账需要限额、风控状态、线下订单号、目标用户和审计日志。
- 钻石兑换必须生成兑换订单,记录政策版本和汇率快照,兑换成功原子扣
DIAMOND并加目标资产。 - 兑换失败不能出现单边扣减,重复兑换命令必须返回同一兑换订单结果。
P4: Anchor Reward And Withdraw
- 送礼实时加主播
GIFT_POINT,但美元奖励由结算任务按政策转换,不能写死在送礼链路。 - 奖励结算生成
anchor_reward_settlements,确认后加主播HOST_SALARY_USD,AGENCY_SALARY_USD,BD_SALARY_USD,ADMIN_SALARY_USD。 - 提现申请必须先冻结
HOST_SALARY_USD,AGENCY_SALARY_USD,BD_SALARY_USD,ADMIN_SALARY_USD,审核拒绝解冻,审核通过后进入人工打款流程。 - 人工打款完成后从冻结余额出账;打款失败回到可重试状态,不能自动解冻后丢失审核上下文。
Component Diagram
graph LR
Client["Client"] --> Gateway["gateway-service"]
Gateway --> Room["room-service"]
Gateway --> Wallet["wallet-service"]
Gateway --> Admin["admin/ops API"]
Room -->|"DebitGift gRPC"| Wallet
Admin -->|"manual recharge / withdraw review"| Wallet
Merchant["coin merchant"] -->|"merchant transfer API"| Gateway
Apple["Apple IAP"] -->|"receipt / callback"| Gateway
Google["Google Play Billing"] -->|"receipt / callback"| Gateway
Wallet --> MySQL[("wallet MySQL")]
Wallet --> Outbox[("wallet_outbox")]
Outbox --> MQ["MQ / event bus"]
MQ --> Activity["activity-service / settlement jobs"]
MQ --> BI["risk / audit / analytics"]
Wallet --> RoomEvent["billing result"]
Room --> TencentIM["Tencent IM room message"]
Ledger Design
钱包不要继续使用“单账户余额表 + 单条流水”承载所有场景。目标模型是账户表记录余额,交易表记录业务命令,分录表记录每个账户的资产变化。
核心表建议:
wallet_accounts(
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
asset_type VARCHAR(32) NOT NULL,
available_amount BIGINT NOT NULL,
frozen_amount BIGINT NOT NULL,
version BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, asset_type)
);
wallet_transactions(
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
transaction_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
biz_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
request_hash VARCHAR(128) NOT NULL,
external_ref VARCHAR(128) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, transaction_id),
UNIQUE KEY uk_wallet_tx_command (app_code, command_id)
);
wallet_entries(
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
transaction_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
asset_type VARCHAR(32) NOT NULL,
available_delta BIGINT NOT NULL,
frozen_delta BIGINT NOT NULL,
available_after BIGINT NOT NULL,
frozen_after BIGINT NOT NULL,
counterparty_user_id BIGINT NOT NULL DEFAULT 0,
room_id VARCHAR(96) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
KEY idx_wallet_entries_user_time (app_code, user_id, created_at_ms),
KEY idx_wallet_entries_tx (app_code, transaction_id)
);
交易和分录的核心约束:
wallet_transactions.command_id是钱包命令幂等键,同一个app_code + command_id重复提交必须返回同一结果。wallet_transactions.request_hash覆盖业务语义,同一个app_code + command_id携带不同 payload 必须返回冲突。wallet_entries是资产事实的审计分录,原则上只追加不修改;余额修正也要通过新交易和新分录表达。wallet_entries.available_delta和frozen_delta必须同时表达一笔资产变化,提现冻结是 available 负数、frozen 正数,打款完成是 frozen 负数。wallet_accounts.version用于并发更新保护,写路径仍以 MySQL 行锁和事务为准。available_amount + frozen_amount是用户某资产的总负债,不能出现负数,不能由 Redis 恢复。
支付、兑换、提现使用独立状态表承载业务状态机:
payment_orders: Apple、Google、运营给币商发币和线下充值记录。coin_seller_transfers: 币商给用户转金币的交易申请和结果;当前实现先用wallet_transactions.biz_type=coin_seller_transfer承载结果。diamond_exchange_orders: 钻石兑换金币或美元余额的政策快照。anchor_reward_settlements: 主播积分达到政策后的美元奖励结算单。withdraw_requests: 主播提现申请、审核、驳回、人工打款和完成状态。wallet_outbox: 余额变化、充值成功、提现状态变化等事件投递源。
业务状态表必须把外部唯一键落到数据库约束里。Apple/Google 使用 provider transaction id 或 purchase token 唯一约束,币商转账使用 coin seller order id + seller user id 唯一约束,提现使用 withdraw request id 唯一约束。
礼物价格需要单独的服务端账务配置,不能由房间命令携带价格决定:
wallet_gift_prices(
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
gift_id VARCHAR(96) NOT NULL,
price_version VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
coin_price BIGINT NOT NULL,
gift_point_amount BIGINT NOT NULL,
heat_value BIGINT NOT NULL,
effective_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, gift_id, price_version),
KEY idx_wallet_gift_prices_effective (app_code, gift_id, status, effective_at_ms)
);
wallet_gift_prices 只承载账务结算字段。礼物名称、图标、动画、排序和客户端资源必须通过资源库和 gift_configs 配置,不能把资源字段塞进账务价格表;钱包落账时必须记录 gift_id、price_version、coin_price、gift_point_amount 和 heat_value 快照。
币商转账的充值口径需要单独记录区域政策和充值事实:
wallet_recharge_policies(
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
policy_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
region_id BIGINT NOT NULL,
policy_version VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
currency_code VARCHAR(3) NOT NULL DEFAULT 'USD',
coin_amount BIGINT NOT NULL,
usd_minor_amount BIGINT NOT NULL,
effective_from_ms BIGINT NOT NULL,
effective_to_ms BIGINT NULL,
UNIQUE KEY uk_wallet_recharge_policy_version (app_code, region_id, policy_version)
);
wallet_recharge_records(
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
transaction_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
seller_user_id BIGINT NOT NULL,
seller_region_id BIGINT NOT NULL,
target_region_id BIGINT NOT NULL,
policy_id BIGINT NOT NULL,
policy_version VARCHAR(64) NOT NULL,
currency_code VARCHAR(3) NOT NULL,
coin_amount BIGINT NOT NULL,
usd_minor_amount BIGINT NOT NULL,
exchange_coin_amount BIGINT NOT NULL,
exchange_usd_minor_amount BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, transaction_id)
);
wallet_recharge_records.usd_minor_amount 是用户充值额的权威统计口径;用户不会因此获得 HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD。政策字段必须保存快照,后续修改区域汇率不能重算历史充值。
Send Gift Flow
sequenceDiagram
participant C as Client
participant G as gateway-service
participant R as room-service
participant W as wallet-service
participant DB as wallet MySQL
participant IM as Tencent IM
C->>G: send gift
G->>R: SendGift(command_id, room_id, gift_id, count)
R->>R: Room Cell validates room/user/target
R->>W: DebitGift(command_id, payer, anchor, gift)
W->>DB: tx lock payer COIN account
W->>DB: debit COIN, credit GIFT_POINT, write tx/entries/outbox
W-->>R: receipt, coin_spent, point_added, balance_after
R->>R: commit heat/rank/contribution/snapshot/outbox
R-->>G: room snapshot
R->>IM: gift room message
DebitGift 应该由钱包按服务端礼物配置计算最终金币扣费和主播积分。客户端不提交礼物单价,room-service 只能使用钱包返回的已结算值;房间恢复也只使用 command log 中保存的 heat_value,不能重新推导价格。
送礼账务要求:
sender_user_id和target_user_id必须不同;如果产品允许给自己送礼,需要单独产品规则和风控开关。gift_count必须设置单次上限,防止大整数溢出和单请求绕过风控。coin_spent = coin_price * gift_count,计算前要检查乘法溢出。gift_point_added = gift_point_amount * gift_count,heat_value = heat_value_per_gift * gift_count。- 余额不足时不能写
wallet_transactions的成功状态,不能写wallet_entries,也不能写 outbox。 - 扣费成功后必须返回
billing_receipt_id、transaction_id、coin_spent、gift_point_added、heat_value、price_version和 senderCOIN账后余额。
Recharge Flows
Apple 和 Google:
- 客户端创建或购买商品,商品映射到固定资产包。
- 客户端提交 provider receipt 或 purchase token。
wallet-service调 provider 校验接口,确认未消费、金额、商品、环境和用户绑定。- MySQL 事务内写
payment_orders、wallet_transactions、wallet_entries和wallet_outbox。 - 同一个 provider transaction id 必须全局唯一,重复回调只能返回原结果。
币商:
- 币商私下给平台转账后,运营后台创建币商库存入账,审核通过后给币商账户发放
COIN_SELLER_COIN。 - 币商没有可提现余额入账,平台给币商发放的是可转给用户的金币库存。
- 用户私下给币商转账后,币商发起转金币命令,钱包在一个事务中扣币商
COIN_SELLER_COIN,加用户COIN。 - 币商给用户转多少
COIN,就代表这次用户通过币商完成了多少金币充值。 - 这类交易
biz_type=coin_seller_transfer,同时记录币商 ID、目标用户、金额、原因和操作来源。 - 币商不能直接改用户余额,所有发币和转币都必须走钱包命令和审计流水。
运营线下充值:
- 用户线下给平台转账后,运营后台创建
manual_recharge。 - 审核通过后钱包给用户加
COIN,并记录操作人、凭证、金额和备注。 - 手动充值必须有权限、二次确认和审计日志。
充值订单状态机:
stateDiagram-v2
[*] --> created
created --> verifying
verifying --> verified
verifying --> verify_failed
verified --> credited
credited --> [*]
verify_failed --> [*]
credited 是唯一代表资产已到账的终态。verified 只代表 provider 或后台审核通过,不代表余额已更新;从 verified 到 credited 必须和 wallet_transactions、wallet_entries、wallet_outbox 同事务提交。
Diamond Exchange
flowchart LR
A["User DIAMOND account"] -->|"debit diamonds"| X["diamond_exchange_order"]
X -->|"policy: diamond to coin"| C["User COIN account"]
X -->|"policy: diamond to USD"| U["User identity salary wallet account"]
钻石兑换必须冻结政策版本。订单里至少记录 from_diamond_amount、to_asset_type、to_amount、policy_id、rate_snapshot 和 status。兑换成功后写分录和 outbox;兑换失败不能出现钻石扣了但目标资产没到账。
Anchor Reward And Withdraw
主播通过礼物获得 GIFT_POINT。积分政策不要写死在送礼链路里,建议由结算任务按周期计算奖励:
- 送礼时钱包给主播增加
GIFT_POINT。 - 结算任务读取积分和政策,生成
anchor_reward_settlements。 - 结算单确认后调用钱包
CreditRewardBalance,增加主播HOST_SALARY_USD,AGENCY_SALARY_USD,BD_SALARY_USD,ADMIN_SALARY_USD。 - 主播提现时先冻结
HOST_SALARY_USD,AGENCY_SALARY_USD,BD_SALARY_USD,ADMIN_SALARY_USD,审核拒绝解冻,审核通过后等待人工转账。 - 人工转账完成后把冻结余额出账,状态改为
paid。
提现状态机:
stateDiagram-v2
[*] --> pending_review
pending_review --> rejected
rejected --> refunded
pending_review --> approved
approved --> paying
paying --> paid
paying --> pay_failed
pay_failed --> approved
提现申请时必须从 available_amount 转到 frozen_amount,不能只写一个待审核单。否则审核期间用户可以重复提现同一笔余额。
RPC Surface
当前开发阶段直接使用新的 wallet RPC 契约:
GetBalances: 查询用户多资产余额。DebitGift: 扣金币、加主播积分,返回已结算值。CreatePaymentOrder: 创建 Apple/Google 商品订单。ConfirmProviderPayment: 校验 provider receipt 并入账。AdminCreditAsset: 运营手动充值或调账,只开放后台。TransferCoinFromSeller: 币商转金币给用户。ExchangeDiamond: 钻石兑换金币或美元余额。CreditRewardBalance: 结算任务给主播发美元余额奖励。CreateWithdrawRequest: 主播提现申请并冻结余额。ReviewWithdrawRequest: 后台审核提现。MarkWithdrawPaid: 人工打款后确认出账。
外部 HTTP 入口仍在 gateway-service,内部统一 gRPC + protobuf。当前处于开发阶段,不做历史兼容保留;修改 api/proto 后必须运行 make proto 和 go test ./...。
RPC Field Requirements
DebitGift 字段要求:
- Request:
price_version可选;为空时钱包选择当前生效价格;传入时必须命中有效价格。 - Response:
transaction_id,用于内部账务查询。 - Response:
coin_spent,实际扣减的COIN。 - Response:
gift_point_added,实际增加给收礼人的GIFT_POINT。 - Response:
heat_value,room-service 更新房间热度的唯一可信值。 - Response:
price_version,这次账务使用的礼物价格版本。 - Response:
balance_after,语义限定为 senderCOIN available_amount。
新 RPC 的共同字段要求:
- Request 必须包含
command_id,后台命令还必须包含operator_user_id、reason、evidence_ref。 - Response 必须包含稳定业务 ID,例如
transaction_id、payment_order_id、withdraw_request_id。 - 查询 RPC 可以不需要
command_id,但必须透传 gateway 的request_id用于追踪。 - 错误码必须能被 gateway 映射成稳定 HTTP envelope,不能只返回自由文本。
TransferCoinFromSeller 字段要求:
- Request:
seller_region_id和target_region_id来自 user-service 用户主数据,客户端不能提交。 - Request: 当前版本要求币商和目标用户同区域;跨区转账必须先设计明确的政策选择规则。
- Response:
recharge_usd_minor是本次转账按区域政策折算的充值美元最小单位金额。 - Response:
recharge_policy_id、recharge_policy_version、recharge_policy_coin_amount和recharge_policy_usd_minor_amount是冻结在本次充值记录里的政策快照。
HTTP And Admin Requirements
外部 HTTP 仍只放在 gateway-service,wallet-service 不直接暴露公网 HTTP。
客户端接口:
GET /api/v1/wallet/balances: 查询当前用户多资产余额。GET /api/v1/wallet/transactions: 查询当前用户流水,支持 asset、biz_type、时间和游标分页。POST /api/v1/wallet/payments/apple/confirm: 提交 Apple receipt 或 transaction id。POST /api/v1/wallet/payments/google/confirm: 提交 Google purchase token。POST /api/v1/wallet/diamond/exchange: 发起钻石兑换。POST /api/v1/wallet/withdrawals: 主播发起提现。GET /api/v1/wallet/withdrawals: 查询提现记录。
后台接口:
- 用户资产查询和流水查询。
- 手动充值、扣减和调账。
- 币商账户开通、禁用、库存发放、转账查询。
- 提现审核、打款标记、打款失败重试。
- 礼物账务价格配置和上下架;礼物资源、资源组和赠送入口按资源架构文档实现。
- 兑换政策、奖励政策和限额配置。
后台所有写接口必须记录操作人、来源 IP、原因、工单或凭证引用。没有审计字段的后台写操作不允许落地。
Error Semantics
钱包错误要区分“用户可处理”和“系统需重试”:
| Error | Meaning | Retry |
|---|---|---|
INSUFFICIENT_BALANCE |
可用余额不足 | no |
ACCOUNT_FROZEN |
账户被风控冻结 | no |
GIFT_NOT_FOUND |
礼物账务配置不存在 | no |
GIFT_OFF_SHELF |
礼物不可购买 | no |
REQUEST_CONFLICT |
同 command_id 不同 payload |
no |
PAYMENT_VERIFY_FAILED |
provider 校验失败 | no |
PROVIDER_DUPLICATE |
provider 交易已处理 | return original |
WITHDRAW_AMOUNT_INVALID |
提现金额或门槛不满足 | no |
LEDGER_BUSY |
同账户并发冲突或锁等待超时 | yes |
LEDGER_INTERNAL |
数据库或内部未知错误 | yes |
room-service 调 DebitGift 时,只能在明确可重试错误上按上层策略重试。余额不足、礼物下架、请求冲突不能重试成重复扣费。
Outbox Events
wallet_outbox 必须和账务交易同事务写入。事件消费者失败不能回滚账务事实,只能通过 outbox worker 补投。
建议事件:
WalletBalanceChanged: 任意资产 available/frozen 变化。WalletGiftDebited: 送礼扣费成功,包含 sender、target、room、gift、price_version、coin_spent、gift_point_added。PaymentCredited: provider 或后台充值到账。WalletCoinSellerTransferred: 币商给用户转金币成功。WalletRechargeRecorded: 用户充值口径记录成功,包含coin_amount、usd_minor_amount、policy_id和policy_version。DiamondExchanged: 钻石兑换成功。RewardBalanceCredited: 主播奖励美元余额入账。WithdrawFrozen: 提现申请冻结成功。WithdrawPaid: 人工打款完成并出账。WithdrawRejected: 审核拒绝并解冻。
事件 payload 必须包含 event_id、transaction_id、command_id、user_id、asset_type、available_delta、frozen_delta、created_at_ms。消费者必须按 event_id 或 transaction_id + event_type 幂等。
Reconciliation And Observability
- 每日对账任务按
wallet_entries汇总校验wallet_accounts.available_amount/frozen_amount。 - Provider 充值需要按 provider 后台交易和本地
payment_orders做差异对账。 - 提现需要按
withdraw_requests状态、冻结余额和人工打款记录做对账。 - 关键指标:扣费成功率、余额不足数、幂等命中数、请求冲突数、provider 校验失败数、outbox pending 数、outbox 重试次数、提现冻结金额。
- 关键日志必须带
request_id、command_id、transaction_id、user_id、biz_type,但不能输出 provider receipt、支付凭证全文和敏感证件信息。
Development Plan
- 保持当前干净账本表:
wallet_accounts、wallet_transactions、wallet_entries、wallet_outbox、wallet_gift_prices。 DebitGift只走 v2 账本和服务端礼物价格,不保留客户端单价字段。- 增加后台礼物价格配置、资源库选礼物、上下架和审计入口。
- 增加充值、兑换、提现订单表和状态机。
- 增加钱包 outbox worker 和 MQ 投递适配。
Acceptance Criteria
账务基础:
- 同一
command_id、同一 payload 重复调用DebitGift,余额只变化一次,返回同一billing_receipt_id。 - 同一
command_id、不同 payload 调用DebitGift,返回REQUEST_CONFLICT,余额不变。 - 并发多个扣费请求打同一个 sender 账户,不出现负数余额。
- 余额不足时没有成功交易、没有分录、没有 outbox。
送礼:
- 钱包按
wallet_gift_prices计算扣费、积分和热度,客户端没有礼物单价输入。 - 扣费成功后 sender
COIN减少,targetGIFT_POINT增加,room-service 使用钱包返回heat_value更新房间热度。 - 腾讯 IM 或 room outbox 投递失败不影响钱包交易事实。
充值和兑换:
- 同一个 Apple/Google provider transaction 重复确认,只返回原到账结果。
- provider 校验失败不会产生资产分录。
- 币商转金币在一个事务中扣币商、加用户,任一侧失败则整体失败。
- 钻石兑换成功后扣
DIAMOND和加目标资产是原子结果。
提现:
- 提现申请成功后
identity salary wallet.available_amount减少,frozen_amount增加。 - 审核拒绝后冻结金额回到 available。
- 打款完成后 frozen 减少并写出账分录。
- 打款失败后状态可重试,不丢失冻结关系和审核上下文。
验证命令:
make proto
go test ./services/wallet-service/...
go test ./services/room-service/...
go test ./services/gateway-service/...
docker compose config
涉及 Docker、配置或 provider mock 时再补 docker compose build 和实际 docker compose up -d 冒烟。
Implementation Order
- 新增 v2 账本表:
wallet_accounts扩展为多资产 available/frozen,新增wallet_transactions、wallet_entries、wallet_outbox。 - 重构 repository:提供通用
ApplyTransaction,在一个 MySQL 事务中完成幂等、锁账户、写分录、更新余额。 - 保持现有
DebitGiftRPC 可用,内部改为COIN扣费和GIFT_POINT入账。 - 增加余额查询和管理端手动充值,先打通运营可测链路。
- 增加 Apple/Google 订单和 receipt 校验,provider transaction id 做唯一约束。
- 增加币商账户、币商转账、区域充值政策快照和限额审计。
- 增加钻石兑换订单。
- 增加主播奖励结算和提现冻结/审核/人工打款闭环。
Critical Rules
- 任何入账、扣款、冻结、解冻、出账都必须写 transaction 和 entry,不能直接 update balance。
- 幂等键必须覆盖业务语义,同一个 command_id 带不同 payload 必须返回冲突。
- Apple/Google 充值以 provider 真实交易号为最终幂等源,不能只信客户端 request id。
- 币商充值分两段:平台给币商发
COIN_SELLER_COIN库存,币商再把库存转成用户COIN;必须有币商金币余额、充值政策快照、限额和审计。 HOST_SALARY_USD,AGENCY_SALARY_USD,BD_SALARY_USD,ADMIN_SALARY_USD是可提现负债,调账和奖励发放需要更高权限和审计。- 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
- 钱包事件投递使用 outbox,MQ 投递失败不回滚账务事实。
- 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。