hyapp-server/docs/钱包服务架构.md

29 KiB
Raw Blame History

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_idcommand_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 组成员直接给用户发放 COINDIAMOND,但这仍然是钱包入账流水,不写用户资源权益;钻石后续兑换仍必须走独立兑换订单。

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_transactionswallet_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_recordsprovider order 未实现 Apple、Google、线下订单和 provider 校验仍需补齐
币商转金币 DONE TransferCoinFromSellerCOIN_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_spentgift_point_addedheat_value,客户端不能提交礼物单价。
  • 送礼扣费必须校验 gift_configsgift_config_regions,只允许房间 visible_region_id 或 GLOBAL 区域可用的 active 礼物进入账务。
  • 同一个事务内完成 sender COIN 扣减、target GIFT_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=80000usd_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_deltafrozen_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_idprice_versioncoin_pricegift_point_amountheat_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_idtarget_user_id 必须不同;如果产品允许给自己送礼,需要单独产品规则和风控开关。
  • gift_count 必须设置单次上限,防止大整数溢出和单请求绕过风控。
  • coin_spent = coin_price * gift_count,计算前要检查乘法溢出。
  • gift_point_added = gift_point_amount * gift_countheat_value = heat_value_per_gift * gift_count
  • 余额不足时不能写 wallet_transactions 的成功状态,不能写 wallet_entries,也不能写 outbox。
  • 扣费成功后必须返回 billing_receipt_idtransaction_idcoin_spentgift_point_addedheat_valueprice_version 和 sender COIN 账后余额。

Recharge Flows

Apple 和 Google

  1. 客户端创建或购买商品,商品映射到固定资产包。
  2. 客户端提交 provider receipt 或 purchase token。
  3. wallet-service 调 provider 校验接口,确认未消费、金额、商品、环境和用户绑定。
  4. MySQL 事务内写 payment_orderswallet_transactionswallet_entrieswallet_outbox
  5. 同一个 provider transaction id 必须全局唯一,重复回调只能返回原结果。

币商:

  1. 币商私下给平台转账后,运营后台创建币商库存入账,审核通过后给币商账户发放 COIN_SELLER_COIN
  2. 币商没有可提现余额入账,平台给币商发放的是可转给用户的金币库存。
  3. 用户私下给币商转账后,币商发起转金币命令,钱包在一个事务中扣币商 COIN_SELLER_COIN,加用户 COIN
  4. 币商给用户转多少 COIN,就代表这次用户通过币商完成了多少金币充值。
  5. 这类交易 biz_type=coin_seller_transfer,同时记录币商 ID、目标用户、金额、原因和操作来源。
  6. 币商不能直接改用户余额,所有发币和转币都必须走钱包命令和审计流水。

运营线下充值:

  1. 用户线下给平台转账后,运营后台创建 manual_recharge
  2. 审核通过后钱包给用户加 COIN,并记录操作人、凭证、金额和备注。
  3. 手动充值必须有权限、二次确认和审计日志。

充值订单状态机:

stateDiagram-v2
  [*] --> created
  created --> verifying
  verifying --> verified
  verifying --> verify_failed
  verified --> credited
  credited --> [*]
  verify_failed --> [*]

credited 是唯一代表资产已到账的终态。verified 只代表 provider 或后台审核通过,不代表余额已更新;从 verifiedcredited 必须和 wallet_transactionswallet_entrieswallet_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_amountto_asset_typeto_amountpolicy_idrate_snapshotstatus。兑换成功后写分录和 outbox兑换失败不能出现钻石扣了但目标资产没到账。

Anchor Reward And Withdraw

主播通过礼物获得 GIFT_POINT。积分政策不要写死在送礼链路里,建议由结算任务按周期计算奖励:

  1. 送礼时钱包给主播增加 GIFT_POINT
  2. 结算任务读取积分和政策,生成 anchor_reward_settlements
  3. 结算单确认后调用钱包 CreditRewardBalance,增加主播 HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD
  4. 主播提现时先冻结 HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD,审核拒绝解冻,审核通过后等待人工转账。
  5. 人工转账完成后把冻结余额出账,状态改为 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 protogo test ./...

