feat: remove generic USD wallet balance
This commit is contained in:
parent
858b0993bc
commit
679d068a82
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 | 是否允许提现 |
|
||||
|
||||
### 可能错误
|
||||
|
||||
|
||||
@ -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 政策、周期、工资单、关系管理、统计聚合、幂等结果、领域 outbox;App/客户端不能提交区域,后台关系命令的 `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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 文档。
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
## 服务边界
|
||||
|
||||
|
||||
@ -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 包 |
|
||||
|
||||
@ -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` | 币商给玩家转金币 |
|
||||
|
||||
@ -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。
|
||||
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
|
||||
- 钱包事件投递使用 outbox,MQ 投递失败不回滚账务事实。
|
||||
|
||||
55
scripts/mysql/034_remove_usd_balance_withdrawal_requests.sql
Normal file
55
scripts/mysql/034_remove_usd_balance_withdrawal_requests.sql
Normal 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'
|
||||
);
|
||||
@ -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)) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 '等级',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user