送礼增加金币

This commit is contained in:
zhx 2026-06-22 20:43:39 +08:00
parent 632e6992c0
commit ce060acc6e
4 changed files with 783 additions and 53 deletions

View File

@ -0,0 +1,387 @@
# wallet-service 模块化拆分方案
## 结论
`wallet-service` 需要一起拆三层:`service` 用例层、`storage/mysql` 持久化层、`transport/grpc` 协议适配层。只拆 `repository.go` 不够,因为当前 service 层的 `Repository` 接口已经把礼物扣费、充值、红包、资源、VIP、游戏账变、币商和投影混成一个大端口文件变小后边界仍然不清晰。
推荐路线是两阶段:
| 阶段 | 目标 | 变更方式 |
| --- | --- | --- |
| 第一阶段 | 低风险整理文件,不改行为 | 保持 `walletservice.Service``mysql.Repository` 和 gRPC 注册方式不变,只在同 package 内按业务域拆文件,并把大 `Repository` 接口拆成嵌入式能力接口 |
| 第二阶段 | 真正目录模块化 | 抽出 `ports``usecase/*` 子包,`wallet.Service` 只保留门面方法transport 继续依赖门面,避免 gRPC 层直接感知内部用例拆分 |
不要第一步就把 MySQL repository 拆成多个 Go package。钱包账务依赖同一个 `*sql.Tx` 保证账户、交易、分录、outbox 和幂等结果同事务提交;过早拆 package 会迫使大量事务 helper、metadata 和 outbox 构造函数导出,风险比收益大。
## 当前结构和问题
当前 wallet-service 的主要文件规模:
| 路径 | 行数 | 问题 |
| --- | ---: | --- |
| `internal/storage/mysql/repository.go` | 5089 | 混合核心账本、送礼、活动奖励、游戏账变、币商、工资兑换、outbox、账号锁、receipt 和 metadata |
| `internal/storage/mysql/resource_repository.go` | 3987 | 资源目录、资源组、礼物配置、发放、穿戴、商城和资源 outbox 混在一起 |
| `internal/service/wallet/service.go` | 1259 | service 门面、依赖注入、礼物、工资、币商、奖励、游戏、充值、VIP、投影混在一个文件 |
| `internal/service/wallet/service_test.go` | 5625 | 测试文件过大,说明 service 用例边界没有被稳定拆开 |
| `internal/transport/grpc/server.go` | 1389 | gRPC handler 和 proto/domain mapper 混合,资源和红包已经拆出一部分,但主文件仍覆盖过多业务 |
| `internal/app/app.go` | 576 | 进程装配、MQ 生命周期、outbox worker、投影 consumer、三方支付补偿和红包过期 worker 混合 |
| `internal/domain/ledger/ledger.go` | 1407 | 账本领域对象混合所有业务命令和回执 |
| `internal/domain/resource/resource.go` | 729 | 资源目录、礼物配置、用户权益、商城和发放对象混合 |
当前最核心的问题不是单个文件长,而是变化原因互相交叉:
| 变化原因 | 当前落点 | 应独立出来的边界 |
| --- | --- | --- |
| 送礼扣费和背包送礼 | `service.go` + `repository.go` + `resource_repository.go` | gift ledger |
| 后台调账、活动奖励、游戏账变 | `service.go` + `repository.go` | ledger operations |
| Google、H5、MiFaPay、V5Pay、USDT | `service.go` + `external_recharge.go` + `third_party_payment_repository.go` | recharge |
| 资源、礼物、穿戴、商城 | `resource.go` + `resource_repository.go` + `transport/grpc/resource.go` | resource |
| VIP 和钱包首页查询 | `service.go` + `app_wallet_repository.go` | app wallet |
| 红包 | `red_packet.go` + `red_packet_repository.go` + `transport/grpc/red_packet.go` | red packet |
| 钱包 outbox 和投影 | `app.go` + `service.go` + `repository.go` + projection files | outbox/projection |
| 主播工资、币商库存和工资换币 | `service.go` + `repository.go` + `host_salary_settlement.go` | host salary / coin seller |
## 拆分原则
1. `wallet-service` 仍然是账务事实 owner。余额、交易、分录和 `wallet_outbox` 不拆到其他服务。
2. `wallet_outbox` 仍然只由 wallet-service 直接读取和发布。下游服务只能消费 RocketMQ 或自己的投影表,不能回扫钱包库补业务事实。
3. 第一阶段不改 protobuf、不改 gRPC 方法名、不改 `walletservice.New(repository, activity)` 装配方式。
4. MySQL 第一阶段保持一个 `mysql.Repository` 类型,方法可以拆文件,但事务 helper 仍留在同 package。
5. service 第一阶段保持一个 `wallet.Service` 门面,外部调用不变;先拆文件和接口,再考虑 usecase 子包。
6. 每一步只搬同一业务域的方法、metadata、request hash、receipt 和 outbox event避免把一个事务链路拆成多次提交。
## 第一阶段目标结构
### service 层
保留 package
```text
services/wallet-service/internal/service/wallet/
```
第一阶段目标文件:
```text
service.go # Service struct、New、依赖注入 setter、通用 guard
repository.go # Repository 总接口和嵌入式能力接口
clients.go # ActivityBadgeClient、GooglePlayClient、MifaPayClient、V5PayClient、TronUSDTClient
gift.go # DebitGift、DebitRobotGift、BatchDebitGift
balance.go # GetBalances、GetWalletOverview、GetWalletValueSummary、GetUserGiftWall、ListWalletTransactions
host_salary.go # GetActiveHostSalaryPolicy、GetHostSalaryProgress、ProcessHostSalarySettlementBatch
coin_seller.go # AdminCreditCoinSellerStock、TransferCoinFromSeller、ExchangeSalaryToCoin、TransferSalaryToCoinSeller
admin_ledger.go # AdminCreditAsset
reward.go # CreditTaskReward、CreditLuckyGiftReward、CreditWheelReward、CreditRoomTurnoverReward、CreditInviteActivityReward、CreditAgencyOpeningReward
game.go # ApplyGameCoinChange
recharge.go # ListRechargeBills、ListRechargeProducts、ListAdminRechargeProducts、Create/Update/DeleteRechargeProduct、ConfirmGooglePayment
external_recharge.go # H5 三方充值、通知、查单补偿,保留现有文件但只放外部充值
vip.go # ListVipPackages、GetMyVip、PurchaseVip、GrantVip、ListAdminVipLevels、UpdateAdminVipLevels
red_packet.go # 红包用例,保留现有文件
resource.go # 资源目录、资源组、礼物配置、发放、穿戴、商城,后续再细拆
projection.go # ProjectPendingGiftWallEvents、ProcessWalletProjectionMessage、ProjectPendingBadgeGrantEvents
```
`repository.go` 建议按 room-service 的方式拆成嵌入式能力接口:
```go
type Repository interface {
GiftLedgerStore
BalanceStore
HostSalaryStore
CoinSellerStore
AdminLedgerStore
RewardLedgerStore
GameLedgerStore
RechargeStore
ExternalRechargeStore
VIPStore
RedPacketStore
ResourceCatalogStore
ResourceGrantStore
ResourceEquipmentStore
ResourceShopStore
WalletProjectionStore
BadgeProjectionStore
}
```
接口边界建议:
| 接口 | 方法范围 |
| --- | --- |
| `GiftLedgerStore` | `DebitGift``BatchDebitGift` |
| `BalanceStore` | `GetBalances`、钱包首页、钱包流水、礼物墙 |
| `HostSalaryStore` | 工资政策、工资进度、工资结算批处理 |
| `CoinSellerStore` | 币商库存、币商转币、工资换币 |
| `AdminLedgerStore` | 后台调账 |
| `RewardLedgerStore` | 任务、幸运礼物、转盘、房间流水、邀请、开业奖励 |
| `GameLedgerStore` | 游戏扣款、返奖、退款、冲正 |
| `RechargeStore` | 充值档位、Google 支付、充值账单 |
| `ExternalRechargeStore` | H5 三方支付方式、下单、回调、查单补偿 |
| `VIPStore` | VIP 包、购买、发放、后台配置 |
| `RedPacketStore` | 红包配置、创建、领取、过期退款、后台重试 |
| `ResourceCatalogStore` | 资源、资源组、礼物配置、礼物类型 |
| `ResourceGrantStore` | 单资源发放、资源组发放、发放记录 |
| `ResourceEquipmentStore` | 用户权益、穿戴、卸下、批量读取穿戴 |
| `ResourceShopStore` | 资源商城档位、购买记录、购买 |
| `WalletProjectionStore` | 礼物墙投影和 wallet outbox message 投影入口 |
| `BadgeProjectionStore` | badge grant 投影 claim、delivered、failed |
### storage/mysql 层
保留 package
```text
services/wallet-service/internal/storage/mysql/
```
`repository.go` 第一阶段只保留:
```text
Repository struct
Open / Close / Ping
通用常量中真正跨业务域使用的部分
```
建议拆出的文件:
```text
schema.go # ensure*Schema、重复列/重复索引兼容判断
account.go # walletAccount、lockAccount、lockAccountsOrdered、applyAccountDelta
transaction.go # transactionRow、lookupTransaction、insertTransaction、transactionID、request hash 公共能力
entry.go # walletEntry、insertEntry、balanceAfterTransaction
outbox.go # WalletOutboxRecord、WalletOutboxClaimFilter、Claim/Mark/insertWalletOutbox、事件类型过滤
gift_ledger.go # DebitGift、BatchDebitGift、giftDebitBizType、背包送礼消耗
gift_pricing.go # resolveGiftPrice、resolveGiftDiamondRatio、resolveActiveGiftConfig
gift_receipt.go # giftMetadata、receiptFromGiftMetadata、gift outbox events
host_diamond.go # creditHostPeriodDiamonds、host period diamond account/entry
admin_ledger.go # AdminCreditAsset、adminCreditMetadata、adminCreditRequestHash
reward_ledger.go # 各活动奖励入账、metadata、receipt、request hash、outbox event
game_ledger.go # ApplyGameCoinChange、gameCoinMetadata、gameBizTypeAndDelta
coin_seller_ledger.go # stock、seller transfer、salary exchange、salary transfer、相关 metadata/hash/receipt
```
已存在文件的后续整理:
```text
app_wallet_repository.go # VIP + 钱包首页较大,后续拆 vip_repository.go / wallet_query_repository.go
resource_repository.go # 后续拆 resource_catalog.go / resource_group.go / resource_grant.go / user_resource.go / resource_shop.go / resource_outbox.go
third_party_payment_repository.go # 后续拆 payment_method_repository.go / external_recharge_order_repository.go
red_packet_repository.go # 已独立,但可继续拆 config/create/claim/refund/query
```
### transport/grpc 层
保留 `Server` 类型和 protobuf 注册方式不变,先按 RPC 业务域拆文件:
```text
server.go # Server struct、NewServer、通用 helper
gift.go # DebitGift、BatchDebitGift、DebitRobotGift、gift response mapper
balance.go # GetBalances、钱包首页、钱包流水、礼物墙 mapper
host_salary.go # 工资政策、工资进度、cron 结果 mapper
admin_ledger.go # AdminCreditAsset
coin_seller.go # 币商库存、转币、工资换币
reward.go # 活动奖励 RPC
game.go # ApplyGameCoinChange
recharge.go # 充值账单、Google、充值档位
external_recharge.go # H5 三方支付 RPC 和 mapper
vip.go # VIP RPC 和 mapper
resource.go # 资源 handler后续再拆 catalog/grant/shop/equipment
resource_mapper.go # resource proto/domain mapper
red_packet.go # 红包 handler
red_packet_mapper.go # 红包 proto/domain mapper
cron_server.go # cron gRPC server保留独立
```
`transport/grpc` 的拆分原则是handler 和 mapper 可以同业务域放一起,但一个文件超过 500 行时应把 mapper 单独拆出。transport 只能做 proto/domain 转换和错误透传,不要在这里补账务规则。
### app 装配层
`internal/app/app.go` 可以低风险拆成:
```text
app.go # App struct、New、Run、Close
clients.go # Activity、Google、MiFaPay、V5Pay、TronGrid 客户端装配
mq.go # RocketMQ producer/consumer 创建、start/shutdown
outbox_worker.go # wallet outbox claim/publish/mark
projection_worker.go # wallet projection consumer
external_recharge_worker.go # 外部充值查单补偿 worker
red_packet_worker.go # 红包过期退款 worker
health.go # health HTTP run/close
```
app 层不承载业务规则。worker 只负责任务循环和调用 service用例幂等必须仍在 service/storage 内保证。
### domain 层
第一阶段不拆 package只拆文件
```text
internal/domain/ledger/
ledger.go # 通用常量、AssetBalance、基础类型
gift.go # 送礼 command/receipt
host_salary.go # 工资 policy/progress/settlement
coin_seller.go # 币商库存、转币、工资换币
recharge.go # 充值档位、Google、充值账单
external_recharge.go # H5 三方订单、通知、支付方式
wallet_query.go # 钱包首页、流水、礼物墙
vip.go # VIP
reward.go # 活动奖励
game.go # 游戏账变
red_packet.go # 红包
internal/domain/resource/
resource.go # 通用 Resource
gift_config.go # GiftConfig、GiftTypeConfig
group.go # ResourceGroup
grant.go # GrantResource、ResourceGrant
entitlement.go # UserResourceEntitlement、穿戴
shop.go # ResourceShopItem、PurchaseOrder
```
领域对象拆文件的目标是让业务域可读,不要把领域对象拆成过细 package。否则 transport、service、storage 都要同时改 import收益不高。
## 第二阶段目标目录
第一阶段稳定后,再考虑 service 真正目录化。目标是 `wallet.Service` 作为门面,内部 usecase 子包只依赖 ports 和 domain
```text
services/wallet-service/internal/service/wallet/
service.go # 门面,保留现有对外方法
ports/
repository.go # repository 能力接口
clients.go # 三方 client 能力接口
clock.go # now/clock 抽象,如需要
usecase/
gift/
balance/
hostsalary/
coinseller/
adminledger/
reward/
game/
recharge/
externalrecharge/
vip/
redpacket/
resource/
projection/
```
依赖方向:
```text
transport/grpc -> service/wallet facade -> service/wallet/usecase/* -> service/wallet/ports
storage/mysql -> implements service/wallet/ports.Repository
client/* -> implements service/wallet/ports third-party clients
domain/* -> shared pure data and validation helpers
```
第二阶段不要让 `transport/grpc` 直接调用 `usecase/*`。gRPC 层直接感知所有 usecase 会把装配复杂度扩散到 transport后续新增 RPC 时更容易乱。
## 推荐实施顺序
### 1. service 低风险拆文件
先拆 `internal/service/wallet/service.go`,不改任何方法签名:
1. 新建 `repository.go`,移动 `Repository interface`,并改成嵌入式能力接口。
2. 新建 `clients.go`,移动 Activity、Google、MiFaPay、V5Pay、TronUSDT client 接口。
3. 按业务域移动 Service 方法到 `gift.go``host_salary.go``coin_seller.go``reward.go``game.go``recharge.go``vip.go``balance.go``projection.go`
4. 每移动一个业务域,运行 `go test ./services/wallet-service/internal/service/wallet`
### 2. transport 跟随拆分
`server.go` 只保留 `Server``NewServer` 和通用 helper。按 service 文件域拆 handler 文件mapper 超过 500 行时单独放 `*_mapper.go`
验证:
```bash
go test ./services/wallet-service/internal/transport/grpc
```
### 3. storage/mysql 拆核心 repository
先拆基础设施,再拆业务:
1. `schema.go`
2. `account.go`
3. `transaction.go`
4. `entry.go`
5. `outbox.go`
6. `gift_ledger.go``gift_pricing.go``gift_receipt.go``host_diamond.go`
7. `admin_ledger.go``reward_ledger.go``game_ledger.go``coin_seller_ledger.go`
每一步只搬代码,不改 SQL不改事务边界不改 outbox payload。
验证:
```bash
go test ./services/wallet-service/internal/storage/mysql
go test ./services/wallet-service/...
```
### 4. 拆 resource_repository.go
`resource_repository.go` 单独作为第二个大 storage 任务处理,避免和核心账本拆分混在一起:
```text
resource_catalog.go
resource_group.go
resource_grant.go
user_resource.go
resource_equipment.go
resource_shop.go
resource_outbox.go
```
这一步要特别保护背包送礼链路,因为 `gift_ledger.go` 会调用用户权益消耗相关 helper。
### 5. app worker 拆分
最后拆 `internal/app/app.go`。原因是 app 层只是装配和 worker 循环,等 service/storage 边界清晰后再拆更不容易来回调整。
验证:
```bash
go test ./services/wallet-service/...
```
## 每阶段完成标准
| 阶段 | 完成标准 |
| --- | --- |
| service 拆分 | `service.go` 只保留 Service 结构、构造和依赖 setter`Repository` 变成嵌入式能力接口service 单测通过 |
| transport 拆分 | `server.go` 不再承载大批业务 handlermapper 按业务域归位transport 单测通过 |
| storage 核心拆分 | `repository.go` 小于 250 行;核心账本 helper 有明确文件wallet storage 单测通过 |
| resource storage 拆分 | `resource_repository.go` 小于 500 行;资源目录、发放、穿戴、商城各自可定位;资源 storage 单测通过 |
| app 拆分 | `app.go` 只负责生命周期worker 文件按 MQ/outbox/projection/reconcile/expiry 分开wallet-service 全包测试通过 |
最终验证:
```bash
go test ./services/wallet-service/...
make test
```
如果 `make test` 暴露其他服务或既有配置失败,需要单独记录为基线问题,不能把它当成钱包拆分失败,也不能因为无关失败回滚已验证的 wallet-service 拆分。
## 风险点
| 风险 | 规避方式 |
| --- | --- |
| 事务 helper 被跨 package 导出 | 第一阶段 storage 保持同 package只拆文件 |
| request hash 或 biz type 和原逻辑不一致 | 每个业务域移动时把 command、metadata、request hash、receipt、outbox event 一起移动 |
| service 接口拆完但测试 mock 更复杂 | 总 `Repository` 保持嵌入式组合,测试按用例只依赖小接口 |
| transport 拆分后 mapper 找不到 | handler 和 mapper 同域移动,通用 mapper 留在 `server.go``mapper.go` |
| app worker 拆早导致来回改 | app 层最后拆,等 service 方法和 storage outbox 边界稳定 |
| 背包送礼跨 resource 和 gift ledger | `consumeGiftEntitlementForSend` 先留在 gift ledger 同 package第二阶段再决定是否抽资源权益端口 |
## 不建议做的事
1. 不要第一步创建多个 MySQL repository 实例。账务事务必须共享同一个连接池和同一个事务提交边界。
2. 不要让 `room-service``activity-service``notice-service` 直连钱包库查询交易明细。
3. 不要把 outbox 发布状态交给下游服务更新。wallet outbox 位点仍归 wallet-service。
4. 不要在 transport 层补业务幂等、扣费判断或支付状态机。
5. 不要为了拆目录改变 protobuf 字段或 gRPC 方法名;这是结构整理,不是协议重构。