RPC Field Requirements

DebitGift 字段要求:

  • Request: price_version 可选;为空时钱包选择当前生效价格;传入时必须命中有效价格。
  • Response: transaction_id,用于内部账务查询。
  • Response: coin_spent,实际扣减的 COIN
  • Response: gift_point_added,实际增加给收礼人的 GIFT_POINT
  • Response: heat_valueroom-service 更新房间热度的唯一可信值。
  • Response: price_version,这次账务使用的礼物价格版本。
  • Response: balance_after,语义限定为 sender COIN available_amount

新 RPC 的共同字段要求:

  • Request 必须包含 command_id,后台命令还必须包含 operator_user_idreasonevidence_ref
  • Response 必须包含稳定业务 ID例如 transaction_idpayment_order_idwithdraw_request_id
  • 查询 RPC 可以不需要 command_id,但必须透传 gateway 的 request_id 用于追踪。
  • 错误码必须能被 gateway 映射成稳定 HTTP envelope不能只返回自由文本。

TransferCoinFromSeller 字段要求:

  • Request: seller_region_idtarget_region_id 来自 user-service 用户主数据,客户端不能提交。
  • Request: 当前版本要求币商和目标用户同区域;跨区转账必须先设计明确的政策选择规则。
  • Response: recharge_usd_minor 是本次转账按区域政策折算的充值美元最小单位金额。
  • Response: recharge_policy_idrecharge_policy_versionrecharge_policy_coin_amountrecharge_policy_usd_minor_amount 是冻结在本次充值记录里的政策快照。

HTTP And Admin Requirements

外部 HTTP 仍只放在 gateway-servicewallet-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-serviceDebitGift 时,只能在明确可重试错误上按上层策略重试。余额不足、礼物下架、请求冲突不能重试成重复扣费。

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_amountusd_minor_amountpolicy_idpolicy_version
  • DiamondExchanged: 钻石兑换成功。
  • RewardBalanceCredited: 主播奖励美元余额入账。
  • WithdrawFrozen: 提现申请冻结成功。
  • WithdrawPaid: 人工打款完成并出账。
  • WithdrawRejected: 审核拒绝并解冻。

事件 payload 必须包含 event_idtransaction_idcommand_iduser_idasset_typeavailable_deltafrozen_deltacreated_at_ms。消费者必须按 event_idtransaction_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_idcommand_idtransaction_iduser_idbiz_type,但不能输出 provider receipt、支付凭证全文和敏感证件信息。

Development Plan

  1. 保持当前干净账本表:wallet_accountswallet_transactionswallet_entrieswallet_outboxwallet_gift_prices
  2. DebitGift 只走 v2 账本和服务端礼物价格,不保留客户端单价字段。
  3. 增加后台礼物价格配置、资源库选礼物、上下架和审计入口。
  4. 增加充值、兑换、提现订单表和状态机。
  5. 增加钱包 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 减少target GIFT_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

  1. 新增 v2 账本表:wallet_accounts 扩展为多资产 available/frozen新增 wallet_transactionswallet_entrieswallet_outbox
  2. 重构 repository提供通用 ApplyTransaction,在一个 MySQL 事务中完成幂等、锁账户、写分录、更新余额。
  3. 保持现有 DebitGift RPC 可用,内部改为 COIN 扣费和 GIFT_POINT 入账。
  4. 增加余额查询和管理端手动充值,先打通运营可测链路。
  5. 增加 Apple/Google 订单和 receipt 校验provider transaction id 做唯一约束。
  6. 增加币商账户、币商转账、区域充值政策快照和限额审计。
  7. 增加钻石兑换订单。
  8. 增加主播奖励结算和提现冻结/审核/人工打款闭环。

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。
  • 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
  • 钱包事件投递使用 outboxMQ 投递失败不回滚账务事实。
  • 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。