feat: remove generic USD wallet balance

This commit is contained in:
zhx 2026-06-03 00:02:19 +08:00
parent 858b0993bc
commit 679d068a82
27 changed files with 500 additions and 1117 deletions

File diff suppressed because it is too large Load Diff

View File

@ -734,7 +734,6 @@ message ListRechargeBillsResponse {
message WalletFeatureFlags {
bool recharge_enabled = 1;
bool diamond_exchange_enabled = 2;
bool withdraw_enabled = 3;
}
message GetWalletOverviewRequest {
@ -976,32 +975,6 @@ message ListWalletTransactionsResponse {
int64 total = 2;
}
message WithdrawalRequest {
string withdrawal_id = 1;
int64 user_id = 2;
string asset_type = 3;
int64 amount = 4;
string status = 5;
string payout_account = 6;
string reason = 7;
int64 created_at_ms = 8;
int64 updated_at_ms = 9;
}
message ApplyWithdrawalRequest {
string command_id = 1;
string app_code = 2;
int64 user_id = 3;
int64 amount = 4;
string payout_account = 5;
string reason = 6;
}
message ApplyWithdrawalResponse {
WithdrawalRequest withdrawal = 1;
AssetBalance balance = 2;
}
message VipRewardItem {
int64 resource_id = 1;
string resource_code = 2;
@ -1437,7 +1410,6 @@ service WalletService {
rpc DeleteRechargeProduct(DeleteRechargeProductRequest) returns (DeleteRechargeProductResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ApplyWithdrawal(ApplyWithdrawalRequest) returns (ApplyWithdrawalResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);

View File

@ -243,7 +243,6 @@ const (
WalletService_DeleteRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteRechargeProduct"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ApplyWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyWithdrawal"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
@ -311,7 +310,6 @@ type WalletServiceClient interface {
DeleteRechargeProduct(ctx context.Context, in *DeleteRechargeProductRequest, opts ...grpc.CallOption) (*DeleteRechargeProductResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, in *ApplyWithdrawalRequest, opts ...grpc.CallOption) (*ApplyWithdrawalResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
@ -759,16 +757,6 @@ func (c *walletServiceClient) ListWalletTransactions(ctx context.Context, in *Li
return out, nil
}
func (c *walletServiceClient) ApplyWithdrawal(ctx context.Context, in *ApplyWithdrawalRequest, opts ...grpc.CallOption) (*ApplyWithdrawalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyWithdrawalResponse)
err := c.cc.Invoke(ctx, WalletService_ApplyWithdrawal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListVipPackagesResponse)
@ -987,7 +975,6 @@ type WalletServiceServer interface {
DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ApplyWithdrawal(context.Context, *ApplyWithdrawalRequest) (*ApplyWithdrawalResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
@ -1141,9 +1128,6 @@ func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context
func (UnimplementedWalletServiceServer) ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListWalletTransactions not implemented")
}
func (UnimplementedWalletServiceServer) ApplyWithdrawal(context.Context, *ApplyWithdrawalRequest) (*ApplyWithdrawalResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyWithdrawal not implemented")
}
func (UnimplementedWalletServiceServer) ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListVipPackages not implemented")
}
@ -1972,24 +1956,6 @@ func _WalletService_ListWalletTransactions_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _WalletService_ApplyWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyWithdrawalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ApplyWithdrawal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ApplyWithdrawal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ApplyWithdrawal(ctx, req.(*ApplyWithdrawalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListVipPackages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListVipPackagesRequest)
if err := dec(in); err != nil {
@ -2471,10 +2437,6 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListWalletTransactions",
Handler: _WalletService_ListWalletTransactions_Handler,
},
{
MethodName: "ApplyWithdrawal",
Handler: _WalletService_ApplyWithdrawal_Handler,
},
{
MethodName: "ListVipPackages",
Handler: _WalletService_ListVipPackages_Handler,

View File

@ -138,7 +138,7 @@ GET /api/v1/wallet/me/balances
示例:
```text
GET /api/v1/wallet/me/balances?asset_type=COIN&asset_type=DIAMOND&asset_type=USD_BALANCE
GET /api/v1/wallet/me/balances?asset_type=COIN&asset_type=DIAMOND&asset_type=HOST_SALARY_USD&asset_type=AGENCY_SALARY_USD&asset_type=BD_SALARY_USD&asset_type=ADMIN_SALARY_USD
```
### 返回值 `data`
@ -158,7 +158,7 @@ GET /api/v1/wallet/me/balances?asset_type=COIN&asset_type=DIAMOND&asset_type=USD
| 字段 | 类型 | 说明 |
| ------------------ | ------ | ------------------------------------------------- |
| `asset_type` | string | `COIN` / `DIAMOND` / `USD_BALANCE` / `GIFT_POINT` |
| `asset_type` | string | `COIN` / `DIAMOND` / `HOST_SALARY_USD` / `AGENCY_SALARY_USD` / `BD_SALARY_USD` / `ADMIN_SALARY_USD` / `GIFT_POINT` |
| `available_amount` | int64 | 可用余额 |
| `frozen_amount` | int64 | 冻结余额 |
| `version` | int64 | 该资产余额版本,用于和 IM 通知去重/防乱序 |
@ -201,7 +201,7 @@ GET /api/v1/wallet/me/overview
"version": 4
},
{
"asset_type": "USD_BALANCE",
"asset_type": "HOST_SALARY_USD",
"available_amount": 1500,
"frozen_amount": 0,
"version": 2
@ -209,8 +209,7 @@ GET /api/v1/wallet/me/overview
],
"feature_flags": {
"recharge_enabled": true,
"diamond_exchange_enabled": true,
"withdraw_enabled": true
"diamond_exchange_enabled": true
}
}
```
@ -220,7 +219,6 @@ GET /api/v1/wallet/me/overview
| `balances` | array | 钱包首页需要展示的资产余额 |
| `feature_flags.recharge_enabled` | bool | 是否允许充值 |
| `feature_flags.diamond_exchange_enabled` | bool | 是否允许钻石兑换 |
| `feature_flags.withdraw_enabled` | bool | 是否允许提现 |
### 可能错误

View File

@ -2,7 +2,7 @@
本文只定义后台/Admin 侧实现政策配置、工资周期、工资单、审核、钱包入账、调整单、后台审计和运营干预。App 侧的主播申请、Agency 成员、BD 邀请和用户可见查询见 [Host Agency BD App Architecture](./主播公会BD架构.md)。
这里的工资统一指发放到主播侧 `USD_BALANCE` 的业务奖励。提现审核和人工打款仍由 `wallet-service` 拥有Admin 只负责把审核通过的工资单入账到工资余额,不直接处理线下打款。
这里的工资统一指发放到主播侧 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 的业务奖励。提现审核和人工打款仍由 `wallet-service` 拥有Admin 只负责把审核通过的工资单入账到工资余额,不直接处理线下打款。
本文不新增独立 `host-service` 微服务。host、Agency、BD、政策和工资后台能力属于 user-services代码落在当前仓库的 `services/user-service` host domain。
@ -11,7 +11,7 @@
- 后台可以配置 host、agency、bd 三类工资政策,并按区域生效。
- 后台可以选择日结、周结、半月结、月结,不把结算周期写死在代码里。
- 后台可以生成工资周期,查看每个 host/Agency/BD/BD Leader 的明细来源。
- 后台审批后,`user-service` host domain 以幂等方式调用 `wallet-service``USD_BALANCE` 入账。
- 后台审批后,`user-service` host domain 以幂等方式调用 `wallet-service``HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 入账。
- 后台可以对迟到事件、人工修正、争议处理创建调整单,不能直接修改已入账工资项。
- 后台可以创建或停用 BD Leader、BD、Agency也可以关闭 Agency后台可以查看和新增币商 `coin_seller`;所有动作必须有审计记录。
@ -49,7 +49,7 @@ graph LR
| `hyapp-admin-server` | Admin HTTP 入口、后台鉴权、request envelope、后台操作审计使用独立 `hyapp_admin` 库 |
| `user-service` | 用户、区域、账号状态查询host/Agency/BD/coin_seller 政策、周期、工资单、关系管理、统计聚合、幂等结果、领域 outboxApp/客户端不能提交区域,后台关系命令的 `region_id` 只能来自 `hyapp-admin-server` 鉴权审计入口 |
| `room-service` | 只产出上麦和礼物事件,不接收后台工资命令 |
| `wallet-service` | `COIN``COIN_SELLER_COIN``USD_BALANCE` 入账、提现冻结、提现审核状态和钱包流水 |
| `wallet-service` | `COIN``COIN_SELLER_COIN``HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 入账、提现冻结、提现审核状态和钱包流水 |
## Admin Permission Model
@ -188,7 +188,7 @@ flowchart LR
E --> F
F --> G["approve"]
G --> H["wallet CreditSalaryBalance"]
H --> I["USD_BALANCE"]
H --> I["identity salary wallet"]
```
```sql
@ -353,7 +353,7 @@ Leader 直接拥有 Agency 下线时,推荐快照同时设置 `bd_user_id = le
1. Admin 审批 cycle。
2. `user-service` host domain 查询所有 `approved` salary items。
3. 对每个 item 调用 `wallet-service CreditSalaryBalance`,幂等键为 `salary:{salary_item_id}`
4. `wallet-service` credit `USD_BALANCE` 并返回 `wallet_transaction_id`
4. `wallet-service` credit `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 并返回 `wallet_transaction_id`
5. `user-service` host domain 保存 `wallet_transaction_id`,把 item 标记为 `posted`
6. 如果中途失败,只重试未 posted item幂等键保持不变。
@ -488,7 +488,7 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
| Host changes Agency during cycle | Salary uses `host_daily_stats` relation snapshot and can split across agencies |
| Late room event after posted cycle | Create adjustment item; do not mutate posted salary item |
| Wallet posting partially fails | Retry unposted items with same idempotency key |
| Admin accidentally posts twice | Wallet idempotency must prevent duplicate `USD_BALANCE` credit |
| Admin accidentally posts twice | Wallet idempotency must prevent duplicate `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` credit |
| Agency closed mid-cycle | Work before close remains attributable; future App joins/search stop immediately |
| User region changed | Existing cycle uses event snapshots; new policy/cycle matching follows stats region |
| Negative adjustment exceeds balance | Move to manual handling; do not force wallet negative unless wallet explicitly supports debt |
@ -507,7 +507,7 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
| Agency salary generated | Base uses host salary items under that Agency only |
| BD Leader owns direct Agency | Source host salary counted once for the leader |
| BD under Leader owns Agency | Host salary contributes to BD and leader according to snapshot |
| Cycle posted twice | Wallet idempotency prevents duplicate `USD_BALANCE` credit |
| Cycle posted twice | Wallet idempotency prevents duplicate `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` credit |
| Posting fails after partial success | Retry posts only unposted items |
| Posted item needs correction | Adjustment item created; original item remains immutable |
| Admin closes Agency | Agency hidden from App search; existing facts remain auditable |
@ -515,7 +515,7 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
## Critical Rules
- Admin owns policies and salary cycles; App owns user-initiated relationship flows.
- Wallet is the only owner of `USD_BALANCE` and withdrawal state.
- Wallet is the only owner of `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` and withdrawal state.
- Posted salary items are immutable.
- BD/BD Leader base is downstream host salary only.
- Leader direct Agency salary source must be de-duplicated.

View File

@ -142,7 +142,7 @@
- Agency salary基于下属 host salary item 或明确配置的 agency 指标。
- BD/BD Leader salary基于 downstream host salary sum且按 source host salary item 去重。
- 后台审核工资周期。
- 调用 `wallet-service``USD_BALANCE` 入账,幂等键为 `salary:{salary_item_id}`
- 调用 `wallet-service``HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 入账,幂等键为 `salary:{salary_item_id}`
- 支持调整单:迟到事件、人工修正、争议处理都走补差或冲正。
- Salary posted 和 adjustment posted 事件接入 App message inbox通知接收人查看收益明细通知失败不影响钱包入账事实。
@ -182,7 +182,7 @@
| M3 | Phase 3 | 用户申请、审核、踢出、重新申请、邀请链路完整跑通 |
| M3.5 | Phase 3.5 | 申请、邀请、后台创建关系结果能进入 App 系统消息 |
| M4 | Phase 4 | 有效上麦和礼物统计按关系快照落库 |
| M5 | Phase 5 | 后台生成工资单,审批后入账 `USD_BALANCE` |
| M5 | Phase 5 | 后台生成工资单,审批后入账 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` |
| M6 | Phase 6 | 失败补偿、导出、重建和监控可用 |
## Stop Conditions
@ -191,7 +191,7 @@
- Phase 2 没有完成前,不做 App Agency 搜索上线,因为没有可信组织种子数据。
- Phase 3 没有完成前,不做工资发放,因为 host/Agency/BD 归属还不稳定。
- Phase 4 没有完成前,不做真实工资入账,因为缺少可审计统计输入。
- Phase 5 没有完成审批和幂等 posting 前,不允许写 `USD_BALANCE`。
- Phase 5 没有完成审批和幂等 posting 前,不允许写 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`。
## Verification Commands

View File

@ -16,7 +16,7 @@
- BD 默认不是主播BD 可以加入别的 Agency 成为主播,也可以在未成为主播时邀请自己成为自己的 Agency。
- `coin_seller` 是独立币商身份,可以和 Agency、BD、BD Leader 身份并存;币商拥有专用金币账户,可以给玩家转普通金币。
- App 侧可以展示 host 统计、Agency 成员、申请、邀请和后台已经生成的收益结果。
- App 侧不能创建政策、计算工资周期、审核工资单或直接给 `USD_BALANCE` 入账。
- App 侧不能创建政策、计算工资周期、审核工资单或直接给 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 入账。
## Non-Goals
@ -67,7 +67,7 @@ graph LR
| `gateway-service` | HTTP 入口、鉴权、request envelope、调用内部 gRPC |
| `user-service` | 用户主数据、国家、区域、账号状态host 身份、Agency、BD、coin_seller 层级、申请邀请、关系快照、App 查询读模型 |
| `room-service` | 房间事件:上麦、确认发流、下麦、送礼、离房 |
| `wallet-service` | `COIN``COIN_SELLER_COIN``USD_BALANCE`、收益余额、提现冻结/审核/出账 |
| `wallet-service` | `COIN``COIN_SELLER_COIN``HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`、收益余额、提现冻结/审核/出账 |
`user-service` host domain 读取 `room-service``wallet-service` 事件但不反向修改房间状态。App 查询只读取当前身份、关系、申请、邀请、统计和后台已生成的收益结果;结算写入链路见 Admin 文档。

View File

@ -14,7 +14,7 @@
- `USDT进货`:平台收到币商线下 USDT 后,后台给币商发放 `COIN_SELLER_COIN` 库存;这笔记录计入币商进货/充值统计。
- `金币补偿`:平台因为异常、活动、人工修正等原因给币商补库存;没有充值金额,不计入币商进货/充值统计。
- 两种类型都不能给币商发放 `USD_BALANCE`,也不能直接影响玩家普通 `COIN`
- 两种类型都不能给币商发放 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,也不能直接影响玩家普通 `COIN`
## 服务边界

View File

@ -134,8 +134,8 @@ Authorization: Bearer <access_token>
| --- | --- | --- |
| 消息 tab、系统消息、活动消息 | 我的页设计稿不展示消息红点 | 消息 tab 页面接口 |
| 邀请码入口和邀请统计 | 截图首屏没有邀请码入口 | 邀请页或活动入口 |
| `wallet.recharge_enabled` / `withdraw_enabled` / `diamond_exchange_enabled` | 首屏卡片不需要操作开关 | `GET /api/v1/wallet/me/overview` |
| `DIAMOND` / `USD_BALANCE` | 首屏钱包卡片只展示金币 | 钱包二级页余额接口 |
| `wallet.recharge_enabled` / `diamond_exchange_enabled` | 首屏卡片不需要操作开关 | `GET /api/v1/wallet/me/overview` |
| `DIAMOND` / `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` | 首屏钱包卡片只展示金币 | 钱包二级页余额接口 |
| 邀请用户列表、钱包流水、工资明细、团队成员、背包完整列表、任务列表 | 都需要分页或聚合 | 对应二级页接口 |
## Performance Budget
@ -180,7 +180,6 @@ sequenceDiagram
| `GET /api/v1/wallet/me/overview` | 钱包二级页余额和操作开关 |
| `GET /api/v1/wallet/recharge/products` | 当前区域充值渠道和档位 |
| `GET /api/v1/wallet/diamond-exchange/config` | 钻石兑换金币/余额配置 |
| `POST /api/v1/wallet/withdrawals/apply` | 创建待审核提现申请并冻结 `USD_BALANCE` |
| `GET /api/v1/wallet/transactions` | 钱包流水分页 |
| `GET /api/v1/vip/me` | 当前 VIP 状态 |
| `GET /api/v1/vip/packages` | 可购买 VIP 包 |

View File

@ -99,7 +99,6 @@
| GET | `/api/v1/wallet/me/balances` | wallet | `getMyBalances` | 查询当前用户钱包余额 |
| GET | `/api/v1/wallet/recharge/products` | wallet | `listRechargeProducts` | 查询充值商品和渠道 |
| GET | `/api/v1/wallet/diamond-exchange/config` | wallet | `getDiamondExchangeConfig` | 查询钻石兑换配置 |
| POST | `/api/v1/wallet/withdrawals/apply` | wallet | `applyWithdrawal` | 提交美元余额提现申请 |
| GET | `/api/v1/wallet/coin-transactions` | wallet | `listCoinTransactions` | 分页查询当前用户金币流水 |
| GET | `/api/v1/wallet/transactions` | wallet | `listWalletTransactions` | 分页查询钱包流水 |
| POST | `/api/v1/wallet/coin-seller/transfer` | wallet | `transferCoinFromSeller` | 币商给玩家转金币 |

View File

@ -21,9 +21,9 @@
| `COIN_SELLER_COIN` | 币商专用金币库存,只能转给玩家形成普通 `COIN` | yes, seller transfer only | no | wallet-service |
| `DIAMOND` | 钻石,不可直接消费,只能按政策兑换金币或美元余额 | no | no | wallet-service |
| `GIFT_POINT` | 主播礼物积分,达到政策后结算美元奖励 | no | no | wallet-service |
| `USD_BALANCE` | 主播可提现美元余额,建议单位为 cent 或 micro cent | no | yes | wallet-service |
| `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` | 主播可提现美元余额,建议单位为 cent 或 micro cent | no | yes | wallet-service |
钻石和积分都不是直接消费资产。钻石兑换金币或美元余额时必须落兑换订单,记录政策版本、汇率和结果。主播积分达到政策后,由结算任务发起美元余额奖励入账,用户提现只能提现 `USD_BALANCE`。
钻石和积分都不是直接消费资产。钻石兑换金币或美元余额时必须落兑换订单,记录政策版本、汇率和结果。主播积分达到政策后,由结算任务发起美元余额奖励入账,用户提现只能提现 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`。
资源组可以通过资源域的 `wallet_asset` 组成员直接给用户发放 `COIN``DIAMOND`,但这仍然是钱包入账流水,不写用户资源权益;钻石后续兑换仍必须走独立兑换订单。
@ -40,7 +40,7 @@
| 充值 | `PARTIAL` | 币商转账已写 `wallet_recharge_records`provider order 未实现 | Apple、Google、线下订单和 provider 校验仍需补齐 |
| 币商转金币 | `DONE` | `TransferCoinFromSeller``COIN_SELLER_COIN`、加玩家 `COIN`,并按区域充值政策记录美元充值金额 | 限额、线下订单和风控审计仍需补齐 |
| 钻石兑换 | `TODO` | 无 diamond exchange order | 需要政策快照、兑换状态机和原子出入账 |
| 主播积分/奖励 | `PARTIAL` | 送礼实时给收礼人加 `GIFT_POINT` | 周期结算任务和 `USD_BALANCE` 奖励入账未落地 |
| 主播积分/奖励 | `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 投递适配未落地 |
@ -83,8 +83,8 @@
### P4: Anchor Reward And Withdraw
- 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。
- 奖励结算生成 `anchor_reward_settlements`,确认后加主播 `USD_BALANCE`。
- 提现申请必须先冻结 `USD_BALANCE`,审核拒绝解冻,审核通过后进入人工打款流程。
- 奖励结算生成 `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
@ -239,7 +239,7 @@ wallet_recharge_records(
);
```
`wallet_recharge_records.usd_minor_amount` 是用户充值额的权威统计口径;用户不会因此获得 `USD_BALANCE`。政策字段必须保存快照,后续修改区域汇率不能重算历史充值。
`wallet_recharge_records.usd_minor_amount` 是用户充值额的权威统计口径;用户不会因此获得 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`。政策字段必须保存快照,后续修改区域汇率不能重算历史充值。
## Send Gift Flow
@ -321,7 +321,7 @@ stateDiagram-v2
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 USD_BALANCE 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兑换失败不能出现钻石扣了但目标资产没到账。
@ -332,8 +332,8 @@ flowchart LR
1. 送礼时钱包给主播增加 `GIFT_POINT`
2. 结算任务读取积分和政策,生成 `anchor_reward_settlements`
3. 结算单确认后调用钱包 `CreditRewardBalance`,增加主播 `USD_BALANCE`。
4. 主播提现时先冻结 `USD_BALANCE`,审核拒绝解冻,审核通过后等待人工转账。
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`
提现状态机:
@ -499,7 +499,7 @@ stateDiagram-v2
提现:
- 提现申请成功后 `USD_BALANCE.available_amount` 减少,`frozen_amount` 增加。
- 提现申请成功后 `identity salary wallet.available_amount` 减少,`frozen_amount` 增加。
- 审核拒绝后冻结金额回到 available。
- 打款完成后 frozen 减少并写出账分录。
- 打款失败后状态可重试,不丢失冻结关系和审核上下文。
@ -533,7 +533,7 @@ docker compose config
- 幂等键必须覆盖业务语义,同一个 command_id 带不同 payload 必须返回冲突。
- Apple/Google 充值以 provider 真实交易号为最终幂等源,不能只信客户端 request id。
- 币商充值分两段:平台给币商发 `COIN_SELLER_COIN` 库存,币商再把库存转成用户 `COIN`;必须有币商金币余额、充值政策快照、限额和审计。
- `USD_BALANCE` 是可提现负债,调账和奖励发放需要更高权限和审计。
- `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 是可提现负债,调账和奖励发放需要更高权限和审计。
- 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
- 钱包事件投递使用 outboxMQ 投递失败不回滚账务事实。

View File

@ -0,0 +1,55 @@
-- Remove the obsolete generic USD withdrawal request table from the wallet database.
-- Salary settlement now writes role-specific wallets only:
-- HOST_SALARY_USD, AGENCY_SALARY_USD, BD_SALARY_USD, ADMIN_SALARY_USD.
-- Keep an archive copy before dropping the source table so production history is not lost.
SET @source_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_withdrawal_requests'
);
SET @archive_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_withdrawal_requests_removed_20260602'
);
SET @sql := IF(
@source_exists = 1 AND @archive_exists = 0,
'CREATE TABLE wallet_withdrawal_requests_removed_20260602 LIKE wallet_withdrawal_requests',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
@source_exists = 1,
'INSERT IGNORE INTO wallet_withdrawal_requests_removed_20260602 SELECT * FROM wallet_withdrawal_requests',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
@source_exists = 1,
'DROP TABLE wallet_withdrawal_requests',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT
SUM(TABLE_NAME = 'wallet_withdrawal_requests') AS source_table_exists,
SUM(TABLE_NAME = 'wallet_withdrawal_requests_removed_20260602') AS archive_table_exists
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (
'wallet_withdrawal_requests',
'wallet_withdrawal_requests_removed_20260602'
);

View File

@ -23,8 +23,9 @@ const (
policyTypeAdmin = "admin"
triggerAutomatic = "automatic"
triggerManual = "manual"
// assetUSDBalance 必须与 wallet-service 钱包资产类型保持一致BD/Admin 工资直接入美元余额账户。
assetUSDBalance = "USD_BALANCE"
// BD/Admin 工资钱包资产类型必须与 wallet-service ledger 常量保持一致admin-server 是独立 module不能直接复用根 module 常量。
assetBDSalaryUSD = "BD_SALARY_USD"
assetAdminSalaryUSD = "ADMIN_SALARY_USD"
bizTypeTeamSettlement = "team_salary_settlement"
statusSucceeded = "succeeded"
)
@ -97,6 +98,7 @@ type settlementMetadata struct {
LevelNo int `json:"level_no"`
IncomeUSDMinor int64 `json:"income_usd_minor"`
SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"`
SalaryAssetType string `json:"salary_asset_type"`
SettledLevelNo int `json:"settled_level_no"`
SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"`
ProcessedAtMS int64 `json:"processed_at_ms"`
@ -490,6 +492,10 @@ func (s *Service) settleCandidate(ctx context.Context, appCode string, operatorA
MonthClosedAtMS: nowMS,
Version: progress.Version,
}
salaryAssetType := teamSalaryAssetType(candidate.PolicyType)
if salaryAssetType == "" {
return recordDTO{}, false, fmt.Errorf("team salary asset type is invalid: %s", candidate.PolicyType)
}
metadata := settlementMetadata{
AppCode: appCode,
PolicyType: candidate.PolicyType,
@ -502,6 +508,7 @@ func (s *Service) settleCandidate(ctx context.Context, appCode string, operatorA
LevelNo: candidate.Level.LevelNo,
IncomeUSDMinor: candidate.IncomeUSDMinor,
SalaryUSDMinorDelta: salaryDelta,
SalaryAssetType: salaryAssetType,
SettledLevelNo: nextProgress.SettledLevelNo,
SettledSalaryUSDMinor: nextProgress.SettledSalaryUSDMinor,
ProcessedAtMS: nowMS,
@ -513,11 +520,11 @@ func (s *Service) settleCandidate(ctx context.Context, appCode string, operatorA
if err := s.insertWalletTransaction(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil {
return recordDTO{}, false, err
}
balanceAfter, version, err := s.creditWallet(ctx, tx, appCode, transactionID, candidate.UserID, salaryDelta, nowMS)
balanceAfter, version, err := s.creditWallet(ctx, tx, appCode, transactionID, candidate.UserID, metadata.SalaryAssetType, salaryDelta, nowMS)
if err != nil {
return recordDTO{}, false, err
}
if err := s.insertWalletOutbox(ctx, tx, appCode, transactionID, commandID, candidate.UserID, salaryDelta, balanceAfter, version, metadata, nowMS); err != nil {
if err := s.insertWalletOutbox(ctx, tx, appCode, transactionID, commandID, candidate.UserID, metadata.SalaryAssetType, salaryDelta, balanceAfter, version, metadata, nowMS); err != nil {
return recordDTO{}, false, err
}
if err := s.insertRecord(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil {
@ -840,12 +847,12 @@ func (s *Service) insertWalletTransaction(ctx context.Context, tx *sql.Tx, appCo
return err
}
// creditWallet 锁定美元账户并追加 wallet_entries使余额、流水和版本在同一事务内一致。
func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, userID int64, amount int64, nowMS int64) (int64, int64, error) {
// creditWallet 锁定对应角色工资钱包并追加 wallet_entries使余额、流水和版本在同一事务内一致。
func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, userID int64, assetType string, amount int64, nowMS int64) (int64, int64, error) {
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO wallet_accounts (
app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, appCode, userID, assetUSDBalance, nowMS, nowMS); err != nil {
) VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, appCode, userID, assetType, nowMS, nowMS); err != nil {
return 0, 0, err
}
var available int64
@ -855,7 +862,7 @@ func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string,
SELECT available_amount, frozen_amount, version
FROM wallet_accounts
WHERE app_code = ? AND user_id = ? AND asset_type = ?
FOR UPDATE`, appCode, userID, assetUSDBalance,
FOR UPDATE`, appCode, userID, assetType,
).Scan(&available, &frozen, &version); err != nil {
return 0, 0, err
}
@ -868,7 +875,7 @@ func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string,
UPDATE wallet_accounts
SET available_amount = ?, version = version + 1, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`,
after, nowMS, appCode, userID, assetUSDBalance, version,
after, nowMS, appCode, userID, assetType, version,
); err != nil {
return 0, 0, err
}
@ -877,7 +884,7 @@ func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string,
app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta,
available_after, frozen_after, counterparty_user_id, room_id, created_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, 0, '', ?)`,
appCode, transactionID, userID, assetUSDBalance, amount, after, frozen, nowMS,
appCode, transactionID, userID, assetType, amount, after, frozen, nowMS,
); err != nil {
return 0, 0, err
}
@ -885,12 +892,12 @@ func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string,
}
// insertWalletOutbox 投递余额变更事件,供后续 MQ/通知链路异步消费。
func (s *Service) insertWalletOutbox(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, userID int64, amount int64, balanceAfter int64, version int64, metadata settlementMetadata, nowMS int64) error {
func (s *Service) insertWalletOutbox(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, userID int64, assetType string, amount int64, balanceAfter int64, version int64, metadata settlementMetadata, nowMS int64) error {
payload, err := json.Marshal(map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": userID,
"asset_type": assetUSDBalance,
"asset_type": assetType,
"available_delta": amount,
"available_after": balanceAfter,
"version": version,
@ -905,7 +912,7 @@ func (s *Service) insertWalletOutbox(ctx context.Context, tx *sql.Tx, appCode st
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, payload, status, created_at_ms, updated_at_ms
) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?)`,
appCode, transactionID+":"+assetUSDBalance, transactionID, commandID, userID, assetUSDBalance, amount, string(payload), nowMS, nowMS,
appCode, transactionID+":"+assetType, transactionID, commandID, userID, assetType, amount, string(payload), nowMS, nowMS,
)
return err
}
@ -1233,6 +1240,18 @@ func normalizeTriggerMode(value string) string {
}
}
func teamSalaryAssetType(policyType string) string {
// 用户可以同时拥有 BD/Admin 等多重身份,工资资产必须按本次结算角色拆分,不能混入其它身份钱包。
switch normalizePolicyType(policyType) {
case policyTypeBD:
return assetBDSalaryUSD
case policyTypeAdmin:
return assetAdminSalaryUSD
default:
return ""
}
}
// normalizeStatus 只允许记录表定义的状态值,避免前端筛选拼接任意 SQL 条件。
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {

View File

@ -72,8 +72,8 @@ func TestRealMySQLTeamSalaryFullFlow(t *testing.T) {
if totalRecords != 2 || len(records) != 2 {
t.Fatalf("record count mismatch: total=%d len=%d records=%+v", totalRecords, len(records), records)
}
assertWalletBalance(t, walletDB, 4001, 100_000)
assertWalletBalance(t, walletDB, 5001, 20_000)
assertWalletBalance(t, walletDB, 4001, assetBDSalaryUSD, 100_000)
assertWalletBalance(t, walletDB, 5001, assetAdminSalaryUSD, 20_000)
assertScalar(t, walletDB, `SELECT COUNT(*) FROM team_salary_settlement_records`, int64(2))
assertScalar(t, walletDB, `SELECT COUNT(*) FROM wallet_transactions WHERE biz_type = 'team_salary_settlement'`, int64(2))
@ -354,14 +354,14 @@ func execAll(t *testing.T, db *sql.DB, statements ...string) {
}
}
func assertWalletBalance(t *testing.T, db *sql.DB, userID int64, expected int64) {
func assertWalletBalance(t *testing.T, db *sql.DB, userID int64, assetType string, expected int64) {
t.Helper()
var amount int64
if err := db.QueryRow(`SELECT available_amount FROM wallet_accounts WHERE app_code = 'lalu' AND user_id = ? AND asset_type = 'USD_BALANCE'`, userID).Scan(&amount); err != nil {
if err := db.QueryRow(`SELECT available_amount FROM wallet_accounts WHERE app_code = 'lalu' AND user_id = ? AND asset_type = ?`, userID, assetType).Scan(&amount); err != nil {
t.Fatalf("query wallet balance failed: %v", err)
}
if amount != expected {
t.Fatalf("wallet balance mismatch for user %d: got=%d want=%d", userID, amount, expected)
t.Fatalf("wallet balance mismatch for user %d asset %s: got=%d want=%d", userID, assetType, amount, expected)
}
}

View File

@ -18,7 +18,6 @@ type WalletClient interface {
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error)
ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error)
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
@ -81,10 +80,6 @@ func (c *grpcWalletClient) ListWalletTransactions(ctx context.Context, req *wall
return c.client.ListWalletTransactions(ctx, req)
}
func (c *grpcWalletClient) ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
return c.client.ApplyWithdrawal(ctx, req)
}
func (c *grpcWalletClient) ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) {
return c.client.ListVipPackages(ctx, req)
}

View File

@ -167,7 +167,6 @@ type WalletHandlers struct {
ListRechargeProducts http.HandlerFunc
ConfirmGooglePayment http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ApplyWithdrawal http.HandlerFunc
ListCoinTransactions http.HandlerFunc
ListWalletTransactions http.HandlerFunc
ListCoinSellers http.HandlerFunc
@ -396,7 +395,6 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers)

View File

@ -429,8 +429,6 @@ type fakeWalletClient struct {
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
lastTransactions *walletv1.ListWalletTransactionsRequest
transactionsResp *walletv1.ListWalletTransactionsResponse
lastWithdrawal *walletv1.ApplyWithdrawalRequest
withdrawalResp *walletv1.ApplyWithdrawalResponse
lastVipPackages *walletv1.ListVipPackagesRequest
vipPackagesResp *walletv1.ListVipPackagesResponse
lastMyVip *walletv1.GetMyVipRequest
@ -1147,7 +1145,6 @@ func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.Ge
FeatureFlags: &walletv1.WalletFeatureFlags{
RechargeEnabled: true,
DiamondExchangeEnabled: true,
WithdrawEnabled: true,
},
}, nil
}
@ -1230,17 +1227,6 @@ func (f *fakeWalletClient) ListWalletTransactions(_ context.Context, req *wallet
return &walletv1.ListWalletTransactionsResponse{}, nil
}
func (f *fakeWalletClient) ApplyWithdrawal(_ context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
f.lastWithdrawal = req
if f.err != nil {
return nil, f.err
}
if f.withdrawalResp != nil {
return f.withdrawalResp, nil
}
return &walletv1.ApplyWithdrawalResponse{}, nil
}
func (f *fakeWalletClient) ListVipPackages(_ context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) {
f.lastVipPackages = req
if f.vipErr != nil {

View File

@ -47,7 +47,6 @@ type walletOverviewData struct {
type walletFeatureFlagsData struct {
RechargeEnabled bool `json:"recharge_enabled"`
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
WithdrawEnabled bool `json:"withdraw_enabled"`
}
type walletTransactionData struct {
@ -96,15 +95,6 @@ type giftWallItemData struct {
SortOrder int32 `json:"sort_order"`
}
type withdrawalApplyRequestBody struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
Amount int64 `json:"amount"`
PayoutAccount string `json:"payout_account"`
PayoutAlt string `json:"payoutAccount"`
Reason string `json:"reason"`
}
type googlePaymentConfirmRequestBody struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
@ -134,18 +124,6 @@ type googlePaymentConfirmData struct {
ConsumeState string `json:"consume_state"`
}
type withdrawalData struct {
WithdrawalID string `json:"withdrawal_id"`
UserID int64 `json:"user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
Status string `json:"status"`
PayoutAccount string `json:"payout_account"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type vipPurchaseRequestBody struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
@ -387,46 +365,6 @@ func (h *Handler) listWalletTransactionsByAsset(writer http.ResponseWriter, requ
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
// applyWithdrawal 创建待人工审核的提现申请。
func (h *Handler) applyWithdrawal(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body withdrawalApplyRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
payoutAccount := strings.TrimSpace(body.PayoutAccount)
if payoutAccount == "" {
payoutAccount = strings.TrimSpace(body.PayoutAlt)
}
if commandID == "" || body.Amount <= 0 || payoutAccount == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ApplyWithdrawal(request.Context(), &walletv1.ApplyWithdrawalRequest{
CommandId: commandID,
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
Amount: body.Amount,
PayoutAccount: payoutAccount,
Reason: strings.TrimSpace(body.Reason),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"withdrawal": withdrawalFromProto(resp.GetWithdrawal()),
"balance": balanceFromProto(resp.GetBalance()),
})
}
func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProductData {
if product == nil {
return rechargeProductData{}
@ -496,7 +434,6 @@ func walletOverviewFromProto(resp *walletv1.GetWalletOverviewResponse) walletOve
FeatureFlags: walletFeatureFlagsData{
RechargeEnabled: flags.GetRechargeEnabled(),
DiamondExchangeEnabled: flags.GetDiamondExchangeEnabled(),
WithdrawEnabled: flags.GetWithdrawEnabled(),
},
}
}
@ -566,23 +503,6 @@ func giftWallItemFromProto(item *walletv1.GiftWallItem) giftWallItemData {
}
}
func withdrawalFromProto(item *walletv1.WithdrawalRequest) withdrawalData {
if item == nil {
return withdrawalData{}
}
return withdrawalData{
WithdrawalID: item.GetWithdrawalId(),
UserID: item.GetUserId(),
AssetType: item.GetAssetType(),
Amount: item.GetAmount(),
Status: item.GetStatus(),
PayoutAccount: item.GetPayoutAccount(),
Reason: item.GetReason(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func balanceFromProto(balance *walletv1.AssetBalance) assetBalanceData {
if balance == nil {
return assetBalanceData{}

View File

@ -48,7 +48,6 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
ListRechargeProducts: h.listRechargeProducts,
ConfirmGooglePayment: h.confirmGooglePayment,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers,

View File

@ -1046,28 +1046,6 @@ CREATE TABLE IF NOT EXISTS red_packet_user_daily_counters (
PRIMARY KEY (app_code, user_id, send_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红包用户每日发送计数表';
CREATE TABLE IF NOT EXISTS wallet_withdrawal_requests (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
withdrawal_id VARCHAR(96) NOT NULL COMMENT '提现 ID',
command_id VARCHAR(128) NOT NULL COMMENT '业务命令幂等 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
asset_type VARCHAR(32) NOT NULL COMMENT '钱包资产类型',
amount BIGINT NOT NULL COMMENT '金额或数量',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
payout_account VARCHAR(256) NOT NULL COMMENT '出款账户',
reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '原因',
wallet_transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
reviewed_by_user_id BIGINT NULL COMMENT '审核用户 ID',
review_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '审核原因',
reviewed_at_ms BIGINT NULL COMMENT '审核时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, withdrawal_id),
UNIQUE KEY uk_withdrawal_command (app_code, command_id),
KEY idx_withdrawal_user_time (app_code, user_id, created_at_ms),
KEY idx_withdrawal_status_time (app_code, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包提现申请表';
CREATE TABLE IF NOT EXISTS vip_levels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
level INT NOT NULL COMMENT '等级',

View File

@ -11,8 +11,14 @@ const (
AssetGiftPoint = "GIFT_POINT"
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND"
// AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。
AssetUSDBalance = "USD_BALANCE"
// AssetHostSalaryUSD 是主播工资美元钱包,只接收主播工资和月底剩余钻石折美元。
AssetHostSalaryUSD = "HOST_SALARY_USD"
// AssetAgencySalaryUSD 是代理工资美元钱包,只接收主播结算同时产生的代理工资。
AssetAgencySalaryUSD = "AGENCY_SALARY_USD"
// AssetBDSalaryUSD 是 BD 工资美元钱包,只接收 BD 政策结算工资。
AssetBDSalaryUSD = "BD_SALARY_USD"
// AssetAdminSalaryUSD 是 Admin 工资美元钱包,只接收 Admin 政策结算工资。
AssetAdminSalaryUSD = "ADMIN_SALARY_USD"
// StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。
StockTypeUSDTPurchase = "usdt_purchase"
@ -43,9 +49,6 @@ const (
// GooglePurchaseStatePurchased 是 Google Play 已支付完成状态。
GooglePurchaseStatePurchased = "PURCHASED"
// WithdrawalStatusPending 表示主播美元余额提现已提交,等待后台人工审核和线下转账。
WithdrawalStatusPending = "pending"
// HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。
HostSalarySettlementModeDaily = "daily"
// HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。
@ -279,7 +282,6 @@ type ListRechargeBillsQuery struct {
type WalletFeatureFlags struct {
RechargeEnabled bool
DiamondExchangeEnabled bool
WithdrawEnabled bool
}
// WalletOverview 是我的页和钱包首页共同使用的轻量摘要。
@ -473,29 +475,6 @@ type ListWalletTransactionsQuery struct {
PageSize int32
}
// WithdrawalCommand 是主播余额提现申请命令。人工审核和线下转账不在 App 主链路完成。
type WithdrawalCommand struct {
AppCode string
CommandID string
UserID int64
Amount int64
PayoutAccount string
Reason string
}
// WithdrawalRequest 是提现申请事实。
type WithdrawalRequest struct {
WithdrawalID string
UserID int64
AssetType string
Amount int64
Status string
PayoutAccount string
Reason string
CreatedAtMS int64
UpdatedAtMS int64
}
// VipRewardItem 是 VIP 资源组权益的轻量展示投影。
type VipRewardItem struct {
ResourceID int64
@ -786,7 +765,7 @@ type RedPacketExpireResult struct {
// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。
func ValidAssetType(assetType string) bool {
switch assetType {
case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetUSDBalance:
case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD:
return true
default:
return false

View File

@ -48,7 +48,6 @@ type Repository interface {
DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error
GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error)
ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error)
ApplyWithdrawal(ctx context.Context, command ledger.WithdrawalCommand) (ledger.WithdrawalRequest, ledger.AssetBalance, error)
ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error)
GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error)
PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error)
@ -738,24 +737,6 @@ func (s *Service) ListWalletTransactions(ctx context.Context, query ledger.ListW
return s.repository.ListWalletTransactions(ctx, query)
}
// ApplyWithdrawal 冻结主播美元余额并创建待人工审核提现申请。
func (s *Service) ApplyWithdrawal(ctx context.Context, command ledger.WithdrawalCommand) (ledger.WithdrawalRequest, ledger.AssetBalance, error) {
if command.CommandID == "" || command.UserID <= 0 || command.Amount <= 0 {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.InvalidArgument, "withdrawal command is incomplete")
}
command.PayoutAccount = strings.TrimSpace(command.PayoutAccount)
command.Reason = strings.TrimSpace(command.Reason)
if command.PayoutAccount == "" || len(command.PayoutAccount) > 256 || len(command.Reason) > 512 {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.InvalidArgument, "withdrawal payout account is invalid")
}
if s.repository == nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ApplyWithdrawal(ctx, command)
}
// ListVipPackages 返回可购买 VIP 包和当前会员状态。
func (s *Service) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) {
if userID <= 0 {

View File

@ -264,9 +264,9 @@ func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) {
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("first settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 10002, ledger.AssetCoin, 90)
assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-salary-gift-2",
@ -301,9 +301,9 @@ func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) {
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("second settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 300)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300)
assertBalance(t, svc, 10002, ledger.AssetCoin, 198)
assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 80)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80)
if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 {
t.Fatalf("salary settlement records mismatch, got %d", got)
}
@ -391,8 +391,8 @@ func TestHostSalaryManualPolicyRequiresManualTrigger(t *testing.T) {
if result.ProcessedCount != 1 || result.SuccessCount != 1 {
t.Fatalf("manual settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150)
assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
}
// TestHostSalaryMonthEndCreditsResidualAndClearsCycle 验证月底结算补发剩余钻石折美元,并逻辑关闭当月周期。
@ -455,7 +455,7 @@ func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) {
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("month-end settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 400)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 400)
if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), receipt.HostPeriodCycleKey, int64(250)); got != 1 {
t.Fatalf("month-end progress should store residual and cleared marker, got %d", got)
}
@ -534,9 +534,9 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("level 1 daily settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 10002, ledger.AssetCoin, 90)
assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle)
if len(previousIDs) != 1 || previousIDs[0] == "" {
t.Fatalf("level 1 settlement id missing: %v", previousIDs)
@ -598,9 +598,9 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
t.Fatalf("level 2 daily settlement result mismatch: %+v", result)
}
// 第二次只补发累计 2 级与已发 1 级之间的差额,最终余额等于 2 级累计权益。
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 300)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300)
assertBalance(t, svc, 10002, ledger.AssetCoin, 198)
assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 80)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80)
previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle)
if len(previousIDs) != 2 {
t.Fatalf("level 2 settlement should create second record: %v", previousIDs)
@ -623,7 +623,7 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
t.Fatalf("month-end lifecycle result mismatch: %+v", result)
}
// 480 钻石达到 2 级后剩余 80 钻石,按 0.01 USD/diamond 折算为 80 美分并标记周期已清算。
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 380)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 380)
if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND last_settled_total_diamonds = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), previousCycle, int64(480), int64(80)); got != 1 {
t.Fatalf("month-end progress should store residual and clear marker, got %d", got)
}
@ -683,9 +683,9 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
t.Fatalf("next-cycle daily result mismatch: %+v", result)
}
// 新 cycle_key 使用独立进度,因此月末清算后的下一周期从 1 级累计权益重新开始发放。
assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 530)
assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 530)
assertBalance(t, svc, 10002, ledger.AssetCoin, 288)
assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 130)
assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 130)
nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey)
if len(nextIDs) != 1 || nextIDs[0] == "" {
t.Fatalf("next-cycle settlement id missing: %v", nextIDs)
@ -757,9 +757,9 @@ func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) {
if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("half-month settlement result mismatch: %+v", result)
}
assertBalance(t, svc, 10003, ledger.AssetUSDBalance, 150)
assertBalance(t, svc, 10003, ledger.AssetHostSalaryUSD, 150)
assertBalance(t, svc, 10003, ledger.AssetCoin, 90)
assertBalance(t, svc, 30002, ledger.AssetUSDBalance, 50)
assertBalance(t, svc, 30002, ledger.AssetAgencySalaryUSD, 50)
if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" {
t.Fatalf("half-month settlement id missing: %v", ids)
}
@ -2499,36 +2499,6 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) {
}
}
// TestApplyWithdrawalFreezesUSDBalance 验证提现申请只创建待审核事实,并把可提现美元余额转入冻结。
func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetAssetBalance(50001, ledger.AssetUSDBalance, 3000)
svc := walletservice.New(repository)
withdrawal, balance, err := svc.ApplyWithdrawal(context.Background(), ledger.WithdrawalCommand{
CommandID: "cmd-withdraw-usd",
UserID: 50001,
Amount: 1200,
PayoutAccount: "paypal:host@example.com",
Reason: "host payout",
})
if err != nil {
t.Fatalf("ApplyWithdrawal failed: %v", err)
}
if withdrawal.WithdrawalID == "" || withdrawal.Status != ledger.WithdrawalStatusPending || withdrawal.Amount != 1200 {
t.Fatalf("withdrawal response mismatch: %+v", withdrawal)
}
if balance.AvailableAmount != 1800 || balance.FrozenAmount != 1200 {
t.Fatalf("withdrawal balance mismatch: %+v", balance)
}
if got := repository.CountRows("wallet_withdrawal_requests", "user_id = ? AND status = ?", int64(50001), ledger.WithdrawalStatusPending); got != 1 {
t.Fatalf("withdrawal request should be persisted once, got %d", got)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-withdraw-usd", "withdrawal_apply"); got != 1 {
t.Fatalf("withdrawal should write one transaction, got %d", got)
}
}
// TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -2845,7 +2815,7 @@ func TestWalletOverviewDisablesDiamondExchange(t *testing.T) {
if err != nil {
t.Fatalf("GetWalletOverview failed: %v", err)
}
if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled {
if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled {
t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
}
if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, "DIAMOND") != 0 {

View File

@ -16,15 +16,21 @@ import (
)
const (
bizTypeWithdrawalApply = "withdrawal_apply"
bizTypeVIPPurchase = "vip_purchase"
bizTypeVIPGrant = "vip_grant"
vipOutboxAsset = "VIP"
bizTypeVIPPurchase = "vip_purchase"
bizTypeVIPGrant = "vip_grant"
vipOutboxAsset = "VIP"
)
// GetWalletOverview 返回钱包首页摘要;开关放在 wallet-service 内,避免 gateway 硬编码账务能力。
func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) {
balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin, ledger.AssetUSDBalance})
// 钱包首页返回所有可展示的资金口袋工资钱包按身份拆分0 余额也返回,便于客户端固定布局。
balances, err := r.GetBalances(ctx, userID, []string{
ledger.AssetCoin,
ledger.AssetHostSalaryUSD,
ledger.AssetAgencySalaryUSD,
ledger.AssetBDSalaryUSD,
ledger.AssetAdminSalaryUSD,
})
if err != nil {
return ledger.WalletOverview{}, err
}
@ -33,7 +39,6 @@ func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledge
FeatureFlags: ledger.WalletFeatureFlags{
RechargeEnabled: true,
DiamondExchangeEnabled: false,
WithdrawEnabled: true,
},
}, nil
}
@ -196,124 +201,6 @@ func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.Li
return items, total, rows.Err()
}
// ApplyWithdrawal 冻结 USD_BALANCE 并写待审核提现申请。后台审核和人工转账是后续独立命令。
func (r *Repository) ApplyWithdrawal(ctx context.Context, command ledger.WithdrawalCommand) (ledger.WithdrawalRequest, ledger.AssetBalance, error) {
if r == nil || r.db == nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := stableHash(fmt.Sprintf("withdrawal|%s|%d|%d|%s", command.AppCode, command.UserID, command.Amount, command.PayoutAccount))
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWithdrawalApply); err != nil || exists {
if err != nil || !exists {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
withdrawal, balance, receiptErr := r.withdrawalReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
return withdrawal, balance, receiptErr
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetUSDBalance, false, nowMs)
if err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
if account.AvailableAmount < command.Amount {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
transactionID := transactionID(command.AppCode, command.CommandID)
withdrawalID := "wdr_" + stableHash(command.AppCode+"|"+command.CommandID)
availableAfter := account.AvailableAmount - command.Amount
frozenAfter := account.FrozenAmount + command.Amount
metadata := map[string]any{
"app_code": command.AppCode,
"withdrawal_id": withdrawalID,
"user_id": command.UserID,
"asset_type": ledger.AssetUSDBalance,
"amount": command.Amount,
"payout_account": command.PayoutAccount,
"reason": command.Reason,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWithdrawalApply, requestHash, withdrawalID, metadata, nowMs); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, command.Amount, nowMs); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetUSDBalance,
AvailableDelta: -command.Amount,
FrozenDelta: command.Amount,
AvailableAfter: availableAfter,
FrozenAfter: frozenAfter,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
withdrawal := ledger.WithdrawalRequest{
WithdrawalID: withdrawalID,
UserID: command.UserID,
AssetType: ledger.AssetUSDBalance,
Amount: command.Amount,
Status: ledger.WithdrawalStatusPending,
PayoutAccount: command.PayoutAccount,
Reason: command.Reason,
CreatedAtMS: nowMs,
UpdatedAtMS: nowMs,
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO wallet_withdrawal_requests (
app_code, withdrawal_id, command_id, user_id, asset_type, amount, status,
payout_account, reason, wallet_transaction_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode, withdrawal.WithdrawalID, command.CommandID, command.UserID, withdrawal.AssetType,
withdrawal.Amount, withdrawal.Status, withdrawal.PayoutAccount, withdrawal.Reason, transactionID, nowMs, nowMs,
); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetUSDBalance, -command.Amount, command.Amount, availableAfter, frozenAfter, account.Version+1, metadata, nowMs),
}); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
if err := tx.Commit(); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
return withdrawal, ledger.AssetBalance{
AppCode: command.AppCode,
UserID: command.UserID,
AssetType: ledger.AssetUSDBalance,
AvailableAmount: availableAfter,
FrozenAmount: frozenAfter,
Version: account.Version + 1,
UpdatedAtMs: nowMs,
}, nil
}
func (r *Repository) withdrawalReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (ledger.WithdrawalRequest, ledger.AssetBalance, error) {
row := tx.QueryRowContext(ctx, `
SELECT withdrawal_id, user_id, asset_type, amount, status, payout_account, reason, created_at_ms, updated_at_ms
FROM wallet_withdrawal_requests
WHERE app_code = ? AND wallet_transaction_id = ?`,
appcode.FromContext(ctx), transactionID,
)
var withdrawal ledger.WithdrawalRequest
if err := row.Scan(&withdrawal.WithdrawalID, &withdrawal.UserID, &withdrawal.AssetType, &withdrawal.Amount, &withdrawal.Status, &withdrawal.PayoutAccount, &withdrawal.Reason, &withdrawal.CreatedAtMS, &withdrawal.UpdatedAtMS); err != nil {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
}
balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetUSDBalance)
return withdrawal, balance, err
}
// GetMyVip 返回用户当前 VIP 状态;过期只影响 active 投影,不直接修改历史事实。
func (r *Repository) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) {
if r == nil || r.db == nil {

View File

@ -57,6 +57,8 @@ type hostSalarySettlementMetadata struct {
HostUSDBalanceAfter int64 `json:"host_usd_balance_after"`
HostCoinBalanceAfter int64 `json:"host_coin_balance_after"`
AgencyUSDBalanceAfter int64 `json:"agency_usd_balance_after"`
HostSalaryAssetType string `json:"host_salary_asset_type"`
AgencySalaryAssetType string `json:"agency_salary_asset_type"`
SettledLevelNo int `json:"settled_level_no"`
SettledHostSalaryUSDMinor int64 `json:"settled_host_salary_usd_minor"`
SettledHostCoinReward int64 `json:"settled_host_coin_reward"`
@ -262,6 +264,8 @@ func (r *Repository) processHostSalaryCandidate(ctx context.Context, command led
HostCoinRewardDelta: hostCoinDelta,
AgencySalaryUSDMinorDelta: agencyPaidDelta,
ResidualUSDMinorDelta: residualDelta,
HostSalaryAssetType: ledger.AssetHostSalaryUSD,
AgencySalaryAssetType: ledger.AssetAgencySalaryUSD,
SettledLevelNo: nextProgress.SettledLevelNo,
SettledHostSalaryUSDMinor: nextProgress.SettledHostSalaryUSDMinor,
SettledHostCoinReward: nextProgress.SettledHostCoinReward,
@ -407,12 +411,13 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
// 主播美元、主播金币、代理美元共用同一 transaction_id确保账务上可视为一次工资结算。
events := make([]walletOutboxEvent, 0, 3)
if hostUSDDelta > 0 {
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetUSDBalance, hostUSDDelta, 0, nowMs)
// 主播工资和月底剩余钻石折美元只进入主播工资钱包,不混入代理或团队工资钱包。
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetHostSalaryUSD, hostUSDDelta, 0, nowMs)
if err != nil {
return nil, err
}
metadata.HostUSDBalanceAfter = balanceAfter
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetUSDBalance, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs))
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetHostSalaryUSD, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs))
}
if hostCoinDelta > 0 {
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, nowMs)
@ -423,12 +428,13 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, balanceAfter, 0, version, metadata, nowMs))
}
if agencyUSDDelta > 0 {
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.AgencyOwnerUserID, ledger.AssetUSDBalance, agencyUSDDelta, metadata.HostUserID, nowMs)
// 代理工资进入代理工资钱包,同一用户若同时是主播也不会和主播工资钱包合并。
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.AgencyOwnerUserID, ledger.AssetAgencySalaryUSD, agencyUSDDelta, metadata.HostUserID, nowMs)
if err != nil {
return nil, err
}
metadata.AgencyUSDBalanceAfter = balanceAfter
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetUSDBalance, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs))
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetAgencySalaryUSD, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs))
}
return events, nil
}

View File

@ -534,7 +534,7 @@ func validateTableName(table string) string {
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
"host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies",
"host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records",
"coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests",
"coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules",
"vip_levels", "user_vip_memberships", "vip_purchase_orders", "user_vip_history",
"resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events",
"resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":

View File

@ -329,26 +329,6 @@ func (s *Server) ListWalletTransactions(ctx context.Context, req *walletv1.ListW
return resp, nil
}
// ApplyWithdrawal 创建提现申请并冻结美元余额。
func (s *Server) ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
withdrawal, balance, err := s.svc.ApplyWithdrawal(ctx, ledger.WithdrawalCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
UserID: req.GetUserId(),
Amount: req.GetAmount(),
PayoutAccount: req.GetPayoutAccount(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.ApplyWithdrawalResponse{
Withdrawal: withdrawalToProto(withdrawal),
Balance: balanceToProto(balance),
}, nil
}
// ListVipPackages 返回当前 VIP 和可购买包列表。
func (s *Server) ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
@ -562,21 +542,6 @@ func walletFeatureFlagsToProto(flags ledger.WalletFeatureFlags) *walletv1.Wallet
return &walletv1.WalletFeatureFlags{
RechargeEnabled: flags.RechargeEnabled,
DiamondExchangeEnabled: flags.DiamondExchangeEnabled,
WithdrawEnabled: flags.WithdrawEnabled,
}
}
func withdrawalToProto(withdrawal ledger.WithdrawalRequest) *walletv1.WithdrawalRequest {
return &walletv1.WithdrawalRequest{
WithdrawalId: withdrawal.WithdrawalID,
UserId: withdrawal.UserID,
AssetType: withdrawal.AssetType,
Amount: withdrawal.Amount,
Status: withdrawal.Status,
PayoutAccount: withdrawal.PayoutAccount,
Reason: withdrawal.Reason,
CreatedAtMs: withdrawal.CreatedAtMS,
UpdatedAtMs: withdrawal.UpdatedAtMS,
}
}