View File

@ -35,7 +35,7 @@ func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walle
}
}
// TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格,且礼物积分下线后不再给收礼人入账
// TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格,收礼收入进入 COIN历史 GIFT_POINT 仍保持下线
func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
@ -76,11 +76,21 @@ func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) {
if balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
t.Fatalf("target GIFT_POINT must stay zero after point removal: %+v", balances)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
t.Fatalf("gift debit should only write sender COIN entry, got %d", got)
balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances target COIN failed: %v", err)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 2 {
t.Fatalf("gift debit should write sender balance and gift outbox events, got %d", got)
if balanceAmount(balances, ledger.AssetCoin) != 4 {
t.Fatalf("target COIN income mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
t.Fatalf("gift debit should write sender debit and target income entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 4 {
t.Fatalf("gift debit should write sender balance, target balance, debit and income outbox events, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
t.Fatalf("gift debit should publish target income fact, got %d", got)
}
if receipt.HostPeriodDiamondAdded != 0 || receipt.HostPeriodCycleKey != "" {
t.Fatalf("non-host gift must not credit host period diamonds: %+v", receipt)
@ -125,6 +135,13 @@ func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
if balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
t.Fatalf("target %d GIFT_POINT must stay zero after point removal: %+v", userID, balances)
}
balances, err = svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances target COIN %d failed: %v", userID, err)
}
if balanceAmount(balances, ledger.AssetCoin) != 4 {
t.Fatalf("target %d COIN income mismatch: %+v", userID, balances)
}
}
balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin})
if err != nil {
@ -136,8 +153,8 @@ func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003"); got != 2 {
t.Fatalf("batch gift should write one transaction per target, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id IN (?, ?)", receipt.Targets[0].Receipt.TransactionID, receipt.Targets[1].Receipt.TransactionID); got != 2 {
t.Fatalf("batch gift should only write sender COIN entries per target, got %d", got)
if got := repository.CountRows("wallet_entries", "transaction_id IN (?, ?)", receipt.Targets[0].Receipt.TransactionID, receipt.Targets[1].Receipt.TransactionID); got != 4 {
t.Fatalf("batch gift should write sender debit and target income entries per target, got %d", got)
}
again, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
@ -159,6 +176,15 @@ func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
}
for _, userID := range []int64{10002, 10003} {
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances target COIN %d after replay failed: %v", userID, err)
}
if balanceAmount(balances, ledger.AssetCoin) != 4 {
t.Fatalf("batch replay must not duplicate target income for %d: %+v", userID, balances)
}
}
}
// TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。
@ -273,17 +299,19 @@ func TestDebitGiftAppliesGlobalDefaultGiftDiamondRatioToHeatValueByGiftType(t *t
repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeSuperLucky, "50.00")
cases := []struct {
giftID string
giftType string
command string
senderID int64
coinPrice int64
heatUnit int64
wantHeat int64
giftID string
giftType string
command string
senderID int64
targetID int64
coinPrice int64
heatUnit int64
wantHeat int64
wantIncome int64
}{
{giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, coinPrice: 100, heatUnit: 999, wantHeat: 100},
{giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, coinPrice: 100, heatUnit: 999, wantHeat: 10},
{giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, coinPrice: 200, heatUnit: 500, wantHeat: 2},
{giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, targetID: 14001, coinPrice: 100, heatUnit: 999, wantHeat: 100, wantIncome: 30},
{giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, targetID: 14002, coinPrice: 100, heatUnit: 999, wantHeat: 10, wantIncome: 10},
{giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, targetID: 14003, coinPrice: 200, heatUnit: 500, wantHeat: 2, wantIncome: 2},
}
for _, tc := range cases {
@ -296,7 +324,7 @@ func TestDebitGiftAppliesGlobalDefaultGiftDiamondRatioToHeatValueByGiftType(t *t
SenderUserID: tc.senderID,
SenderRegionID: 1001,
RegionID: 8801,
TargetUserID: 14001,
TargetUserID: tc.targetID,
GiftID: tc.giftID,
GiftCount: 1,
PriceVersion: "v1",
@ -307,6 +335,13 @@ func TestDebitGiftAppliesGlobalDefaultGiftDiamondRatioToHeatValueByGiftType(t *t
if receipt.HeatValue != tc.wantHeat || receipt.GiftPointAdded != 0 || receipt.CoinSpent != tc.coinPrice {
t.Fatalf("%s room contribution ratio mismatch: %+v want heat %d", tc.giftType, receipt, tc.wantHeat)
}
balances, err := svc.GetBalances(context.Background(), tc.targetID, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances target income %s failed: %v", tc.giftType, err)
}
if balanceAmount(balances, ledger.AssetCoin) != tc.wantIncome {
t.Fatalf("%s target income mismatch: got %+v want %d", tc.giftType, balances, tc.wantIncome)
}
}
}
@ -1095,7 +1130,7 @@ func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) {
}
}
// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼;礼物积分下线后只扣 COIN不再回加 GIFT_POINT
// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼;同一 COIN 账户保留扣费和收礼收入两条分录,但余额事件只发净变更
func TestDebitGiftAllowsSelfGift(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(10001, 100)
@ -1115,18 +1150,27 @@ func TestDebitGiftAllowsSelfGift(t *testing.T) {
t.Fatalf("self DebitGift failed: %v", err)
}
if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.BalanceAfter != 86 {
if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.BalanceAfter != 90 {
t.Fatalf("self gift settlement mismatch: %+v", receipt)
}
balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 86 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
if balanceAmount(balances, ledger.AssetCoin) != 90 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
t.Fatalf("self gift balances mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
t.Fatalf("self gift should only write debit entry, got %d", got)
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
t.Fatalf("self gift should write debit and income entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletBalanceChanged"); got != 1 {
t.Fatalf("self gift should publish one net balance event, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ?", receipt.TransactionID, "WalletBalanceChanged", int64(-10)); got != 1 {
t.Fatalf("self gift balance event should carry net -10 delta, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
t.Fatalf("self gift should still publish income fact, got %d", got)
}
}
@ -1305,6 +1349,9 @@ func TestDebitRobotGiftCreatesRobotCoinAccountAndAutoTopsUp(t *testing.T) {
if got := repository.CountRows("wallet_accounts", "user_id = ? AND asset_type = ? AND available_amount = 0", int64(20001), ledger.AssetRobotCoin); got != 1 {
t.Fatalf("robot gift should create empty ROBOT_COIN account after debit, got %d", got)
}
if got := repository.CountRows("wallet_accounts", "user_id = ? AND asset_type = ?", int64(20002), ledger.AssetCoin); got != 0 {
t.Fatalf("robot gift must not create target COIN income account, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(20001), ledger.AssetRobotCoin); got != 2 {
t.Fatalf("robot gift should write top-up and debit entries, got %d", got)
}
@ -3078,12 +3125,22 @@ func TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit(t *testing.T) {
if quantity != 3 || remaining != 1 {
t.Fatalf("bag gift entitlement quantity mismatch: quantity=%d remaining=%d", quantity, remaining)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 0 {
t.Fatalf("bag gift must not write COIN wallet entries, got %d", got)
balances, err := svc.GetBalances(ctx, 61002, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances bag gift target failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 3 {
t.Fatalf("bag gift target income mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
t.Fatalf("bag gift must only write target COIN income entry, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftDebited"); got != 1 {
t.Fatalf("bag gift must still publish WalletGiftDebited for normal room projections, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
t.Fatalf("bag gift must publish target income fact, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", "cmd-send-bag-spark", "UserResourceChanged"); got != 1 {
t.Fatalf("bag gift must publish resource change event, got %d", got)
}

View File

@ -56,6 +56,8 @@ const (
giftWallChargeAssetMixed = "MIXED"
giftChargeSourceCoin = "coin"
giftChargeSourceBag = "bag"
normalGiftIncomeRatioPercent = "30.00"
normalGiftIncomeRatioPPM = 300_000
)
// Repository 是 wallet-service 的 MySQL v2 账本入口。
@ -447,6 +449,15 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err != nil {
return ledger.Receipt{}, err
}
giftIncomeRatio, giftIncomeRatioRegionID := giftIncomeRatio(giftConfig.GiftTypeCode, roomContributionRatio, roomContributionRatioRegionID)
giftIncomeCoinAmount := int64(0)
if !command.RobotGift {
// 收礼金币收入只属于真人送礼经济账;背包礼物也按礼物服务端价值发放,机器人礼物继续只服务房间展示。
giftIncomeCoinAmount, err = giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM)
if err != nil {
return ledger.Receipt{}, err
}
}
hostPeriodDiamondAdded := int64(0)
hostPeriodCycleKey := ""
giftDiamondRatioPercent := "0.00"
@ -466,16 +477,42 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
}
// 机器人礼物扣的是展示专用 ROBOT_COIN本地或新机器人账号第一次送礼时可能还没有
// 该资产账户,必须先创建空账户,再由下方 robotCoinTopUp 按本次价格补足并立即扣减。
accountAssetType := chargeAssetType
if chargeSource == giftChargeSourceBag {
accountAssetType = ledger.AssetCoin
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, command.RobotGift || chargeAmount == 0 || chargeSource == giftChargeSourceBag, nowMs)
if err != nil {
return ledger.Receipt{}, err
var senderState *walletAccountState
var targetCoinState *walletAccountState
if command.RobotGift {
// 机器人礼物扣的是展示专用 ROBOT_COIN它不产生收礼 COIN 收入,因此继续沿用单账户锁定路径。
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, true, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
senderState = &walletAccountState{account: sender}
} else {
lockRequests := []walletAccountLockRequest{{
UserID: command.SenderUserID,
AssetType: accountAssetType,
CreateIfMissing: chargeAmount == 0 || chargeSource == giftChargeSourceBag,
}}
if giftIncomeCoinAmount > 0 {
lockRequests = append(lockRequests, walletAccountLockRequest{
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
CreateIfMissing: true,
})
}
states, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
senderState = states[walletAccountLockKey(command.SenderUserID, accountAssetType)]
if giftIncomeCoinAmount > 0 {
targetCoinState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetCoin)]
}
}
sender := senderState.account
robotCoinTopUp := int64(0)
if command.RobotGift && sender.AvailableAmount < chargeAmount {
// 机器人房间使用专用 ROBOT_COIN 做展示账务,按次补足后立刻扣减,不要求真实充值预算。
@ -489,6 +526,18 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
// Bag 礼物不扣 COINbalance_after 只给客户端刷新余额使用,必须保持当前 COIN 余额。
senderAfter = sender.AvailableAmount
}
giftIncomeBalanceAfter := int64(0)
if giftIncomeCoinAmount > 0 {
if targetCoinState == nil {
return ledger.Receipt{}, xerr.New(xerr.Internal, "gift income account is missing")
}
giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount
if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag {
// 自己送自己时同一个 COIN 账户先扣礼物价值、再加收礼收入;回执余额必须反映最终净变更。
giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount
senderAfter = giftIncomeBalanceAfter
}
}
transactionID := transactionID(command.AppCode, command.CommandID)
metadata := giftMetadata{
AppCode: command.AppCode,
@ -514,6 +563,10 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: senderAfter,
GiftIncomeCoinAmount: giftIncomeCoinAmount,
GiftIncomeRatioPercent: giftIncomeRatio.Percent,
GiftIncomeRatioRegionID: giftIncomeRatioRegionID,
GiftIncomeBalanceAfter: giftIncomeBalanceAfter,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
@ -537,20 +590,20 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.Receipt{}, err
}
events := make([]walletOutboxEvent, 0, 3)
events := make([]walletOutboxEvent, 0, 5)
if robotCoinTopUp > 0 {
if err := r.applyAccountDelta(ctx, tx, sender, robotCoinTopUp, 0, nowMs); err != nil {
topUpAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, robotCoinTopUp, 0, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
topUpAfter := sender.AvailableAmount + robotCoinTopUp
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: chargeAssetType,
AvailableDelta: robotCoinTopUp,
FrozenDelta: 0,
AvailableAfter: topUpAfter,
FrozenAfter: sender.FrozenAmount,
AvailableAfter: topUpAccount.AvailableAmount,
FrozenAfter: topUpAccount.FrozenAmount,
CounterpartyUserID: 0,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
@ -559,8 +612,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
}
// 自动补足只作为机器人展示账本的内部分录;对外余额事件只发布最终扣减后的余额,
// 避免同一 transaction/user/asset 生成两个 WalletBalanceChanged 事件 ID。
sender.AvailableAmount = topUpAfter
sender.Version++
sender = senderState.account
}
if chargeSource == giftChargeSourceBag {
resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, command, giftConfig.ResourceID, int64(command.GiftCount), metadata, nowMs)
@ -569,7 +621,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
}
events = append(events, resourceEvent)
} else {
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
@ -578,8 +631,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
AssetType: chargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
AvailableAfter: debitAccount.AvailableAmount,
FrozenAfter: debitAccount.FrozenAmount,
CounterpartyUserID: command.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
@ -587,6 +640,35 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
return ledger.Receipt{}, err
}
}
if giftIncomeCoinAmount > 0 {
incomeState := targetCoinState
if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
// 自送同一个 COIN 账户时,收入必须基于扣费后的版本继续加账,避免用锁定初始版本写出版本冲突。
incomeState = senderState
}
incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount
if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
metadata.BalanceAfter = incomeAccount.AvailableAmount
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: giftIncomeCoinAmount,
FrozenDelta: 0,
AvailableAfter: incomeAccount.AvailableAmount,
FrozenAfter: incomeAccount.FrozenAmount,
CounterpartyUserID: command.SenderUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.Receipt{}, err
}
}
if hostPeriodDiamondAdded > 0 {
// 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币和主播工资周期钻石会一起回滚。
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs)
@ -600,9 +682,19 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if !command.RobotGift {
// 机器人礼物只服务房间展示,不需要客户端余额私信、礼物墙投影或真实消费类下游事件。
if chargeSource != giftChargeSourceBag {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
if command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
} else {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
}
}
if giftIncomeCoinAmount > 0 && !(command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs))
}
events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
if giftIncomeCoinAmount > 0 {
events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
}
}
if hostPeriodDiamondAdded > 0 {
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
@ -668,6 +760,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
giftIncomeRatio, giftIncomeRatioRegionID := giftIncomeRatio(giftConfig.GiftTypeCode, roomContributionRatio, roomContributionRatioRegionID)
giftIncomeCoinAmount, err := giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
totalChargeAmount, err := checkedMul(chargeAmount, int64(len(command.Targets)))
if err != nil {
return ledger.BatchGiftReceipt{}, err
@ -715,14 +812,30 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if chargeSource == giftChargeSourceBag {
accountAssetType = ledger.AssetCoin
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, totalChargeAmount == 0 || chargeSource == giftChargeSourceBag, nowMs)
lockRequests := []walletAccountLockRequest{{
UserID: command.SenderUserID,
AssetType: accountAssetType,
CreateIfMissing: totalChargeAmount == 0 || chargeSource == giftChargeSourceBag,
}}
if giftIncomeCoinAmount > 0 {
for _, target := range command.Targets {
lockRequests = append(lockRequests, walletAccountLockRequest{
UserID: target.TargetUserID,
AssetType: ledger.AssetCoin,
CreateIfMissing: true,
})
}
}
accountStates, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
senderState := accountStates[walletAccountLockKey(command.SenderUserID, accountAssetType)]
sender := senderState.account
if chargeSource != giftChargeSourceBag && sender.AvailableAmount < totalChargeAmount {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
events := make([]walletOutboxEvent, 0, len(command.Targets)*3+1)
events := make([]walletOutboxEvent, 0, len(command.Targets)*5+1)
if chargeSource == giftChargeSourceBag {
resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, debitGiftCommandFromBatchCommand(command), giftConfig.ResourceID, int64(command.GiftCount)*int64(len(command.Targets)), giftMetadata{
AppCode: command.AppCode,
@ -776,10 +889,23 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
}
transactionID := transactionID(command.AppCode, target.CommandID)
senderAfter := sender.AvailableAmount - chargeAmount
senderAfter := senderState.account.AvailableAmount - chargeAmount
if chargeSource == giftChargeSourceBag {
// Bag 批量送礼按总数量扣库存COIN 余额不变;每个目标回执仍保留原礼物价格,供房间贡献和麦位热度复用。
senderAfter = sender.AvailableAmount
senderAfter = senderState.account.AvailableAmount
}
giftIncomeBalanceAfter := int64(0)
targetCoinState := accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetCoin)]
if giftIncomeCoinAmount > 0 {
if targetCoinState == nil {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "gift income account is missing")
}
giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount
if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
// 批量里如果包含自己,当前目标的回执余额必须体现本目标扣费和收入后的净结果。
giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount
senderAfter = giftIncomeBalanceAfter
}
}
metadata := giftMetadata{
AppCode: command.AppCode,
@ -805,6 +931,10 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: senderAfter,
GiftIncomeCoinAmount: giftIncomeCoinAmount,
GiftIncomeRatioPercent: giftIncomeRatio.Percent,
GiftIncomeRatioRegionID: giftIncomeRatioRegionID,
GiftIncomeBalanceAfter: giftIncomeBalanceAfter,
BillingReceipt: billingReceiptID(command.AppCode, target.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
@ -828,21 +958,18 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{}, err
}
if chargeSource != giftChargeSourceBag {
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
sender.AvailableAmount = senderAfter
sender.Version++
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: price.ChargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
AvailableAfter: debitAccount.AvailableAmount,
FrozenAfter: debitAccount.FrozenAmount,
CounterpartyUserID: target.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
@ -850,6 +977,35 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{}, err
}
}
if giftIncomeCoinAmount > 0 {
incomeState := targetCoinState
if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
// 自送目标复用 senderState 的当前版本,保证同一个 COIN 账户的扣费和收入顺序可审计、可重放。
incomeState = senderState
}
incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount
if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
metadata.BalanceAfter = incomeAccount.AvailableAmount
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: target.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: giftIncomeCoinAmount,
FrozenDelta: 0,
AvailableAfter: incomeAccount.AvailableAmount,
FrozenAfter: incomeAccount.FrozenAmount,
CounterpartyUserID: command.SenderUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.BatchGiftReceipt{}, err
}
}
if hostPeriodDiamondAdded > 0 {
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs)
if err != nil {
@ -859,7 +1015,20 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
}
if chargeSource != giftChargeSourceBag {
if target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
} else {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
}
}
if giftIncomeCoinAmount > 0 && !(target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs))
}
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
if giftIncomeCoinAmount > 0 {
events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
if hostPeriodDiamondAdded > 0 {
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
@ -2340,6 +2509,77 @@ type walletAccount struct {
UpdatedAtMS int64
}
type walletAccountLockRequest struct {
UserID int64
AssetType string
CreateIfMissing bool
}
type walletAccountState struct {
account walletAccount
}
func walletAccountLockKey(userID int64, assetType string) string {
return fmt.Sprintf("%s:%d", strings.ToUpper(strings.TrimSpace(assetType)), userID)
}
func (r *Repository) lockAccountsOrdered(ctx context.Context, tx *sql.Tx, requests []walletAccountLockRequest, nowMs int64) (map[string]*walletAccountState, error) {
merged := make(map[string]walletAccountLockRequest, len(requests))
for _, request := range requests {
request.AssetType = ledger.NormalizeGiftChargeAssetType(request.AssetType)
if request.UserID <= 0 || strings.TrimSpace(request.AssetType) == "" {
return nil, xerr.New(xerr.InvalidArgument, "wallet account lock request is invalid")
}
key := walletAccountLockKey(request.UserID, request.AssetType)
if existing, ok := merged[key]; ok {
// 同一事务内同一用户同一资产只锁一次;任一调用方需要自动建账时必须保留 createIfMissing
// 否则自送或背包送礼会因为去重丢掉收礼账户初始化语义。
existing.CreateIfMissing = existing.CreateIfMissing || request.CreateIfMissing
merged[key] = existing
continue
}
merged[key] = request
}
ordered := make([]walletAccountLockRequest, 0, len(merged))
for _, request := range merged {
ordered = append(ordered, request)
}
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].AssetType == ordered[j].AssetType {
return ordered[i].UserID < ordered[j].UserID
}
return ordered[i].AssetType < ordered[j].AssetType
})
states := make(map[string]*walletAccountState, len(ordered))
for _, request := range ordered {
account, err := r.lockAccount(ctx, tx, request.UserID, request.AssetType, request.CreateIfMissing, nowMs)
if err != nil {
return nil, err
}
states[walletAccountLockKey(request.UserID, request.AssetType)] = &walletAccountState{account: account}
}
return states, nil
}
func (r *Repository) applyTrackedAccountDelta(ctx context.Context, tx *sql.Tx, state *walletAccountState, availableDelta int64, frozenDelta int64, nowMs int64) (walletAccount, error) {
if state == nil {
return walletAccount{}, xerr.New(xerr.Internal, "wallet account state is missing")
}
before := state.account
if err := r.applyAccountDelta(ctx, tx, before, availableDelta, frozenDelta, nowMs); err != nil {
return walletAccount{}, err
}
after := before
after.AvailableAmount += availableDelta
after.FrozenAmount += frozenDelta
after.Version++
after.UpdatedAtMS = nowMs
state.account = after
return after, nil
}
func (r *Repository) lockAccount(ctx context.Context, tx *sql.Tx, userID int64, assetType string, createIfMissing bool, nowMs int64) (walletAccount, error) {
account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType)
if err != nil || exists || !createIfMissing {
@ -2547,6 +2787,17 @@ func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) {
return product / 1_000_000, nil
}
func giftIncomeRatio(giftTypeCode string, configuredRatio giftDiamondRatioSnapshot, configuredRegionID int64) (giftDiamondRatioSnapshot, int64) {
switch strings.TrimSpace(giftTypeCode) {
case resourcedomain.GiftTypeLucky, resourcedomain.GiftTypeSuperLucky:
// 幸运和超级幸运礼物继续使用后台全局比例,默认分别是 10% 和 1%;这里不复用普通礼物新增的 30% 收礼收入。
return configuredRatio, configuredRegionID
default:
// 普通礼物收礼金币收入是本次新增固定口径;它不影响房间热度和主播周期钻石的后台配置比例。
return giftDiamondRatioSnapshot{Percent: normalGiftIncomeRatioPercent, PPM: normalGiftIncomeRatioPPM}, 0
}
}
func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot {
switch strings.TrimSpace(giftTypeCode) {
case "lucky":
@ -3322,6 +3573,10 @@ type giftMetadata struct {
GiftPointAdded int64 `json:"gift_point_added"`
HeatValue int64 `json:"heat_value"`
BalanceAfter int64 `json:"balance_after"`
GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"`
GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"`
GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,omitempty"`
GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after,omitempty"`
BillingReceipt string `json:"billing_receipt_id"`
SenderUserID int64 `json:"sender_user_id"`
SenderRegionID int64 `json:"sender_region_id"`
@ -4277,6 +4532,37 @@ func giftDebitedEvent(transactionID string, commandID string, userID int64, asse
}
}
func giftIncomeCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletGiftIncomeCredited", metadata.TargetUserID, ledger.AssetCoin),
EventType: "WalletGiftIncomeCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.GiftIncomeCoinAmount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": metadata.TargetUserID,
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"gift_type_code": metadata.GiftTypeCode,
"gift_charge_amount": metadata.ChargeAmount,
"charge_source": metadata.ChargeSource,
"amount": metadata.GiftIncomeCoinAmount,
"ratio_percent": metadata.GiftIncomeRatioPercent,
"ratio_region_id": metadata.GiftIncomeRatioRegionID,
"gift_income_balance_after": metadata.GiftIncomeBalanceAfter,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond),

View File

@ -168,7 +168,7 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.
return resource, nil
}
// UpdateResource 全量更新资源配置;当前开发阶段不做兼容性合并。
// UpdateResource 全量更新资源配置;
func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
if r == nil || r.db == nil {
return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")