主播政策相关
This commit is contained in:
parent
d4a9fbec01
commit
c9d8c75d59
@ -3954,6 +3954,8 @@ type FreezePointWithdrawalRequest struct {
|
||||
Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
AppCode string `protobuf:"bytes,10,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
WithdrawalRef string `protobuf:"bytes,11,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"`
|
||||
// region_id 只能由 gateway 从登录用户资料注入,客户端请求体不能决定工资政策边界。
|
||||
RegionId int64 `protobuf:"varint,12,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -4065,6 +4067,13 @@ func (x *FreezePointWithdrawalRequest) GetWithdrawalRef() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *FreezePointWithdrawalRequest) GetRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.RegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type FreezePointWithdrawalResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
@ -4397,8 +4406,10 @@ type ReleasePointWithdrawalRequest struct {
|
||||
Reason string `protobuf:"bytes,10,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
AppCode string `protobuf:"bytes,11,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
WithdrawalApplicationId string `protobuf:"bytes,12,opt,name=withdrawal_application_id,json=withdrawalApplicationId,proto3" json:"withdrawal_application_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// withdrawal_ref 只用于申请创建失败回滚对应的次数 reservation;人工驳回不传。
|
||||
WithdrawalRef string `protobuf:"bytes,13,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ReleasePointWithdrawalRequest) Reset() {
|
||||
@ -4515,6 +4526,13 @@ func (x *ReleasePointWithdrawalRequest) GetWithdrawalApplicationId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReleasePointWithdrawalRequest) GetWithdrawalRef() string {
|
||||
if x != nil {
|
||||
return x.WithdrawalRef
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ReleasePointWithdrawalResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
@ -25814,8 +25832,10 @@ type TransferPointToCoinSellerRequest struct {
|
||||
// source_country_code 由 gateway 从用户资料读取,wallet 会再次校验币商服务国家。
|
||||
SourceCountryCode string `protobuf:"bytes,6,opt,name=source_country_code,json=sourceCountryCode,proto3" json:"source_country_code,omitempty"`
|
||||
Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// region_id 只能由 gateway 从登录用户资料注入,用于解析当前 UTC 月工资政策。
|
||||
RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TransferPointToCoinSellerRequest) Reset() {
|
||||
@ -25897,6 +25917,13 @@ func (x *TransferPointToCoinSellerRequest) GetReason() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TransferPointToCoinSellerRequest) GetRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.RegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type TransferPointToCoinSellerResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
@ -26899,7 +26926,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\x1fReleaseSalaryWithdrawalResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12(\n" +
|
||||
"\x10salary_usd_minor\x18\x03 \x01(\x03R\x0esalaryUsdMinor\"\x90\x03\n" +
|
||||
"\x10salary_usd_minor\x18\x03 \x01(\x03R\x0esalaryUsdMinor\"\xad\x03\n" +
|
||||
"\x1cFreezePointWithdrawalRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
|
||||
@ -26914,7 +26941,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\x06reason\x18\t \x01(\tR\x06reason\x12\x19\n" +
|
||||
"\bapp_code\x18\n" +
|
||||
" \x01(\tR\aappCode\x12%\n" +
|
||||
"\x0ewithdrawal_ref\x18\v \x01(\tR\rwithdrawalRef\"\xc0\x02\n" +
|
||||
"\x0ewithdrawal_ref\x18\v \x01(\tR\rwithdrawalRef\x12\x1b\n" +
|
||||
"\tregion_id\x18\f \x01(\x03R\bregionId\"\xc0\x02\n" +
|
||||
"\x1dFreezePointWithdrawalResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" +
|
||||
@ -26946,7 +26974,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\x10fee_point_amount\x18\x04 \x01(\x03R\x0efeePointAmount\x12(\n" +
|
||||
"\x10net_point_amount\x18\x05 \x01(\x03R\x0enetPointAmount\x12$\n" +
|
||||
"\x0epoints_per_usd\x18\x06 \x01(\x03R\fpointsPerUsd\x12\x17\n" +
|
||||
"\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xd0\x03\n" +
|
||||
"\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xf7\x03\n" +
|
||||
"\x1dReleasePointWithdrawalRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
|
||||
@ -26962,7 +26990,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\x06reason\x18\n" +
|
||||
" \x01(\tR\x06reason\x12\x19\n" +
|
||||
"\bapp_code\x18\v \x01(\tR\aappCode\x12:\n" +
|
||||
"\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\"\xc1\x02\n" +
|
||||
"\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\x12%\n" +
|
||||
"\x0ewithdrawal_ref\x18\r \x01(\tR\rwithdrawalRef\"\xc1\x02\n" +
|
||||
"\x1eReleasePointWithdrawalResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" +
|
||||
@ -29109,7 +29138,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\fcountry_code\x18\x03 \x01(\tR\vcountryCode\x12)\n" +
|
||||
"\x10include_disabled\x18\x04 \x01(\bR\x0fincludeDisabled\"t\n" +
|
||||
"&ListPointWithdrawalCoinSellersResponse\x12J\n" +
|
||||
"\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\x93\x02\n" +
|
||||
"\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\xb0\x02\n" +
|
||||
" TransferPointToCoinSellerRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" +
|
||||
@ -29118,7 +29147,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\x0eseller_user_id\x18\x04 \x01(\x03R\fsellerUserId\x12!\n" +
|
||||
"\fpoint_amount\x18\x05 \x01(\x03R\vpointAmount\x12.\n" +
|
||||
"\x13source_country_code\x18\x06 \x01(\tR\x11sourceCountryCode\x12\x16\n" +
|
||||
"\x06reason\x18\a \x01(\tR\x06reason\"\xf1\x02\n" +
|
||||
"\x06reason\x18\a \x01(\tR\x06reason\x12\x1b\n" +
|
||||
"\tregion_id\x18\b \x01(\x03R\bregionId\"\xf1\x02\n" +
|
||||
"!TransferPointToCoinSellerResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12;\n" +
|
||||
"\x1asource_point_balance_after\x18\x02 \x01(\x03R\x17sourcePointBalanceAfter\x120\n" +
|
||||
|
||||
@ -499,6 +499,8 @@ message FreezePointWithdrawalRequest {
|
||||
string reason = 9;
|
||||
string app_code = 10;
|
||||
string withdrawal_ref = 11;
|
||||
// region_id 只能由 gateway 从登录用户资料注入,客户端请求体不能决定工资政策边界。
|
||||
int64 region_id = 12;
|
||||
}
|
||||
|
||||
message FreezePointWithdrawalResponse {
|
||||
@ -551,6 +553,8 @@ message ReleasePointWithdrawalRequest {
|
||||
string reason = 10;
|
||||
string app_code = 11;
|
||||
string withdrawal_application_id = 12;
|
||||
// withdrawal_ref 只用于申请创建失败回滚对应的次数 reservation;人工驳回不传。
|
||||
string withdrawal_ref = 13;
|
||||
}
|
||||
|
||||
message ReleasePointWithdrawalResponse {
|
||||
@ -2967,6 +2971,8 @@ message TransferPointToCoinSellerRequest {
|
||||
// source_country_code 由 gateway 从用户资料读取,wallet 会再次校验币商服务国家。
|
||||
string source_country_code = 6;
|
||||
string reason = 7;
|
||||
// region_id 只能由 gateway 从登录用户资料注入,用于解析当前 UTC 月工资政策。
|
||||
int64 region_id = 8;
|
||||
}
|
||||
|
||||
message TransferPointToCoinSellerResponse {
|
||||
|
||||
217
docs/flutter对接/Fami积分兑换金币Flutter对接.md
Normal file
217
docs/flutter对接/Fami积分兑换金币Flutter对接.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Fami 积分兑换金币 Flutter 对接
|
||||
|
||||
本文描述 Fami/Huwaa App 查看 POINT 兑换 COIN 比例和执行兑换的 HTTP 接口。页面文案可将 `POINT` 展示为“钻石积分”,但 Flutter 模型和日志建议保留真实资产类型。
|
||||
|
||||
## 1. 通用约定
|
||||
|
||||
Base URL 由 App 环境配置提供:
|
||||
|
||||
```text
|
||||
线上:https://api.global-interaction.com
|
||||
本地:http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization: Bearer {access_token}` | 是 | 当前登录用户 token。 |
|
||||
| `X-App-Code: fami` | 是 | Fami App 固定传 `fami`;不允许用户在页面切换 App。 |
|
||||
| `Content-Type: application/json` | POST 是 | 兑换请求体为 JSON。 |
|
||||
|
||||
成功 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 只在 `code == "OK"` 时解析 `data`;失败日志记录 path、`code` 和 `request_id`,不记录 token。
|
||||
|
||||
## 2. 查看积分兑换比例
|
||||
|
||||
```http
|
||||
GET /api/v1/point-wallet/exchange-config
|
||||
Authorization: Bearer {access_token}
|
||||
X-App-Code: fami
|
||||
```
|
||||
|
||||
无 query 和 body。地区由 gateway 根据当前登录用户资料确定,Flutter 不提交 `region_id`。
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_config",
|
||||
"data": {
|
||||
"source_asset_type": "POINT",
|
||||
"target_asset_type": "COIN",
|
||||
"points_per_usd": 1000000,
|
||||
"coins_per_usd": 80000,
|
||||
"ratio_point_amount": 1000000,
|
||||
"ratio_coin_amount": 80000,
|
||||
"rounding_mode": "floor",
|
||||
"policy_instance_code": "fami_salary_policy_202607"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `points_per_usd` | 当前用户地区已发布工资政策中的 POINT/USD 比例。 |
|
||||
| `coins_per_usd` | 统一工资兑金币比例,当前为 `80000`。 |
|
||||
| `ratio_point_amount` / `ratio_coin_amount` | Flutter 直接展示的 POINT → COIN 比例。 |
|
||||
| `rounding_mode` | 目标金币使用整数向下取整。 |
|
||||
| `policy_instance_code` | 已发布政策快照编码,可用于客户端调试日志。 |
|
||||
|
||||
预览公式:
|
||||
|
||||
```text
|
||||
coinAmount = floor(pointAmount * ratio_coin_amount / ratio_point_amount)
|
||||
```
|
||||
|
||||
预览只用于界面展示,不能作为入账事实。如果 Admin 在用户确认前发布了新政策,兑换接口会在账变事务内重新读取当前政策。
|
||||
|
||||
## 3. 积分兑换金币
|
||||
|
||||
```http
|
||||
POST /api/v1/point-wallet/exchange-to-coins
|
||||
Authorization: Bearer {access_token}
|
||||
X-App-Code: fami
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `command_id` | string | 是 | 最大 128 字节的幂等键,建议 `point_exchange_` + UUID v4。 |
|
||||
| `point_amount` | int64 | 是 | 要扣减的 POINT 整数,必须大于 0。 |
|
||||
|
||||
客户端不提交 `user_id`、`region_id`、`coin_amount`、兑换比例或政策编码。
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "point_exchange_550e8400-e29b-41d4-a716-446655440000",
|
||||
"point_amount": 1000000
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_exchange",
|
||||
"data": {
|
||||
"transaction_id": "fami_xxx",
|
||||
"point_amount": 1000000,
|
||||
"coin_amount": 80000,
|
||||
"point_balance_after": 2500000,
|
||||
"coin_balance_after": 180000,
|
||||
"ratio_point_amount": 1000000,
|
||||
"ratio_coin_amount": 80000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
账务语义:
|
||||
|
||||
- wallet-service 在同一 MySQL 事务中扣减本人 `POINT` 并增加本人 `COIN`。
|
||||
- Flutter 成功后必须用 `point_balance_after` 和 `coin_balance_after` 覆盖本地余额,不要用预览值自行加减。
|
||||
- 超时或 5xx 重试必须复用原 `command_id` 和完全相同的 `point_amount`。用户发起新一次兑换时生成新 `command_id`。
|
||||
- 最终兑换比例以成功回执的 `ratio_point_amount` / `ratio_coin_amount` 为准。
|
||||
|
||||
## 4. Flutter DTO 示例
|
||||
|
||||
```dart
|
||||
class PointCoinExchangeConfig {
|
||||
const PointCoinExchangeConfig({
|
||||
required this.pointsPerUsd,
|
||||
required this.coinsPerUsd,
|
||||
required this.ratioPointAmount,
|
||||
required this.ratioCoinAmount,
|
||||
required this.roundingMode,
|
||||
required this.policyInstanceCode,
|
||||
});
|
||||
|
||||
final int pointsPerUsd;
|
||||
final int coinsPerUsd;
|
||||
final int ratioPointAmount;
|
||||
final int ratioCoinAmount;
|
||||
final String roundingMode;
|
||||
final String policyInstanceCode;
|
||||
|
||||
int previewCoins(int pointAmount) =>
|
||||
pointAmount * ratioCoinAmount ~/ ratioPointAmount;
|
||||
|
||||
factory PointCoinExchangeConfig.fromJson(Map<String, dynamic> json) {
|
||||
return PointCoinExchangeConfig(
|
||||
pointsPerUsd: (json['points_per_usd'] as num).toInt(),
|
||||
coinsPerUsd: (json['coins_per_usd'] as num).toInt(),
|
||||
ratioPointAmount: (json['ratio_point_amount'] as num).toInt(),
|
||||
ratioCoinAmount: (json['ratio_coin_amount'] as num).toInt(),
|
||||
roundingMode: json['rounding_mode'] as String? ?? 'floor',
|
||||
policyInstanceCode: json['policy_instance_code'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PointCoinExchangeReceipt {
|
||||
const PointCoinExchangeReceipt({
|
||||
required this.transactionId,
|
||||
required this.pointAmount,
|
||||
required this.coinAmount,
|
||||
required this.pointBalanceAfter,
|
||||
required this.coinBalanceAfter,
|
||||
required this.ratioPointAmount,
|
||||
required this.ratioCoinAmount,
|
||||
});
|
||||
|
||||
final String transactionId;
|
||||
final int pointAmount;
|
||||
final int coinAmount;
|
||||
final int pointBalanceAfter;
|
||||
final int coinBalanceAfter;
|
||||
final int ratioPointAmount;
|
||||
final int ratioCoinAmount;
|
||||
|
||||
factory PointCoinExchangeReceipt.fromJson(Map<String, dynamic> json) {
|
||||
return PointCoinExchangeReceipt(
|
||||
transactionId: json['transaction_id'] as String? ?? '',
|
||||
pointAmount: (json['point_amount'] as num).toInt(),
|
||||
coinAmount: (json['coin_amount'] as num).toInt(),
|
||||
pointBalanceAfter: (json['point_balance_after'] as num).toInt(),
|
||||
coinBalanceAfter: (json['coin_balance_after'] as num).toInt(),
|
||||
ratioPointAmount: (json['ratio_point_amount'] as num).toInt(),
|
||||
ratioCoinAmount: (json['ratio_coin_amount'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 常见错误
|
||||
|
||||
| HTTP | `code` | Flutter 处理 |
|
||||
| --- | --- | --- |
|
||||
| 400 | `INVALID_ARGUMENT` | `point_amount`/`command_id` 不合法,阻止提交并记录客户端参数问题。 |
|
||||
| 401 | `UNAUTHORIZED` | 进入统一刷新 token/重新登录流程。 |
|
||||
| 403 | `PERMISSION_DENIED` | 当前 App 不支持,或用户地区没有已发布兑换政策;隐藏兑换入口。 |
|
||||
| 409 | `INSUFFICIENT_BALANCE` | 刷新 POINT 余额并提示余额不足。 |
|
||||
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 被用于不同参数;不要自动换键重试。 |
|
||||
| 502/503 | `UPSTREAM_ERROR` / `UNAVAILABLE` | 保留原 `command_id`,允许用户重试原操作。 |
|
||||
|
||||
## 6. 相关 IM
|
||||
|
||||
无。积分兑换是私有钱包账变,不发送房间或 C2C IM。App 以 HTTP 成功回执更新当前页面,其他页面进入时重新读取钱包余额。
|
||||
@ -93,6 +93,7 @@ var catalog = map[Code]Spec{
|
||||
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
|
||||
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "payment reference already used"),
|
||||
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "request payload does not match previous command"),
|
||||
PointWithdrawalLimitReached: spec(codes.ResourceExhausted, 429, PointWithdrawalLimitReached, "withdrawal limit reached"),
|
||||
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
|
||||
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
|
||||
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
|
||||
|
||||
@ -101,6 +101,8 @@ const (
|
||||
CoinSellerPaymentRefDuplicated Code = "COIN_SELLER_PAYMENT_REF_DUPLICATED"
|
||||
// IdempotencyConflict 表示 command_id 相同但业务 payload 不一致。
|
||||
IdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
|
||||
// PointWithdrawalLimitReached 表示用户已达到月工资政策配置的 UTC 日/周/月提现次数上限。
|
||||
PointWithdrawalLimitReached Code = "POINT_WITHDRAWAL_LIMIT_REACHED"
|
||||
// VIPLevelNotFound 表示目标 VIP 等级不存在。
|
||||
VIPLevelNotFound Code = "VIP_LEVEL_NOT_FOUND"
|
||||
// VIPLevelDisabled 表示目标 VIP 等级当前不可购买。
|
||||
|
||||
@ -135,6 +135,14 @@ func TestCatalogMappings(t *testing.T) {
|
||||
publicCode: string(LedgerConflict),
|
||||
publicMessage: "wallet state changed, please retry",
|
||||
},
|
||||
{
|
||||
name: "point withdrawal policy limit maps to http 429",
|
||||
code: PointWithdrawalLimitReached,
|
||||
grpcCode: codes.ResourceExhausted,
|
||||
httpStatus: 429,
|
||||
publicCode: string(PointWithdrawalLimitReached),
|
||||
publicMessage: "withdrawal limit reached",
|
||||
},
|
||||
{
|
||||
name: "country cooldown keeps actionable app message",
|
||||
code: CountryChangeCooldown,
|
||||
|
||||
@ -430,10 +430,12 @@ type HostAgencySalaryPolicy struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null;default:lalu" json:"appCode"`
|
||||
Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"`
|
||||
// RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。
|
||||
// RegionID 只保存 RegionIDs 中最小的兼容值;真实适用边界由关联表按用户区域匹配。
|
||||
RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"regionId"`
|
||||
// RegionIDs 是这份政策实际覆盖的区域集合;RegionID 只保留首个值用于旧客户端和旧索引兼容。
|
||||
RegionIDs []int64 `gorm:"-" json:"regionIds"`
|
||||
// CycleKey 是工资归属月;PolicyVersion 使用政策主键形成稳定版本号,发布后两者均不可修改。
|
||||
CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"`
|
||||
CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"`
|
||||
PolicyVersion uint64 `gorm:"column:policy_version;uniqueIndex:uk_admin_host_agency_salary_policy_version;not null;default:0" json:"policyVersion"`
|
||||
Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"`
|
||||
// SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。
|
||||
@ -443,17 +445,25 @@ type HostAgencySalaryPolicy struct {
|
||||
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
|
||||
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
|
||||
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
|
||||
// 时间范围保留为运营展示元数据;工资结算只按 CycleKey 绑定版本,不能再用执行时间选政策。
|
||||
// 提现次数限制随月度政策形成不可变快照;count=0 表示不限,period 只允许 day/week/month。
|
||||
CoinSellerWithdrawalLimitPeriod string `gorm:"column:coin_seller_withdrawal_limit_period;size:16;not null;default:month" json:"coinSellerWithdrawalLimitPeriod"`
|
||||
CoinSellerWithdrawalLimitCount int64 `gorm:"column:coin_seller_withdrawal_limit_count;not null;default:0" json:"coinSellerWithdrawalLimitCount"`
|
||||
PlatformWithdrawalLimitPeriod string `gorm:"column:platform_withdrawal_limit_period;size:16;not null;default:month" json:"platformWithdrawalLimitPeriod"`
|
||||
PlatformWithdrawalLimitCount int64 `gorm:"column:platform_withdrawal_limit_count;not null;default:0" json:"platformWithdrawalLimitCount"`
|
||||
// Effective* 仅保留兼容既有表结构;后台不再配置或展示,工资结算只按 CycleKey 绑定版本。
|
||||
EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"`
|
||||
EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"`
|
||||
Description string `gorm:"size:255;not null;default:''" json:"description"`
|
||||
CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"`
|
||||
UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"`
|
||||
// 发布字段只记录 admin 配置同步到 wallet 运行快照的结果;结算仍只读取 wallet-service 自己的运行表。
|
||||
PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"`
|
||||
PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"`
|
||||
PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"`
|
||||
PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"`
|
||||
PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"`
|
||||
PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"`
|
||||
PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"`
|
||||
PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"`
|
||||
// CopiedFrom* 记录系统补齐缺失月份时的来源;新月份拥有独立 policy_version,后续编辑不会改动上月口径。
|
||||
CopiedFromPolicyID uint `gorm:"column:copied_from_policy_id;not null;default:0" json:"copiedFromPolicyId"`
|
||||
CopiedFromCycleKey string `gorm:"column:copied_from_cycle_key;size:7;not null;default:''" json:"copiedFromCycleKey"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||
Levels []HostAgencySalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"`
|
||||
@ -463,6 +473,33 @@ func (HostAgencySalaryPolicy) TableName() string {
|
||||
return "admin_host_agency_salary_policies"
|
||||
}
|
||||
|
||||
// HostAgencySalaryPolicyRegion 把一份政策关联到多个区域,避免复制多份可独立漂移的等级配置。
|
||||
type HostAgencySalaryPolicyRegion struct {
|
||||
PolicyID uint `gorm:"column:policy_id;primaryKey"`
|
||||
RegionID int64 `gorm:"column:region_id;primaryKey;index:idx_host_agency_policy_region,priority:1"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;not null"`
|
||||
}
|
||||
|
||||
func (HostAgencySalaryPolicyRegion) TableName() string {
|
||||
return "admin_host_agency_salary_policy_regions"
|
||||
}
|
||||
|
||||
// HostAgencySalaryPolicyActiveScope 是启用政策的数据库级区域/月占位。
|
||||
// 普通区域映射允许一份政策覆盖多区,但无法表达“同一区域同月只能有一份 active 政策”;
|
||||
// 该表用业务主键把并发校验下沉到 MySQL,policy_id 索引用于政策更新、停用和删除时整包释放。
|
||||
type HostAgencySalaryPolicyActiveScope struct {
|
||||
AppCode string `gorm:"column:app_code;size:32;primaryKey"`
|
||||
RegionID int64 `gorm:"column:region_id;primaryKey"`
|
||||
CycleKey string `gorm:"column:cycle_key;size:7;primaryKey"`
|
||||
PolicyID uint `gorm:"column:policy_id;index:idx_host_agency_salary_policy_active_scope_policy,priority:1;not null"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;not null"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null"`
|
||||
}
|
||||
|
||||
func (HostAgencySalaryPolicyActiveScope) TableName() string {
|
||||
return "admin_host_agency_salary_policy_active_scopes"
|
||||
}
|
||||
|
||||
type HostAgencySalaryLevel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PolicyID uint `gorm:"uniqueIndex:uk_admin_host_agency_salary_level;index:idx_admin_host_agency_salary_level_policy,not null" json:"policyId"`
|
||||
|
||||
@ -25,11 +25,18 @@ func New(store *repository.Store, walletDB *sql.DB, audit shared.OperationLogger
|
||||
|
||||
func (h *Handler) ListPolicies(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
cycleKey := firstQuery(c, "cycle_key", "cycleKey")
|
||||
if cycleKey != "" {
|
||||
if _, err := normalizePolicyCycleKey(cycleKey); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
// query 层保留 snake_case/camelCase 两种写法,兼容生成端和手写端调用。
|
||||
items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{
|
||||
items, total, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{
|
||||
Keyword: options.Keyword,
|
||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||
CycleKey: firstQuery(c, "cycle_key", "cycleKey"),
|
||||
CycleKey: cycleKey,
|
||||
Status: options.Status,
|
||||
SettlementMode: firstQuery(c, "settlement_mode", "settlementMode"),
|
||||
// trigger mode 可直接筛出“只允许手动结算”的政策,后续工资结算页会复用同一查询口径。
|
||||
@ -56,7 +63,7 @@ func (h *Handler) CreatePolicy(c *gin.Context) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "create-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID))
|
||||
shared.OperationLogWithResourceID(c, h.audit, "create-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_ids=%v", item.RegionIDs))
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
@ -71,12 +78,12 @@ func (h *Handler) UpdatePolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// PUT 语义是整条政策配置替换,等级明细也会由 service/repository 按整包替换。
|
||||
item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req)
|
||||
item, err := h.service.Update(c.Request.Context(), appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID))
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_ids=%v", item.RegionIDs))
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
@ -103,7 +110,7 @@ func (h *Handler) PublishPolicy(c *gin.Context) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "publish-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d status=%s", item.RegionID, item.Status))
|
||||
shared.OperationLogWithResourceID(c, h.audit, "publish-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_ids=%v status=%s", item.RegionIDs, item.Status))
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
package hostagencypolicy
|
||||
|
||||
type policyRequest struct {
|
||||
Name string `json:"name"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
Status string `json:"status"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
Description string `json:"description"`
|
||||
Levels []levelRequest `json:"levels"`
|
||||
Name string `json:"name"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
Status string `json:"status"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
||||
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
||||
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
|
||||
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
|
||||
Description string `json:"description"`
|
||||
Levels []levelRequest `json:"levels"`
|
||||
}
|
||||
|
||||
type levelRequest struct {
|
||||
|
||||
@ -7,29 +7,35 @@ import (
|
||||
)
|
||||
|
||||
type policyDTO struct {
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"app_code"`
|
||||
Name string `json:"name"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
PolicyVersion uint64 `json:"policy_version"`
|
||||
Status string `json:"status"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
Description string `json:"description"`
|
||||
CreatedByAdminID uint `json:"created_by_admin_id"`
|
||||
UpdatedByAdminID uint `json:"updated_by_admin_id"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
PublishError string `json:"publish_error"`
|
||||
PublishedAtMS int64 `json:"published_at_ms"`
|
||||
PublishedByAdminID uint `json:"published_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Levels []levelDTO `json:"levels"`
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"app_code"`
|
||||
Name string `json:"name"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
PolicyVersion uint64 `json:"policy_version"`
|
||||
Status string `json:"status"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
||||
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
||||
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
|
||||
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
|
||||
Description string `json:"description"`
|
||||
CreatedByAdminID uint `json:"created_by_admin_id"`
|
||||
UpdatedByAdminID uint `json:"updated_by_admin_id"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
PublishError string `json:"publish_error"`
|
||||
PublishedAtMS int64 `json:"published_at_ms"`
|
||||
PublishedByAdminID uint `json:"published_by_admin_id"`
|
||||
CopiedFromPolicyID uint `json:"copied_from_policy_id"`
|
||||
CopiedFromCycleKey string `json:"copied_from_cycle_key"`
|
||||
Inherited bool `json:"inherited"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Levels []levelDTO `json:"levels"`
|
||||
}
|
||||
|
||||
type levelDTO struct {
|
||||
@ -54,32 +60,48 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
|
||||
}
|
||||
// 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。
|
||||
return policyDTO{
|
||||
ID: item.ID,
|
||||
AppCode: item.AppCode,
|
||||
Name: item.Name,
|
||||
RegionID: item.RegionID,
|
||||
CycleKey: item.CycleKey,
|
||||
PolicyVersion: item.PolicyVersion,
|
||||
Status: item.Status,
|
||||
SettlementMode: item.SettlementMode,
|
||||
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
|
||||
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
|
||||
EffectiveFromMS: item.EffectiveFromMS,
|
||||
EffectiveToMS: item.EffectiveToMS,
|
||||
Description: item.Description,
|
||||
CreatedByAdminID: item.CreatedByAdminID,
|
||||
UpdatedByAdminID: item.UpdatedByAdminID,
|
||||
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
|
||||
PublishError: item.PublishError,
|
||||
PublishedAtMS: item.PublishedAtMS,
|
||||
PublishedByAdminID: item.PublishedByAdminID,
|
||||
CreatedAtMS: item.CreatedAtMS,
|
||||
UpdatedAtMS: item.UpdatedAtMS,
|
||||
Levels: levels,
|
||||
ID: item.ID,
|
||||
AppCode: item.AppCode,
|
||||
Name: item.Name,
|
||||
RegionID: item.RegionID,
|
||||
RegionIDs: responseRegionIDs(item),
|
||||
CycleKey: item.CycleKey,
|
||||
PolicyVersion: item.PolicyVersion,
|
||||
Status: item.Status,
|
||||
SettlementMode: item.SettlementMode,
|
||||
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
|
||||
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
|
||||
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
||||
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
|
||||
PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
||||
PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount,
|
||||
Description: item.Description,
|
||||
CreatedByAdminID: item.CreatedByAdminID,
|
||||
UpdatedByAdminID: item.UpdatedByAdminID,
|
||||
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
|
||||
PublishError: item.PublishError,
|
||||
PublishedAtMS: item.PublishedAtMS,
|
||||
PublishedByAdminID: item.PublishedByAdminID,
|
||||
CopiedFromPolicyID: item.CopiedFromPolicyID,
|
||||
CopiedFromCycleKey: item.CopiedFromCycleKey,
|
||||
Inherited: item.CopiedFromPolicyID > 0 && item.CopiedFromCycleKey != "",
|
||||
CreatedAtMS: item.CreatedAtMS,
|
||||
UpdatedAtMS: item.UpdatedAtMS,
|
||||
Levels: levels,
|
||||
}
|
||||
}
|
||||
|
||||
func responseRegionIDs(item model.HostAgencySalaryPolicy) []int64 {
|
||||
if len(item.RegionIDs) > 0 {
|
||||
return append([]int64(nil), item.RegionIDs...)
|
||||
}
|
||||
if item.RegionID > 0 {
|
||||
return []int64{item.RegionID}
|
||||
}
|
||||
return []int64{}
|
||||
}
|
||||
|
||||
func levelFromModel(item model.HostAgencySalaryLevel) levelDTO {
|
||||
hostSalary := trimDecimalZeros(item.HostSalaryUSD)
|
||||
agencySalary := trimDecimalZeros(item.AgencySalaryUSD)
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -12,6 +13,8 @@ import (
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -35,6 +38,10 @@ const (
|
||||
publishStatusDraft = "draft"
|
||||
publishStatusPublished = "published"
|
||||
publishStatusFailed = "failed"
|
||||
|
||||
withdrawalLimitPeriodDay = "day"
|
||||
withdrawalLimitPeriodWeek = "week"
|
||||
withdrawalLimitPeriodMonth = "month"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@ -46,9 +53,19 @@ func NewService(store *repository.Store, walletDB *sql.DB) *Service {
|
||||
return &Service{store: store, walletDB: walletDB}
|
||||
}
|
||||
|
||||
func (s *Service) List(appCode string, options repository.HostAgencySalaryPolicyListOptions) ([]policyDTO, int64, error) {
|
||||
func (s *Service) List(ctx context.Context, appCode string, options repository.HostAgencySalaryPolicyListOptions) ([]policyDTO, int64, error) {
|
||||
// 列表入口统一收敛 app_code、状态和结算方式,避免前端传别名或非法值时把无效条件下推到 DB。
|
||||
options.AppCode = appctx.Normalize(appCode)
|
||||
if cycleKey := strings.TrimSpace(options.CycleKey); cycleKey != "" {
|
||||
if _, err := normalizePolicyCycleKey(cycleKey); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// 运营首次打开缺少配置的月份时,系统把紧邻上月的已发布政策复制成独立、可编辑的草稿版本。
|
||||
// wallet 同时保留请求时绑定继承,因此草稿发布前也沿用上月口径;运营调整后发布会安全接管继承绑定。
|
||||
if err := s.ensureMonthlyPolicyCopies(ctx, options.AppCode, cycleKey); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
options.Status = normalizeStatusFilter(options.Status)
|
||||
options.SettlementMode = normalizeSettlementModeFilter(options.SettlementMode)
|
||||
options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode)
|
||||
@ -63,29 +80,124 @@ func (s *Service) List(appCode string, options repository.HostAgencySalaryPolicy
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureMonthlyPolicyCopies(_ context.Context, appCode string, cycleKey string) error {
|
||||
previousCycleKey, err := previousPolicyCycleKey(cycleKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sources, err := s.store.ListPublishedActiveHostAgencySalaryPoliciesForCycle(appCode, previousCycleKey)
|
||||
if err != nil || len(sources) == 0 {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
covered, err := s.store.ListHostAgencySalaryPolicyRegionIDsForCycle(appCode, cycleKey, source.RegionIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missingRegionIDs := missingPolicyRegionIDs(source.RegionIDs, covered)
|
||||
if len(missingRegionIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
clone := cloneHostAgencySalaryPolicyForCycle(source, cycleKey)
|
||||
clone.RegionIDs = missingRegionIDs
|
||||
clone.RegionID = missingRegionIDs[0]
|
||||
if err := s.store.CreateHostAgencySalaryPolicy(&clone); err != nil {
|
||||
// 人工创建或另一份自动副本可能刚占用同一区域/月;数据库占位是最终裁决,
|
||||
// 当前副本整包回滚后下一次列表会按最新覆盖范围补剩余区域。
|
||||
if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
|
||||
continue
|
||||
}
|
||||
// GET 可能被多个后台实例同时触发;月+名称唯一键只允许一个副本,竞争失败方直接读取胜者。
|
||||
if isDuplicatePolicyCopy(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 自动副本保持 draft:直接发布会让运营无法修改 8 月副本;运行侧在此期间已通过上月 binding 继承保证不断档。
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func missingPolicyRegionIDs(source []int64, covered []int64) []int64 {
|
||||
coveredSet := make(map[int64]struct{}, len(covered))
|
||||
for _, regionID := range covered {
|
||||
coveredSet[regionID] = struct{}{}
|
||||
}
|
||||
missing := make([]int64, 0, len(source))
|
||||
for _, regionID := range source {
|
||||
if _, exists := coveredSet[regionID]; !exists {
|
||||
missing = append(missing, regionID)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func cloneHostAgencySalaryPolicyForCycle(source model.HostAgencySalaryPolicy, cycleKey string) model.HostAgencySalaryPolicy {
|
||||
clone := source
|
||||
clone.ID = 0
|
||||
clone.CycleKey = cycleKey
|
||||
clone.PolicyVersion = 0
|
||||
clone.PublishStatus = publishStatusDraft
|
||||
clone.PublishError = ""
|
||||
clone.PublishedAtMS = 0
|
||||
clone.PublishedByAdminID = 0
|
||||
clone.CopiedFromPolicyID = source.ID
|
||||
clone.CopiedFromCycleKey = source.CycleKey
|
||||
clone.CreatedByAdminID = 0
|
||||
clone.UpdatedByAdminID = 0
|
||||
clone.CreatedAtMS = 0
|
||||
clone.UpdatedAtMS = 0
|
||||
clone.RegionIDs = append([]int64(nil), source.RegionIDs...)
|
||||
clone.Levels = append([]model.HostAgencySalaryLevel(nil), source.Levels...)
|
||||
for index := range clone.Levels {
|
||||
clone.Levels[index].ID = 0
|
||||
clone.Levels[index].PolicyID = 0
|
||||
clone.Levels[index].CreatedAtMS = 0
|
||||
clone.Levels[index].UpdatedAtMS = 0
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func previousPolicyCycleKey(cycleKey string) (string, error) {
|
||||
parsed, err := time.Parse("2006-01", strings.TrimSpace(cycleKey))
|
||||
if err != nil || parsed.UTC().Format("2006-01") != strings.TrimSpace(cycleKey) {
|
||||
return "", errors.New("工资周期格式必须是 YYYY-MM")
|
||||
}
|
||||
return parsed.AddDate(0, -1, 0).UTC().Format("2006-01"), nil
|
||||
}
|
||||
|
||||
func isDuplicatePolicyCopy(err error) bool {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) {
|
||||
// 创建时先把请求规整为模型,所有金额和等级递增规则都在这个边界完成,仓储层只负责持久化。
|
||||
item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req)
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
// 启用政策必须先做周期冲突校验,否则同一区域同一月份会出现两套工资规则。
|
||||
// 启用政策必须逐区域做周期冲突校验,否则同一工资周期会在某个覆盖区域出现两套规则。
|
||||
if err := s.ensureNoActiveOverlap(item, 0); err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if err := s.store.CreateHostAgencySalaryPolicy(&item); err != nil {
|
||||
return policyDTO{}, err
|
||||
return policyDTO{}, normalizePolicyPersistenceError(err)
|
||||
}
|
||||
return policyFromModel(item), nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) {
|
||||
func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) {
|
||||
// 更新先读原记录,保证 app_code 和主键归属来自数据库,避免请求体越权改租户。
|
||||
item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 {
|
||||
runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item)
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished {
|
||||
// 运行侧已经按周期绑定该版本;编辑会让历史结算口径漂移,只能为新周期新建政策。
|
||||
return policyDTO{}, errors.New("已发布的周期政策版本不可修改,请为新周期创建政策")
|
||||
}
|
||||
@ -96,14 +208,17 @@ func (s *Service) Update(appCode string, actorID uint, id uint, req policyReques
|
||||
}
|
||||
item.Name = updated.Name
|
||||
item.RegionID = updated.RegionID
|
||||
item.RegionIDs = updated.RegionIDs
|
||||
item.CycleKey = updated.CycleKey
|
||||
item.Status = updated.Status
|
||||
item.SettlementMode = updated.SettlementMode
|
||||
item.SettlementTriggerMode = updated.SettlementTriggerMode
|
||||
item.GiftCoinToDiamondRatio = updated.GiftCoinToDiamondRatio
|
||||
item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
|
||||
item.EffectiveFromMS = updated.EffectiveFromMS
|
||||
item.EffectiveToMS = updated.EffectiveToMS
|
||||
item.CoinSellerWithdrawalLimitPeriod = updated.CoinSellerWithdrawalLimitPeriod
|
||||
item.CoinSellerWithdrawalLimitCount = updated.CoinSellerWithdrawalLimitCount
|
||||
item.PlatformWithdrawalLimitPeriod = updated.PlatformWithdrawalLimitPeriod
|
||||
item.PlatformWithdrawalLimitCount = updated.PlatformWithdrawalLimitCount
|
||||
item.Description = updated.Description
|
||||
item.UpdatedByAdminID = actorID
|
||||
// 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft。
|
||||
@ -115,18 +230,22 @@ func (s *Service) Update(appCode string, actorID uint, id uint, req policyReques
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if err := s.store.UpdateHostAgencySalaryPolicy(&item); err != nil {
|
||||
return policyDTO{}, err
|
||||
return policyDTO{}, normalizePolicyPersistenceError(err)
|
||||
}
|
||||
return policyFromModel(item), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(_ context.Context, appCode string, id uint) error {
|
||||
func (s *Service) Delete(ctx context.Context, appCode string, id uint) error {
|
||||
// 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。
|
||||
item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 {
|
||||
runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished {
|
||||
// 已发布版本及其周期绑定是历史结算依据;停用或删除都不能破坏已存在月份的可追溯性。
|
||||
return errors.New("已发布的周期政策版本不可删除")
|
||||
}
|
||||
@ -152,7 +271,7 @@ func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id
|
||||
publishedAtMS := time.Now().UTC().UnixMilli()
|
||||
if err := s.publishRuntimePolicy(ctx, item, publishedAtMS); err != nil {
|
||||
// 发布失败也写回状态,方便后台列表明确展示“配置未进入 wallet 运行态”的事实。
|
||||
_ = s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusFailed, truncateRunes(err.Error(), 255), item.PublishedAtMS, actorID)
|
||||
_ = s.store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished(appCode, id, truncateRunes(err.Error(), 255), actorID)
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if err := s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusPublished, "", publishedAtMS, actorID); err != nil {
|
||||
@ -165,17 +284,53 @@ func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id
|
||||
return policyFromModel(item), nil
|
||||
}
|
||||
|
||||
func normalizePolicyPersistenceError(err error) error {
|
||||
if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
|
||||
return errors.New("同一区域同一工资周期只能存在一个启用政策")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) runtimePolicySnapshotExists(ctx context.Context, item model.HostAgencySalaryPolicy) (bool, error) {
|
||||
if s == nil || s.walletDB == nil {
|
||||
// 编辑/删除属于破坏不可变发布事实的高风险动作;无法核对 wallet 时必须失败关闭,不能假定未发布。
|
||||
return false, errors.New("wallet mysql is not configured")
|
||||
}
|
||||
if item.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
policyVersion := item.PolicyVersion
|
||||
if policyVersion == 0 {
|
||||
policyVersion = uint64(item.ID)
|
||||
}
|
||||
var exists int
|
||||
err := s.walletDB.QueryRowContext(ctx, `
|
||||
SELECT 1
|
||||
FROM host_agency_salary_policies
|
||||
WHERE app_code = ? AND policy_id = ? AND policy_version = ?
|
||||
LIMIT 1`, item.AppCode, item.ID, policyVersion).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, excludeID uint) error {
|
||||
// 停用政策允许提前配置多套草稿;只有 active 才会参与结算匹配,所以冲突检查只约束启用态。
|
||||
if item.Status != policyStatusActive {
|
||||
return nil
|
||||
}
|
||||
overlap, err := s.store.HasActiveHostAgencySalaryPolicyForCycle(item.AppCode, item.RegionID, item.CycleKey, excludeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if overlap {
|
||||
return errors.New("同一区域同一工资周期只能存在一个启用政策")
|
||||
for _, regionID := range item.RegionIDs {
|
||||
overlap, err := s.store.HasActiveHostAgencySalaryPolicyForCycle(item.AppCode, regionID, item.CycleKey, excludeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if overlap {
|
||||
return errors.New("同一区域同一工资周期只能存在一个启用政策")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -198,71 +353,240 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
|
||||
// 仅兼容迁移前创建但尚未发布的草稿;新记录在 admin 仓储创建事务中固定为 policy ID。
|
||||
policyVersion = uint64(item.ID)
|
||||
}
|
||||
// policy 主表和等级表只允许首次插入;任何重复主键或同周期绑定冲突都会回滚整包发布,禁止覆盖历史版本。
|
||||
// 跨库状态回写失败后重试时,wallet 可能已经拥有完整快照;内容一致视为幂等成功,
|
||||
// 但任何业务字段或等级不一致都拒绝覆盖,继续保持已发布版本不可变。
|
||||
if err := ensureRuntimeHostSalaryPolicySnapshot(ctx, tx, item, policyVersion, publishedAtMS); err != nil {
|
||||
return err
|
||||
}
|
||||
// 每个区域各自拥有不可覆盖的周期绑定,但同一政策快照和等级只写入一次,避免多区域版本漂移。
|
||||
// 系统继承绑定的 source_cycle_key 指向上月,允许随后自动生成或人工创建的本月独立版本接管;显式绑定永不覆盖。
|
||||
for _, regionID := range item.RegionIDs {
|
||||
if err := bindExplicitHostSalaryPolicy(ctx, tx, item, regionID, policyVersion, publishedAtMS); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type runtimeHostSalaryPolicySnapshot struct {
|
||||
CycleKey string
|
||||
PolicyVersion uint64
|
||||
Name string
|
||||
RegionID int64
|
||||
Status string
|
||||
SettlementMode string
|
||||
SettlementTriggerMode string
|
||||
GiftCoinToDiamondRatio string
|
||||
ResidualDiamondToUSDRate string
|
||||
CoinSellerWithdrawalLimitPeriod string
|
||||
CoinSellerWithdrawalLimitCount int64
|
||||
PlatformWithdrawalLimitPeriod string
|
||||
PlatformWithdrawalLimitCount int64
|
||||
}
|
||||
|
||||
type runtimeHostSalaryLevelSnapshot struct {
|
||||
LevelNo int32
|
||||
RequiredDiamonds int64
|
||||
HostSalaryUSDMinor int64
|
||||
HostCoinReward int64
|
||||
AgencySalaryUSDMinor int64
|
||||
Status string
|
||||
SortOrder int32
|
||||
}
|
||||
|
||||
func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error {
|
||||
// 先以完整快照尝试原子占位;主键已存在时只做 policy_id 自赋值,绝不改写任何业务字段。
|
||||
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO host_agency_salary_policies (
|
||||
app_code, policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate,
|
||||
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode,
|
||||
item.ID,
|
||||
item.CycleKey,
|
||||
policyVersion,
|
||||
item.Name,
|
||||
item.RegionID,
|
||||
item.Status,
|
||||
item.SettlementMode,
|
||||
firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||
item.GiftCoinToDiamondRatio,
|
||||
item.ResidualDiamondToUSDRate,
|
||||
item.EffectiveFromMS,
|
||||
item.EffectiveToMS,
|
||||
nonZeroMS(item.CreatedAtMS, publishedAtMS),
|
||||
publishedAtMS,
|
||||
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id)`,
|
||||
item.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, item.RegionID, item.Status,
|
||||
item.SettlementMode, firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||
item.GiftCoinToDiamondRatio, item.ResidualDiamondToUSDRate,
|
||||
firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
|
||||
firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
|
||||
nonZeroMS(item.CreatedAtMS, publishedAtMS), publishedAtMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, level := range item.Levels {
|
||||
_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
|
||||
|
||||
var existing runtimeHostSalaryPolicySnapshot
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
CAST(gift_coin_to_diamond_ratio AS CHAR), CAST(residual_diamond_to_usd_rate AS CHAR),
|
||||
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count
|
||||
FROM host_agency_salary_policies
|
||||
WHERE app_code = ? AND policy_id = ?
|
||||
FOR UPDATE`, item.AppCode, item.ID).Scan(
|
||||
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.RegionID, &existing.Status,
|
||||
&existing.SettlementMode, &existing.SettlementTriggerMode,
|
||||
&existing.GiftCoinToDiamondRatio, &existing.ResidualDiamondToUSDRate,
|
||||
&existing.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount,
|
||||
&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtimeHostSalaryPolicyMatches(existing, item, policyVersion) {
|
||||
return errors.New("wallet 已存在内容不一致的工资政策快照")
|
||||
}
|
||||
|
||||
expectedLevels, err := runtimeHostSalaryLevelsFromModel(item.Levels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existingLevels) == 0 {
|
||||
// 主快照写锁仍由当前事务持有;只有第一个成功占位的事务会看到空等级并整包插入,
|
||||
// 后续并发发布等待主键锁后会读取完整等级,不会重复插入。
|
||||
for _, level := range expectedLevels {
|
||||
// 运行表保存美分整数,避免 wallet-service 结算时再次解析 decimal 字符串造成口径漂移。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO host_agency_salary_policy_levels (
|
||||
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor,
|
||||
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode, item.ID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder,
|
||||
publishedAtMS, publishedAtMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !runtimeHostSalaryLevelsMatch(existingLevels, expectedLevels) {
|
||||
return errors.New("wallet 已存在内容不一致的工资政策等级快照")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeHostSalaryPolicyMatches(existing runtimeHostSalaryPolicySnapshot, item model.HostAgencySalaryPolicy, policyVersion uint64) bool {
|
||||
return existing.CycleKey == item.CycleKey && existing.PolicyVersion == policyVersion && existing.Name == item.Name &&
|
||||
existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
|
||||
existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
|
||||
trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
|
||||
trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
|
||||
existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
|
||||
existing.CoinSellerWithdrawalLimitCount == item.CoinSellerWithdrawalLimitCount &&
|
||||
existing.PlatformWithdrawalLimitPeriod == firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
|
||||
existing.PlatformWithdrawalLimitCount == item.PlatformWithdrawalLimitCount
|
||||
}
|
||||
|
||||
func runtimeHostSalaryLevelsFromModel(levels []model.HostAgencySalaryLevel) ([]runtimeHostSalaryLevelSnapshot, error) {
|
||||
models := append([]model.HostAgencySalaryLevel(nil), levels...)
|
||||
sort.Slice(models, func(i, j int) bool { return models[i].LevelNo < models[j].LevelNo })
|
||||
out := make([]runtimeHostSalaryLevelSnapshot, 0, len(models))
|
||||
for _, level := range models {
|
||||
_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// 运行表保存美分整数,避免 wallet-service 结算时再解析 decimal 字符串造成重复口径。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO host_agency_salary_policy_levels (
|
||||
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor,
|
||||
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode,
|
||||
item.ID,
|
||||
level.LevelNo,
|
||||
level.RequiredDiamonds,
|
||||
hostSalaryMinor,
|
||||
level.HostCoinReward,
|
||||
agencySalaryMinor,
|
||||
level.Status,
|
||||
level.SortOrder,
|
||||
nonZeroMS(level.CreatedAtMS, publishedAtMS),
|
||||
publishedAtMS,
|
||||
); err != nil {
|
||||
return err
|
||||
out = append(out, runtimeHostSalaryLevelSnapshot{
|
||||
LevelNo: level.LevelNo, RequiredDiamonds: level.RequiredDiamonds, HostSalaryUSDMinor: hostSalaryMinor,
|
||||
HostCoinReward: level.HostCoinReward, AgencySalaryUSDMinor: agencySalaryMinor, Status: level.Status, SortOrder: level.SortOrder,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadRuntimeHostSalaryLevels(ctx context.Context, tx *sql.Tx, appCode string, policyID uint) ([]runtimeHostSalaryLevelSnapshot, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
|
||||
agency_salary_usd_minor, status, sort_order
|
||||
FROM host_agency_salary_policy_levels
|
||||
WHERE app_code = ? AND policy_id = ?
|
||||
ORDER BY level_no ASC`, appCode, policyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var levels []runtimeHostSalaryLevelSnapshot
|
||||
for rows.Next() {
|
||||
var level runtimeHostSalaryLevelSnapshot
|
||||
if err := rows.Scan(&level.LevelNo, &level.RequiredDiamonds, &level.HostSalaryUSDMinor, &level.HostCoinReward,
|
||||
&level.AgencySalaryUSDMinor, &level.Status, &level.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levels = append(levels, level)
|
||||
}
|
||||
return levels, rows.Err()
|
||||
}
|
||||
|
||||
func runtimeHostSalaryLevelsMatch(existing []runtimeHostSalaryLevelSnapshot, expected []runtimeHostSalaryLevelSnapshot) bool {
|
||||
if len(existing) != len(expected) {
|
||||
return false
|
||||
}
|
||||
for index := range expected {
|
||||
if existing[index] != expected[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 周期绑定的复合主键是最终防线:同一区域同一周期只能首次绑定一次,后续月份发布不会影响它。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO host_salary_policy_cycle_bindings (
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode, item.RegionID, item.CycleKey, item.ID, policyVersion, publishedAtMS,
|
||||
); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
func bindExplicitHostSalaryPolicy(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, regionID int64, policyVersion uint64, publishedAtMS int64) error {
|
||||
var existingPolicyID, existingPolicyVersion uint64
|
||||
var sourceCycleKey string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT policy_id, policy_version, source_cycle_key
|
||||
FROM host_salary_policy_cycle_bindings
|
||||
WHERE app_code = ? AND region_id = ? AND cycle_key = ?
|
||||
FOR UPDATE`, item.AppCode, regionID, item.CycleKey,
|
||||
).Scan(&existingPolicyID, &existingPolicyVersion, &sourceCycleKey)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO host_salary_policy_cycle_bindings (
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode, regionID, item.CycleKey, item.ID, policyVersion, item.CycleKey, publishedAtMS,
|
||||
)
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingPolicyID == uint64(item.ID) && existingPolicyVersion == policyVersion {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(sourceCycleKey) == "" || strings.TrimSpace(sourceCycleKey) == item.CycleKey {
|
||||
return errors.New("该区域工资月已经绑定其他显式政策版本")
|
||||
}
|
||||
// 只替换明确标记为跨月继承的绑定;WHERE 再次约束来源,防止未来代码改动误覆盖显式历史版本。
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE host_salary_policy_cycle_bindings
|
||||
SET policy_id = ?, policy_version = ?, source_cycle_key = ?, created_at_ms = ?
|
||||
WHERE app_code = ? AND region_id = ? AND cycle_key = ? AND source_cycle_key <> cycle_key`,
|
||||
item.ID, policyVersion, item.CycleKey, publishedAtMS, item.AppCode, regionID, item.CycleKey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return errors.New("工资月继承绑定已被并发修改,请重试")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.HostAgencySalaryPolicy, error) {
|
||||
@ -270,11 +594,11 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
if name == "" || len([]rune(name)) > 120 {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确")
|
||||
}
|
||||
// 当前阶段保持单区域政策:一个区域同一工资周期只能命中一条 active 规则。
|
||||
if req.RegionID <= 0 {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域")
|
||||
regionIDs, err := normalizePolicyRegionIDs(req.RegionID, req.RegionIDs)
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
cycleKey, err := normalizePolicyCycleKey(req.CycleKey, req.EffectiveFromMS)
|
||||
cycleKey, err := normalizePolicyCycleKey(req.CycleKey)
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
@ -302,9 +626,6 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
if !validSettlementTriggerMode(settlementTriggerMode) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确")
|
||||
}
|
||||
if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("政策生效时间不正确")
|
||||
}
|
||||
// 送礼入主播周期钻石的实际比例已经由“礼物钻石”配置在 wallet 扣礼物时写入周期账户;
|
||||
// 这里不再接受工资政策单独覆盖,避免同一笔礼物在入账和结算阶段出现两套钻石口径。
|
||||
giftRatio, _, err := parseFixedDecimal(defaultGiftCoinToDiamondRatio, 6, false, "金币转钻石比例")
|
||||
@ -316,6 +637,14 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
coinSellerLimitPeriod, coinSellerLimitCount, err := normalizeWithdrawalLimit(req.CoinSellerWithdrawalLimitPeriod, req.CoinSellerWithdrawalLimitCount, "用户找币商提现")
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
platformLimitPeriod, platformLimitCount, err := normalizeWithdrawalLimit(req.PlatformWithdrawalLimitPeriod, req.PlatformWithdrawalLimitCount, "用户找平台提现")
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
description := strings.TrimSpace(req.Description)
|
||||
if len([]rune(description)) > 255 {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符")
|
||||
@ -326,24 +655,43 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
return model.HostAgencySalaryPolicy{
|
||||
AppCode: appCode,
|
||||
Name: name,
|
||||
RegionID: req.RegionID,
|
||||
CycleKey: cycleKey,
|
||||
Status: status,
|
||||
SettlementMode: settlementMode,
|
||||
SettlementTriggerMode: settlementTriggerMode,
|
||||
GiftCoinToDiamondRatio: giftRatio,
|
||||
ResidualDiamondToUSDRate: residualRate,
|
||||
EffectiveFromMS: req.EffectiveFromMS,
|
||||
EffectiveToMS: req.EffectiveToMS,
|
||||
Description: description,
|
||||
CreatedByAdminID: actorID,
|
||||
UpdatedByAdminID: actorID,
|
||||
Levels: levels,
|
||||
AppCode: appCode,
|
||||
Name: name,
|
||||
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
|
||||
RegionID: regionIDs[0],
|
||||
RegionIDs: regionIDs,
|
||||
CycleKey: cycleKey,
|
||||
Status: status,
|
||||
SettlementMode: settlementMode,
|
||||
SettlementTriggerMode: settlementTriggerMode,
|
||||
GiftCoinToDiamondRatio: giftRatio,
|
||||
ResidualDiamondToUSDRate: residualRate,
|
||||
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
|
||||
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
|
||||
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
|
||||
PlatformWithdrawalLimitCount: platformLimitCount,
|
||||
Description: description,
|
||||
CreatedByAdminID: actorID,
|
||||
UpdatedByAdminID: actorID,
|
||||
Levels: levels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeWithdrawalLimit 固定月度政策允许的周期枚举;次数为 0 时保留周期但运行侧直接按不限处理。
|
||||
func normalizeWithdrawalLimit(period string, count int64, field string) (string, int64, error) {
|
||||
period = strings.ToLower(strings.TrimSpace(period))
|
||||
if period == "" {
|
||||
period = withdrawalLimitPeriodMonth
|
||||
}
|
||||
if period != withdrawalLimitPeriodDay && period != withdrawalLimitPeriodWeek && period != withdrawalLimitPeriodMonth {
|
||||
return "", 0, fmt.Errorf("%s限制周期不正确", field)
|
||||
}
|
||||
if count < 0 {
|
||||
return "", 0, fmt.Errorf("%s限制次数不能小于 0", field)
|
||||
}
|
||||
return period, count, nil
|
||||
}
|
||||
|
||||
func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) {
|
||||
if len(requests) == 0 {
|
||||
return nil, errors.New("至少需要配置一个等级")
|
||||
@ -526,11 +874,41 @@ func nonZeroMS(value int64, fallback int64) int64 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func normalizePolicyCycleKey(raw string, _ int64) (string, error) {
|
||||
func normalizePolicyRegionIDs(legacyRegionID int64, rawRegionIDs []int64) ([]int64, error) {
|
||||
// region_ids 是新接口的唯一覆盖集合;region_id 仅在旧客户端未提交数组时回退,避免旧值悄悄扩大新配置范围。
|
||||
regionIDs := rawRegionIDs
|
||||
if len(regionIDs) == 0 && legacyRegionID > 0 {
|
||||
regionIDs = []int64{legacyRegionID}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(regionIDs))
|
||||
normalized := make([]int64, 0, len(regionIDs))
|
||||
for _, regionID := range regionIDs {
|
||||
if regionID <= 0 {
|
||||
return nil, errors.New("请选择适用区域")
|
||||
}
|
||||
if _, exists := seen[regionID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[regionID] = struct{}{}
|
||||
normalized = append(normalized, regionID)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, errors.New("请选择适用区域")
|
||||
}
|
||||
// 固定顺序让首个兼容 region_id 和 API 返回值稳定,避免相同集合因提交顺序不同制造无意义更新。
|
||||
for i := 1; i < len(normalized); i++ {
|
||||
for j := i; j > 0 && normalized[j-1] > normalized[j]; j-- {
|
||||
normalized[j-1], normalized[j] = normalized[j], normalized[j-1]
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizePolicyCycleKey(raw string) (string, error) {
|
||||
cycleKey := strings.TrimSpace(raw)
|
||||
// 工资周期是独立业务键,不能从生效时刻反推:后台的“7 月 1 日 00:00”会按
|
||||
// 工资周期是唯一生效边界,不能从历史 Effective* 字段反推:后台的“7 月 1 日 00:00”会按
|
||||
// 浏览器时区转成 6 月 30 日 UTC,再取 UTC 月份会把 7 月政策错绑到 6 月。
|
||||
// 请求必须显式提交 cycle_key,让生效区间只负责时间范围,周期归属不再受时区换算影响。
|
||||
// 请求必须显式提交 cycle_key;历史时间字段仅兼容既有表结构,不再参与配置、展示或选政策。
|
||||
if len(cycleKey) != len("2006-01") {
|
||||
return "", errors.New("请选择工资周期")
|
||||
}
|
||||
|
||||
@ -1,17 +1,31 @@
|
||||
package hostagencypolicy
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
gormMySQL "gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
|
||||
item, err := policyModelFromRequest("lalu", 7, policyRequest{
|
||||
Name: "Lalu 默认政策",
|
||||
RegionID: 101,
|
||||
CycleKey: "2026-06",
|
||||
Status: "active",
|
||||
SettlementMode: "half-month",
|
||||
SettlementTriggerMode: "manual",
|
||||
GiftCoinToDiamondRatio: "9",
|
||||
ResidualDiamondToUSDRate: "0.000001",
|
||||
Name: "Lalu 默认政策",
|
||||
RegionIDs: []int64{102, 101, 102},
|
||||
CycleKey: "2026-06",
|
||||
Status: "active",
|
||||
SettlementMode: "half-month",
|
||||
SettlementTriggerMode: "manual",
|
||||
GiftCoinToDiamondRatio: "9",
|
||||
ResidualDiamondToUSDRate: "0.000001",
|
||||
CoinSellerWithdrawalLimitPeriod: "week",
|
||||
CoinSellerWithdrawalLimitCount: 3,
|
||||
PlatformWithdrawalLimitPeriod: "day",
|
||||
PlatformWithdrawalLimitCount: 1,
|
||||
Levels: []levelRequest{
|
||||
{Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
|
||||
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
|
||||
@ -23,10 +37,16 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
|
||||
if item.AppCode != "lalu" || item.CycleKey != "2026-06" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual {
|
||||
t.Fatalf("policy fields mismatch: %+v", item)
|
||||
}
|
||||
if item.RegionID != 101 || len(item.RegionIDs) != 2 || item.RegionIDs[0] != 101 || item.RegionIDs[1] != 102 {
|
||||
t.Fatalf("multi-region scope should be sorted and deduplicated: %+v", item.RegionIDs)
|
||||
}
|
||||
// 工资政策不能单独改送礼入主播周期钻石的比例;该比例只由礼物钻石配置页控制。
|
||||
if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
|
||||
t.Fatalf("ratio fields mismatch: %+v", item)
|
||||
}
|
||||
if item.CoinSellerWithdrawalLimitPeriod != "week" || item.CoinSellerWithdrawalLimitCount != 3 || item.PlatformWithdrawalLimitPeriod != "day" || item.PlatformWithdrawalLimitCount != 1 {
|
||||
t.Fatalf("withdrawal limit fields mismatch: %+v", item)
|
||||
}
|
||||
if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 {
|
||||
t.Fatalf("levels should be sorted: %+v", item.Levels)
|
||||
}
|
||||
@ -35,6 +55,25 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyModelFromRequestRejectsInvalidWithdrawalLimit(t *testing.T) {
|
||||
_, err := policyModelFromRequest("lalu", 1, policyRequest{
|
||||
Name: "Invalid withdrawal limit", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
|
||||
CoinSellerWithdrawalLimitPeriod: "year", CoinSellerWithdrawalLimitCount: 1,
|
||||
Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("unsupported withdrawal limit period must fail")
|
||||
}
|
||||
_, err = policyModelFromRequest("lalu", 1, policyRequest{
|
||||
Name: "Invalid withdrawal count", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
|
||||
PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitCount: -1,
|
||||
Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("negative withdrawal limit count must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) {
|
||||
_, err := policyModelFromRequest("lalu", 1, policyRequest{
|
||||
Name: "Invalid",
|
||||
@ -102,15 +141,14 @@ func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T)
|
||||
|
||||
func TestPolicyModelFromRequestRejectsMissingExplicitCycle(t *testing.T) {
|
||||
_, err := policyModelFromRequest("lalu", 1, policyRequest{
|
||||
Name: "Legacy cycle",
|
||||
RegionID: 101,
|
||||
EffectiveFromMS: 1782835200000, // 月初本地零点可能仍属于上一个 UTC 月,不能用它推导周期。
|
||||
Name: "Legacy cycle",
|
||||
RegionID: 101,
|
||||
Levels: []levelRequest{
|
||||
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("missing cycle_key must fail instead of deriving a timezone-dependent month")
|
||||
t.Fatal("missing cycle_key must fail because it cannot be derived from a removed effective-time field")
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,3 +165,98 @@ func TestPolicyModelFromRequestRejectsInvalidCycle(t *testing.T) {
|
||||
t.Fatal("expected invalid cycle to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHostAgencySalaryPolicyForNextCycleKeepsIndependentDraft(t *testing.T) {
|
||||
source := model.HostAgencySalaryPolicy{
|
||||
ID: 71, AppCode: "fami", Name: "July policy", RegionID: 25, RegionIDs: []int64{25, 26},
|
||||
CycleKey: "2026-07", PolicyVersion: 71, Status: policyStatusActive, PublishStatus: publishStatusPublished,
|
||||
PublishedAtMS: 100, Levels: []model.HostAgencySalaryLevel{{ID: 9, PolicyID: 71, LevelNo: 1, RequiredDiamonds: 100}},
|
||||
}
|
||||
clone := cloneHostAgencySalaryPolicyForCycle(source, "2026-08")
|
||||
if clone.ID != 0 || clone.PolicyVersion != 0 || clone.CycleKey != "2026-08" || clone.PublishStatus != publishStatusDraft {
|
||||
t.Fatalf("next-cycle copy must be an independent draft: %+v", clone)
|
||||
}
|
||||
if clone.CopiedFromPolicyID != 71 || clone.CopiedFromCycleKey != "2026-07" || len(clone.RegionIDs) != 2 {
|
||||
t.Fatalf("copy provenance or regions mismatch: %+v", clone)
|
||||
}
|
||||
if len(clone.Levels) != 1 || clone.Levels[0].ID != 0 || clone.Levels[0].PolicyID != 0 {
|
||||
t.Fatalf("copied levels must receive new identities: %+v", clone.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin transaction: %v", err)
|
||||
}
|
||||
item := model.HostAgencySalaryPolicy{
|
||||
ID: 81, AppCode: "fami", Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81,
|
||||
Status: policyStatusActive, SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
|
||||
GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
|
||||
CoinSellerWithdrawalLimitPeriod: "week", CoinSellerWithdrawalLimitCount: 2,
|
||||
PlatformWithdrawalLimitPeriod: "day", PlatformWithdrawalLimitCount: 1,
|
||||
Levels: []model.HostAgencySalaryLevel{{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSD: "1.50", HostCoinReward: 20, AgencySalaryUSD: "0.50", Status: policyStatusActive, SortOrder: 1}},
|
||||
}
|
||||
mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{
|
||||
"cycle_key", "policy_version", "name", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
|
||||
"gift_ratio", "residual_rate", "coin_period", "coin_count", "platform_period", "platform_count",
|
||||
}).AddRow("2026-08", 81, "August policy", 25, "active", "daily", "automatic", "1.000000", "0.000000000000", "week", 2, "day", 1))
|
||||
mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{
|
||||
"level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order",
|
||||
}).AddRow(1, 100, 150, 20, 50, "active", 1))
|
||||
if err := ensureRuntimeHostSalaryPolicySnapshot(context.Background(), tx, item, 81, 200); err != nil {
|
||||
t.Fatalf("identical existing runtime snapshot must be idempotent: %v", err)
|
||||
}
|
||||
mock.ExpectRollback()
|
||||
_ = tx.Rollback()
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHostSalaryPolicySnapshotRejectsDifferentContent(t *testing.T) {
|
||||
item := model.HostAgencySalaryPolicy{
|
||||
Name: "August policy", RegionID: 25, CycleKey: "2026-08", Status: policyStatusActive,
|
||||
SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
|
||||
GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0",
|
||||
CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month",
|
||||
}
|
||||
existing := runtimeHostSalaryPolicySnapshot{
|
||||
Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81, Status: policyStatusActive,
|
||||
SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
|
||||
GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
|
||||
CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month",
|
||||
PlatformWithdrawalLimitCount: 2,
|
||||
}
|
||||
if runtimeHostSalaryPolicyMatches(existing, item, 81) {
|
||||
t.Fatal("different withdrawal limit content must never be accepted as an idempotent snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
gormDB, err := gorm.Open(gormMySQL.New(gormMySQL.Config{Conn: db, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
t.Fatalf("open gorm: %v", err)
|
||||
}
|
||||
store := repository.New(gormDB)
|
||||
mock.ExpectExec("UPDATE .*admin_host_agency_salary_policies.*publish_status <> \\? AND published_at_ms = 0").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
if err := store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished("fami", 91, "duplicate publish", 7); err != nil {
|
||||
t.Fatalf("conditional failure update: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("failure update must retain the published guard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -289,8 +289,6 @@ func (s *Service) collectPendingCandidates(ctx context.Context, appCode string,
|
||||
JOIN host_agency_salary_policies p
|
||||
ON p.app_code = binding.app_code
|
||||
AND p.policy_id = binding.policy_id
|
||||
AND p.region_id = binding.region_id
|
||||
AND p.cycle_key = binding.cycle_key
|
||||
AND p.policy_version = binding.policy_version
|
||||
WHERE binding.app_code = a.app_code
|
||||
AND binding.region_id = a.region_id
|
||||
@ -534,14 +532,12 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
|
||||
args = append(args, settlementType)
|
||||
}
|
||||
row := s.walletDB.QueryRowContext(ctx, `
|
||||
SELECT p.policy_id, p.cycle_key, p.policy_version, p.name, p.region_id,
|
||||
SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, binding.region_id,
|
||||
p.settlement_mode, p.settlement_trigger_mode, CAST(p.residual_diamond_to_usd_rate AS CHAR)
|
||||
FROM host_salary_policy_cycle_bindings binding
|
||||
JOIN host_agency_salary_policies p
|
||||
ON p.app_code = binding.app_code
|
||||
AND p.policy_id = binding.policy_id
|
||||
AND p.region_id = binding.region_id
|
||||
AND p.cycle_key = binding.cycle_key
|
||||
AND p.policy_version = binding.policy_version
|
||||
WHERE `+strings.Join(conditions, " AND ")+`
|
||||
LIMIT 1`, args...)
|
||||
|
||||
@ -330,9 +330,10 @@ func legacyPaidCoinSellerRechargeBillDTO(sourceConfig config.FinanceBillSourceCo
|
||||
paidCurrency = "USD"
|
||||
}
|
||||
return rechargeBillDTO{
|
||||
AppCode: sourceConfig.AppCode,
|
||||
TransactionID: legacyDocumentID(document.ID),
|
||||
RechargeType: "coin_seller",
|
||||
AppCode: sourceConfig.AppCode,
|
||||
TransactionID: legacyDocumentID(document.ID),
|
||||
// Legacy H5 FREIGHT_GOLD 是支付渠道成功单,归入币商-三方;运营手工选择的 USDT 只来自 admin 订单台账。
|
||||
RechargeType: financeCoinSellerThirdPartyType,
|
||||
Status: "succeeded",
|
||||
ExternalRef: strings.TrimSpace(document.OrderID),
|
||||
SellerUserID: document.AcceptUserID,
|
||||
|
||||
@ -76,7 +76,7 @@ func TestLegacyPaidCoinSellerRechargeBillDTOUsesOriginalBillAmounts(t *testing.T
|
||||
LegacyBillCountry: "EG",
|
||||
}
|
||||
dto := legacyPaidCoinSellerRechargeBillDTO(aslanBillSourceConfig(), document)
|
||||
if dto.RechargeType != "coin_seller" || dto.Status != "succeeded" {
|
||||
if dto.RechargeType != financeCoinSellerThirdPartyType || dto.Status != "succeeded" {
|
||||
t.Fatalf("coin seller bill identity mismatch: %+v", dto)
|
||||
}
|
||||
if dto.ExternalRef != document.OrderID || dto.SellerUserID != document.AcceptUserID {
|
||||
@ -155,7 +155,7 @@ func TestFinanceCoinSellerIncludesAslanPaidBills(t *testing.T) {
|
||||
},
|
||||
items: []rechargeBillDTO{{
|
||||
AppCode: "aslan", TransactionID: "2077003764199862273", ExternalRef: "x5ctfib7800289mrkm5fco0A10001910",
|
||||
RechargeType: "coin_seller", ProviderCode: "mifa_pay", USDMinorAmount: 10_000, CoinAmount: 8_800_000,
|
||||
RechargeType: financeCoinSellerThirdPartyType, ProviderCode: "mifa_pay", USDMinorAmount: 10_000, CoinAmount: 8_800_000,
|
||||
}},
|
||||
total: 1,
|
||||
}
|
||||
|
||||
@ -39,12 +39,42 @@ type financeCoinSellerRechargeStats struct {
|
||||
const (
|
||||
financeCoinSellerRechargeMaxPageSize = 100
|
||||
financeRechargeMergeMaxPrefetch = 5000
|
||||
financeCoinSellerRechargeType = "coin_seller"
|
||||
financeCoinSellerThirdPartyType = "coin_seller_third_party"
|
||||
financeCoinSellerUSDTType = "coin_seller_usdt"
|
||||
|
||||
// 2^53 以内的 int64 在 float64 里精确可表示,不会被浏览器 JSON 圆整;
|
||||
// 只有 legacy 合成/雪花区域 ID(远大于 2^53)需要按目录归一后才能做 SQL 精确等值。
|
||||
maxExactFloat64RegionID = int64(1) << 53
|
||||
)
|
||||
|
||||
// financeCoinSellerRechargeSource 把 API 的充值来源筛选收敛为台账查询分组。
|
||||
// coin_seller 保持历史兼容并同时包含两类;细分值只影响币商充值,不改变普通三方/Google 的既有 recharge_type 契约。
|
||||
func financeCoinSellerRechargeSource(rechargeType string) (string, bool) {
|
||||
switch normalizedRechargeType(rechargeType) {
|
||||
case "", financeCoinSellerRechargeType:
|
||||
return "", true
|
||||
case financeCoinSellerThirdPartyType:
|
||||
return repository.CoinSellerRechargeSourceThirdParty, true
|
||||
case financeCoinSellerUSDTType:
|
||||
return repository.CoinSellerRechargeSourceUSDT, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func isFinanceCoinSellerRechargeType(rechargeType string) bool {
|
||||
_, ok := financeCoinSellerRechargeSource(rechargeType)
|
||||
return ok && normalizedRechargeType(rechargeType) != ""
|
||||
}
|
||||
|
||||
func financeCoinSellerBillType(providerCode string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(providerCode), "usdt") {
|
||||
return financeCoinSellerUSDTType
|
||||
}
|
||||
return financeCoinSellerThirdPartyType
|
||||
}
|
||||
|
||||
// normalizeFinanceCoinSellerRegionID 把请求里可能被前端 float64 圆整过的 legacy 区域 ID
|
||||
// 还原成区域目录里的精确值;目录不可用或没有对应项时原样返回(与 databi normalizeRegionID 同语义)。
|
||||
func (h *Handler) normalizeFinanceCoinSellerRegionID(ctx context.Context, appCode string, regionID int64) int64 {
|
||||
@ -68,7 +98,8 @@ func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode str
|
||||
return nil
|
||||
}
|
||||
rechargeType := normalizedRechargeType(query.RechargeType)
|
||||
if rechargeType != "" && rechargeType != "coin_seller" {
|
||||
_, coinSellerType := financeCoinSellerRechargeSource(rechargeType)
|
||||
if !coinSellerType {
|
||||
summary.CoinSeller = rechargeBillSummaryBucketDTO{}
|
||||
legacyRecomputeSummaryTotal(summary)
|
||||
return nil
|
||||
@ -78,7 +109,7 @@ func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode str
|
||||
return err
|
||||
}
|
||||
summary.CoinSeller = stats.Total
|
||||
if rechargeType == "coin_seller" {
|
||||
if rechargeType != "" {
|
||||
summary.GooglePlay = rechargeBillSummaryBucketDTO{}
|
||||
summary.ThirdParty = rechargeBillSummaryBucketDTO{}
|
||||
}
|
||||
@ -91,7 +122,8 @@ func (h *Handler) applyFinanceCoinSellerOverview(ctx context.Context, appCode st
|
||||
return nil
|
||||
}
|
||||
rechargeType := normalizedRechargeType(query.RechargeType)
|
||||
if rechargeType != "" && rechargeType != "coin_seller" {
|
||||
_, coinSellerType := financeCoinSellerRechargeSource(rechargeType)
|
||||
if !coinSellerType {
|
||||
for i := range overview.Daily {
|
||||
overview.Daily[i].CoinSellerUsdMinor = 0
|
||||
overview.Daily[i].CoinSellerCoinAmount = 0
|
||||
@ -125,7 +157,7 @@ func (h *Handler) applyFinanceCoinSellerOverview(ctx context.Context, appCode st
|
||||
sort.Slice(overview.Daily, func(i, j int) bool {
|
||||
return overview.Daily[i].Date < overview.Daily[j].Date
|
||||
})
|
||||
if rechargeType == "coin_seller" {
|
||||
if rechargeType != "" {
|
||||
overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, stats.Regions)
|
||||
return nil
|
||||
}
|
||||
@ -137,32 +169,36 @@ func (h *Handler) applyFinanceCoinSellerOverview(ctx context.Context, appCode st
|
||||
|
||||
func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) {
|
||||
stats := financeCoinSellerRechargeStats{Daily: map[string]rechargeBillSummaryBucketDTO{}}
|
||||
rechargeType := normalizedRechargeType(query.RechargeType)
|
||||
if rechargeType != "" && rechargeType != "coin_seller" {
|
||||
rechargeSource, coinSellerType := financeCoinSellerRechargeSource(query.RechargeType)
|
||||
if !coinSellerType {
|
||||
return stats, nil
|
||||
}
|
||||
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
|
||||
return stats, nil
|
||||
}
|
||||
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
|
||||
if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil {
|
||||
paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
// Aslan legacy H5 的 SUCCESS/FREIGHT_GOLD Mongo 订单是支付账单事实;
|
||||
// 这里只合并该事实,不读取 WebConsole 手填金额或钱包发货流水。
|
||||
stats.addBucket(paidStats.Total)
|
||||
for date, bucket := range paidStats.Daily {
|
||||
stats.addDaily(date, bucket)
|
||||
}
|
||||
for _, region := range paidStats.Regions {
|
||||
stats.addRegion(region.RegionID, region.BillCount, region.UsdMinorAmount)
|
||||
if rechargeSource != repository.CoinSellerRechargeSourceUSDT {
|
||||
paidSource := h.financePaidCoinSellerRechargeSource(appCode)
|
||||
if paidSource != nil {
|
||||
paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
// Aslan legacy H5 的 SUCCESS/FREIGHT_GOLD Mongo 订单是支付账单事实;
|
||||
// 这里只合并该事实,不读取 WebConsole 手填金额或钱包发货流水。
|
||||
stats.addBucket(paidStats.Total)
|
||||
for date, bucket := range paidStats.Daily {
|
||||
stats.addDaily(date, bucket)
|
||||
}
|
||||
for _, region := range paidStats.Regions {
|
||||
stats.addRegion(region.RegionID, region.BillCount, region.UsdMinorAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h != nil && h.store != nil {
|
||||
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
|
||||
AppCode: appCode,
|
||||
RechargeSource: rechargeSource,
|
||||
RegionID: query.RegionID,
|
||||
TargetUserID: query.TargetUserID,
|
||||
Keyword: query.Keyword,
|
||||
@ -185,7 +221,7 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
|
||||
stats.addRegion(row.RegionID, row.Count, row.USDMinor)
|
||||
}
|
||||
}
|
||||
if h != nil && h.walletDB != nil {
|
||||
if rechargeSource != repository.CoinSellerRechargeSourceUSDT && h != nil && h.walletDB != nil {
|
||||
if err := h.addH5CoinSellerRechargeStats(ctx, appCode, query, &stats); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
@ -293,6 +329,10 @@ func (h *Handler) addH5CoinSellerRechargeStats(ctx context.Context, appCode stri
|
||||
}
|
||||
|
||||
func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) {
|
||||
rechargeSource, coinSellerType := financeCoinSellerRechargeSource(query.RechargeType)
|
||||
if !coinSellerType {
|
||||
return []rechargeBillDTO{}, 0, nil
|
||||
}
|
||||
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
|
||||
return []rechargeBillDTO{}, 0, nil
|
||||
}
|
||||
@ -301,27 +341,31 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
|
||||
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
|
||||
items := make([]rechargeBillDTO, 0, prefetch*2)
|
||||
var total int64
|
||||
if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil {
|
||||
paidItems, paidTotal, err := paidSource.listFinancePaidCoinSellerRechargeBills(ctx, query, prefetch)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
if rechargeSource != repository.CoinSellerRechargeSourceUSDT {
|
||||
paidSource := h.financePaidCoinSellerRechargeSource(appCode)
|
||||
if paidSource != nil {
|
||||
paidItems, paidTotal, err := paidSource.listFinancePaidCoinSellerRechargeBills(ctx, query, prefetch)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// 真实 H5 账单先进入结果;若同一个支付订单后来又被后台绑定,去重时保留原始账单金额和商品金币。
|
||||
items = append(items, paidItems...)
|
||||
total += paidTotal
|
||||
}
|
||||
// 真实 H5 账单先进入结果;若同一个支付订单后来又被后台绑定,去重时保留原始账单金额和商品金币。
|
||||
items = append(items, paidItems...)
|
||||
total += paidTotal
|
||||
}
|
||||
if h != nil && h.store != nil {
|
||||
orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{
|
||||
Page: 1,
|
||||
PageSize: prefetch,
|
||||
PageSizeLimit: prefetch,
|
||||
AppCode: appCode,
|
||||
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
|
||||
Keyword: query.Keyword,
|
||||
TargetUserID: query.TargetUserID,
|
||||
RegionID: query.RegionID,
|
||||
CreatedFromMS: query.StartAtMS,
|
||||
CreatedToMS: query.EndAtMS,
|
||||
Page: 1,
|
||||
PageSize: prefetch,
|
||||
PageSizeLimit: prefetch,
|
||||
AppCode: appCode,
|
||||
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
|
||||
RechargeSource: rechargeSource,
|
||||
Keyword: query.Keyword,
|
||||
TargetUserID: query.TargetUserID,
|
||||
RegionID: query.RegionID,
|
||||
CreatedFromMS: query.StartAtMS,
|
||||
CreatedToMS: query.EndAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@ -334,7 +378,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
|
||||
items = append(items, financeCoinSellerRechargeOrderBill(order))
|
||||
}
|
||||
}
|
||||
if h != nil && h.walletDB != nil {
|
||||
if rechargeSource != repository.CoinSellerRechargeSourceUSDT && h != nil && h.walletDB != nil {
|
||||
h5Items, h5Total, err := h.listH5CoinSellerRechargeBills(ctx, appCode, query, prefetch)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@ -450,7 +494,7 @@ func (h *Handler) listFinanceAllRechargeBills(ctx context.Context, appCode strin
|
||||
}
|
||||
|
||||
coinItems, coinTotal, err := h.listFinanceCoinSellerRechargeBills(ctx, appCode, financeCoinSellerRechargeQuery{
|
||||
RechargeType: "coin_seller",
|
||||
RechargeType: financeCoinSellerRechargeType,
|
||||
Status: query.Status,
|
||||
PaidState: query.PaidState,
|
||||
Keyword: query.Keyword,
|
||||
@ -575,7 +619,8 @@ func (h *Handler) listH5CoinSellerRechargeBills(ctx context.Context, appCode str
|
||||
return nil, 0, err
|
||||
}
|
||||
item.TransactionID = firstNonEmptyPaymentString(walletTransactionID, item.ExternalRef)
|
||||
item.RechargeType = "coin_seller"
|
||||
// H5 币商身份订单通过支付渠道完成,属于币商-三方;只有运营台账 provider_code=usdt 才进入币商-USDT。
|
||||
item.RechargeType = financeCoinSellerThirdPartyType
|
||||
item.Status = "succeeded"
|
||||
item.TargetRegionID = item.SellerRegionID
|
||||
item.PolicyID = productID
|
||||
@ -609,7 +654,7 @@ func financeCoinSellerRechargeOrderBill(order model.CoinSellerRechargeOrder) rec
|
||||
AppCode: order.AppCode,
|
||||
TransactionID: firstNonEmptyPaymentString(order.WalletTransactionID, fmt.Sprintf("coin-seller-order:%d", order.ID)),
|
||||
CommandID: order.WalletCommandID,
|
||||
RechargeType: "coin_seller",
|
||||
RechargeType: financeCoinSellerBillType(order.ProviderCode),
|
||||
Status: "succeeded",
|
||||
ExternalRef: firstNonEmptyPaymentString(order.ProviderOrderID, order.ExternalOrderNo),
|
||||
SellerUserID: order.TargetUserID,
|
||||
|
||||
@ -14,6 +14,7 @@ func TestFinanceCoinSellerRechargeOrderBillUsesBusinessTime(t *testing.T) {
|
||||
TargetUserID: 10001,
|
||||
USDMinorAmount: 15000,
|
||||
CoinAmount: 12000000,
|
||||
ProviderCode: "usdt",
|
||||
OrderDateMS: 1_783_708_200_000,
|
||||
CreatedAtMS: 1_783_881_000_000,
|
||||
}
|
||||
@ -21,6 +22,27 @@ func TestFinanceCoinSellerRechargeOrderBillUsesBusinessTime(t *testing.T) {
|
||||
if bill.CreatedAtMS != order.OrderDateMS || bill.PaidSyncedAtMS != order.OrderDateMS {
|
||||
t.Fatalf("finance bill must use selected business time: %+v", bill)
|
||||
}
|
||||
if bill.RechargeType != financeCoinSellerUSDTType {
|
||||
t.Fatalf("USDT admin order must expose the refined recharge source: %+v", bill)
|
||||
}
|
||||
order.ProviderCode = "mifapay"
|
||||
if thirdPartyBill := financeCoinSellerRechargeOrderBill(order); thirdPartyBill.RechargeType != financeCoinSellerThirdPartyType {
|
||||
t.Fatalf("provider admin order must expose the refined recharge source: %+v", thirdPartyBill)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinanceCoinSellerRechargeSourceKeepsParentCompatibility(t *testing.T) {
|
||||
for _, rechargeType := range []string{"", financeCoinSellerRechargeType} {
|
||||
if source, ok := financeCoinSellerRechargeSource(rechargeType); !ok || source != "" {
|
||||
t.Fatalf("parent recharge type %q must include both sources: source=%q ok=%v", rechargeType, source, ok)
|
||||
}
|
||||
}
|
||||
if source, ok := financeCoinSellerRechargeSource(financeCoinSellerThirdPartyType); !ok || source != "third_party" {
|
||||
t.Fatalf("third-party source mismatch: source=%q ok=%v", source, ok)
|
||||
}
|
||||
if source, ok := financeCoinSellerRechargeSource(financeCoinSellerUSDTType); !ok || source != "usdt" {
|
||||
t.Fatalf("USDT source mismatch: source=%q ok=%v", source, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFinanceCoinSellerRegionIDRestoresRoundedLegacyID(t *testing.T) {
|
||||
|
||||
@ -94,7 +94,7 @@ func (h *Handler) ListRechargeBills(c *gin.Context) {
|
||||
}
|
||||
source, hasSource := h.billSources[appCode]
|
||||
normalizedType := normalizedRechargeType(rechargeType)
|
||||
if normalizedType == "" || normalizedType == "coin_seller" {
|
||||
if normalizedType == "" || isFinanceCoinSellerRechargeType(normalizedType) {
|
||||
query := financeCoinSellerRechargeQuery{
|
||||
RechargeType: rechargeType,
|
||||
Status: options.Status,
|
||||
@ -113,7 +113,7 @@ func (h *Handler) ListRechargeBills(c *gin.Context) {
|
||||
var items []rechargeBillDTO
|
||||
var total int64
|
||||
var err error
|
||||
if normalizedType == "coin_seller" {
|
||||
if isFinanceCoinSellerRechargeType(normalizedType) {
|
||||
items, total, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query)
|
||||
} else {
|
||||
var legacySource RechargeBillSource
|
||||
|
||||
@ -86,7 +86,7 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string)
|
||||
}
|
||||
rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType"))
|
||||
normalizedType := normalizedRechargeType(rechargeType)
|
||||
if normalizedType == "" || normalizedType == "coin_seller" {
|
||||
if normalizedType == "" || isFinanceCoinSellerRechargeType(normalizedType) {
|
||||
userFilter := shared.UserIdentityFilterFromQuery(c, "")
|
||||
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
|
||||
if !ok {
|
||||
@ -118,7 +118,7 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string)
|
||||
}
|
||||
var items []rechargeBillDTO
|
||||
var err error
|
||||
if normalizedType == "coin_seller" {
|
||||
if isFinanceCoinSellerRechargeType(normalizedType) {
|
||||
items, _, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query)
|
||||
} else {
|
||||
items, _, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, h.billSources[appCode], query)
|
||||
@ -281,6 +281,10 @@ func rechargeSourceLabel(rechargeType string) string {
|
||||
return "谷歌充值"
|
||||
case "coin_seller":
|
||||
return "币商充值"
|
||||
case financeCoinSellerThirdPartyType:
|
||||
return "币商充值-三方"
|
||||
case financeCoinSellerUSDTType:
|
||||
return "币商充值-USDT"
|
||||
case "coin_seller_stock_purchase":
|
||||
return "币商进货"
|
||||
case "coin_seller_stock_deduction":
|
||||
|
||||
@ -250,6 +250,11 @@ func financeRechargeDailyTotal(rows []FinanceRechargeDailyBucket) int64 {
|
||||
// ordinary 与币商统计仍分别由 wallet/legacy source 和 financeCoinSellerRechargeStats 提供,禁止回退旧库存币商口径。
|
||||
func (h *Handler) loadFinanceRechargeOverview(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, includeOrdinary bool, includeRegions bool) (rechargeBillOverviewDTO, error) {
|
||||
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
|
||||
if isFinanceCoinSellerRechargeType(query.RechargeType) {
|
||||
// coin_seller 及其三方/USDT 子来源都由 admin 台账、币商 H5 支付单和 legacy FREIGHT_GOLD 事实重建;
|
||||
// 不再把细分值透传给普通充值源,否则 legacy 未识别的新值会退化成“全部普通充值”。
|
||||
includeOrdinary = false
|
||||
}
|
||||
if source, ok := h.billSources[appCode]; ok {
|
||||
if includeOrdinary {
|
||||
var err error
|
||||
|
||||
@ -111,7 +111,8 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
||||
summary := rechargeBillSummaryDTO{}
|
||||
if !userFilter.IsEmpty() || !sellerFilter.IsEmpty() {
|
||||
// legacy 普通充值汇总没有 user/seller 过滤能力;有用户筛选时只保留后续可精确计算的财务币商桶。
|
||||
} else {
|
||||
} else if !isFinanceCoinSellerRechargeType(query.RechargeType) {
|
||||
// 币商及其细分来源由下方财务币商聚合唯一负责;不让 legacy 源把未知子类型退化成普通充值全量查询。
|
||||
var err error
|
||||
summary, err = source.SummarizeRechargeBills(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
@ -153,7 +154,7 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
||||
}
|
||||
paidState := strings.TrimSpace(firstQuery(c, "paid_state", "paidState"))
|
||||
var summary rechargeBillSummaryDTO
|
||||
if paidState == "" {
|
||||
if paidState == "" && !isFinanceCoinSellerRechargeType(strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType"))) {
|
||||
resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appCode,
|
||||
|
||||
@ -16,29 +16,37 @@ var (
|
||||
ErrCoinSellerRechargeOrderGranted = errors.New("coin seller recharge order already granted")
|
||||
)
|
||||
|
||||
const (
|
||||
// RechargeSource 只描述运营录入币商充值时的付款大类;provider_code 仍保留具体支付商,不能用展示分组覆盖审计事实。
|
||||
CoinSellerRechargeSourceThirdParty = "third_party"
|
||||
CoinSellerRechargeSourceUSDT = "usdt"
|
||||
)
|
||||
|
||||
// coinSellerRechargeBusinessTimeSQL 让新订单按运营选择的业务时间归档,同时保持历史 order_date_ms=0 数据仍按录入时间查询。
|
||||
const coinSellerRechargeBusinessTimeSQL = "CASE WHEN order_date_ms > 0 THEN order_date_ms ELSE created_at_ms END"
|
||||
|
||||
type CoinSellerRechargeOrderListOptions struct {
|
||||
Page int
|
||||
PageSize int
|
||||
PageSizeLimit int
|
||||
AppCode string
|
||||
Status string
|
||||
VerifyStatus string
|
||||
GrantStatus string
|
||||
ProviderCode string
|
||||
Keyword string
|
||||
TargetUserID int64
|
||||
RegionID int64
|
||||
OperatorID uint
|
||||
CreatedFromMS int64
|
||||
CreatedToMS int64
|
||||
Page int
|
||||
PageSize int
|
||||
PageSizeLimit int
|
||||
AppCode string
|
||||
Status string
|
||||
VerifyStatus string
|
||||
GrantStatus string
|
||||
ProviderCode string
|
||||
RechargeSource string
|
||||
Keyword string
|
||||
TargetUserID int64
|
||||
RegionID int64
|
||||
OperatorID uint
|
||||
CreatedFromMS int64
|
||||
CreatedToMS int64
|
||||
}
|
||||
|
||||
type CoinSellerRechargeOrderStatsOptions struct {
|
||||
AppCode string
|
||||
Status string
|
||||
RechargeSource string
|
||||
Keyword string
|
||||
TargetUserID int64
|
||||
RegionID int64
|
||||
@ -368,6 +376,7 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha
|
||||
if providerCode := strings.TrimSpace(options.ProviderCode); providerCode != "" && providerCode != "all" {
|
||||
query = query.Where("provider_code = ?", providerCode)
|
||||
}
|
||||
query = applyCoinSellerRechargeSourceFilter(query, options.RechargeSource)
|
||||
if options.TargetUserID > 0 {
|
||||
query = query.Where("target_user_id = ?", options.TargetUserID)
|
||||
}
|
||||
@ -399,6 +408,7 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller
|
||||
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
query = applyCoinSellerRechargeSourceFilter(query, options.RechargeSource)
|
||||
if options.TargetUserID > 0 {
|
||||
query = query.Where("target_user_id = ?", options.TargetUserID)
|
||||
}
|
||||
@ -421,6 +431,19 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller
|
||||
return query
|
||||
}
|
||||
|
||||
// applyCoinSellerRechargeSourceFilter 把财务展示分组还原到台账的权威 provider_code。
|
||||
// 当前创建入口只允许 mifapay/v5pay/usdt,使用等值或 IN 条件可以继续利用 provider_code 索引,避免为了筛选扫描并在 Go 内丢弃整页数据。
|
||||
func applyCoinSellerRechargeSourceFilter(query *gorm.DB, rechargeSource string) *gorm.DB {
|
||||
switch strings.ToLower(strings.TrimSpace(rechargeSource)) {
|
||||
case CoinSellerRechargeSourceThirdParty:
|
||||
return query.Where("provider_code IN ?", []string{"mifapay", "v5pay"})
|
||||
case CoinSellerRechargeSourceUSDT:
|
||||
return query.Where("provider_code = ?", "usdt")
|
||||
default:
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) scanCoinSellerRechargeOrderDailyStats(options CoinSellerRechargeOrderStatsOptions, includeAllVerified bool, out map[string]CoinSellerRechargeOrderStatsBucket) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
|
||||
@ -10,7 +10,7 @@ func TestCoinSellerRechargeOrderStatsAggregatesVerifiedAndGrantedOverlap(t *test
|
||||
store, mock, closeStore := newRepositorySQLMock(t)
|
||||
defer closeStore()
|
||||
|
||||
mock.ExpectQuery("verified_usd_minor").
|
||||
mock.ExpectQuery("provider_code.*=").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"verified_usd_minor",
|
||||
"verified_coin_amount",
|
||||
@ -34,12 +34,13 @@ func TestCoinSellerRechargeOrderStatsAggregatesVerifiedAndGrantedOverlap(t *test
|
||||
AddRow(int64(101), int64(91), int64(2), int64(2_000), int64(20_000)))
|
||||
|
||||
stats, err := store.CoinSellerRechargeOrderStats(CoinSellerRechargeOrderStatsOptions{
|
||||
AppCode: "lalu",
|
||||
RegionID: 101,
|
||||
TargetUserID: 9001,
|
||||
Keyword: "merchant-1",
|
||||
CreatedFromMS: 1_783_468_800_000,
|
||||
CreatedToMS: 1_783_555_200_000,
|
||||
AppCode: "lalu",
|
||||
RechargeSource: CoinSellerRechargeSourceUSDT,
|
||||
RegionID: 101,
|
||||
TargetUserID: 9001,
|
||||
Keyword: "merchant-1",
|
||||
CreatedFromMS: 1_783_468_800_000,
|
||||
CreatedToMS: 1_783_555_200_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CoinSellerRechargeOrderStats failed: %v", err)
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrHostAgencySalaryPolicyActiveScopeConflict = errors.New("host agency salary policy active scope conflict")
|
||||
|
||||
type HostAgencySalaryPolicyListOptions struct {
|
||||
AppCode string
|
||||
Keyword string
|
||||
@ -22,10 +27,13 @@ type HostAgencySalaryPolicyListOptions struct {
|
||||
|
||||
func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListOptions) ([]model.HostAgencySalaryPolicy, int64, error) {
|
||||
// 工资政策是应用隔离配置,所有查询都必须先限定 app_code,再叠加运营筛选条件。
|
||||
db := s.db.Model(&model.HostAgencySalaryPolicy{}).Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
||||
db := s.db.Model(&model.HostAgencySalaryPolicy{}).
|
||||
Select("admin_host_agency_salary_policies.*").
|
||||
Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
||||
if options.RegionID > 0 {
|
||||
// 第一阶段仍按单区域保存,区域筛选直接命中主表索引。
|
||||
db = db.Where("region_id = ?", options.RegionID)
|
||||
// 区域筛选走关联表索引;一份政策可覆盖多区,不能再只检查兼容用的主表 region_id。
|
||||
db = db.Joins("JOIN admin_host_agency_salary_policy_regions policy_region ON policy_region.policy_id = admin_host_agency_salary_policies.id").
|
||||
Where("policy_region.region_id = ?", options.RegionID)
|
||||
}
|
||||
if cycleKey := strings.TrimSpace(options.CycleKey); cycleKey != "" {
|
||||
db = db.Where("cycle_key = ?", cycleKey)
|
||||
@ -54,8 +62,18 @@ func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListO
|
||||
// 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。
|
||||
err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Order("level_no ASC")
|
||||
}).Order("region_id ASC, cycle_key DESC, policy_version DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
|
||||
return items, total, err
|
||||
}).Order("admin_host_agency_salary_policies.region_id ASC, admin_host_agency_salary_policies.cycle_key DESC, admin_host_agency_salary_policies.policy_version DESC, admin_host_agency_salary_policies.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
itemRefs := make([]*model.HostAgencySalaryPolicy, len(items))
|
||||
for index := range items {
|
||||
itemRefs[index] = &items[index]
|
||||
}
|
||||
if err := s.loadHostAgencySalaryPolicyRegions(s.db, itemRefs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetHostAgencySalaryPolicy(appCode string, id uint) (model.HostAgencySalaryPolicy, error) {
|
||||
@ -64,7 +82,52 @@ func (s *Store) GetHostAgencySalaryPolicy(appCode string, id uint) (model.HostAg
|
||||
err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Order("level_no ASC")
|
||||
}).Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
|
||||
return item, err
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if err := s.loadHostAgencySalaryPolicyRegions(s.db, []*model.HostAgencySalaryPolicy{&item}); err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ListHostAgencySalaryPolicyRegionIDsForCycle 返回目标月已经被任意显式或自动政策占用的区域。
|
||||
// region_ids 来自单份政策的有界集合,查询走关联表 (region_id, policy_id) 主键/索引,不扫描工资或钱包流水。
|
||||
func (s *Store) ListHostAgencySalaryPolicyRegionIDsForCycle(appCode string, cycleKey string, regionIDs []int64) ([]int64, error) {
|
||||
if len(regionIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var covered []int64
|
||||
err := s.db.Model(&model.HostAgencySalaryPolicyRegion{}).
|
||||
Distinct("admin_host_agency_salary_policy_regions.region_id").
|
||||
Joins("JOIN admin_host_agency_salary_policies policy ON policy.id = admin_host_agency_salary_policy_regions.policy_id").
|
||||
Where("policy.app_code = ? AND policy.cycle_key = ? AND admin_host_agency_salary_policy_regions.region_id IN ?", strings.TrimSpace(appCode), strings.TrimSpace(cycleKey), regionIDs).
|
||||
Order("admin_host_agency_salary_policy_regions.region_id ASC").
|
||||
Pluck("admin_host_agency_salary_policy_regions.region_id", &covered).Error
|
||||
return covered, err
|
||||
}
|
||||
|
||||
// ListPublishedActiveHostAgencySalaryPoliciesForCycle 返回可安全继承的完整不可变版本。
|
||||
// 只复制已成功发布且仍启用的政策,避免把草稿或失败配置悄悄带入新工资月。
|
||||
func (s *Store) ListPublishedActiveHostAgencySalaryPoliciesForCycle(appCode string, cycleKey string) ([]model.HostAgencySalaryPolicy, error) {
|
||||
var items []model.HostAgencySalaryPolicy
|
||||
err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Order("level_no ASC")
|
||||
}).Where(
|
||||
"app_code = ? AND cycle_key = ? AND status = ? AND publish_status = ?",
|
||||
strings.TrimSpace(appCode), strings.TrimSpace(cycleKey), "active", "published",
|
||||
).Order("id ASC").Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := make([]*model.HostAgencySalaryPolicy, len(items))
|
||||
for index := range items {
|
||||
refs[index] = &items[index]
|
||||
}
|
||||
if err := s.loadHostAgencySalaryPolicyRegions(s.db, refs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error {
|
||||
@ -80,6 +143,12 @@ func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy)
|
||||
if err := tx.Model(item).UpdateColumn("policy_version", item.PolicyVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceHostAgencySalaryPolicyRegions(tx, item.ID, item.RegionIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceHostAgencySalaryPolicyActiveScopes(tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range levels {
|
||||
levels[index].PolicyID = item.ID
|
||||
}
|
||||
@ -102,6 +171,12 @@ func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy)
|
||||
if err := tx.Save(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceHostAgencySalaryPolicyRegions(tx, item.ID, item.RegionIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceHostAgencySalaryPolicyActiveScopes(tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@ -133,9 +208,28 @@ func (s *Store) UpdateHostAgencySalaryPolicyPublishState(appCode string, id uint
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Store) UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished(appCode string, id uint, publishError string, actorID uint) error {
|
||||
// wallet 提交与 admin 状态回写跨库,失败回写必须带状态条件:并发成功发布已经写入 published 后,
|
||||
// 较晚返回的重复发布错误不能把真实成功状态覆盖成 failed,也不能清空成功时间。
|
||||
return s.db.Model(&model.HostAgencySalaryPolicy{}).
|
||||
Where("app_code = ? AND id = ? AND publish_status <> ? AND published_at_ms = 0", strings.TrimSpace(appCode), id, "published").
|
||||
Updates(map[string]any{
|
||||
"publish_status": "failed",
|
||||
"publish_error": strings.TrimSpace(publishError),
|
||||
"published_by_admin_id": actorID,
|
||||
"updated_by_admin_id": actorID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先删明细再删主表,兼容没有外键级联的部署环境。
|
||||
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryPolicyActiveScope{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryPolicyRegion{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@ -144,9 +238,10 @@ func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error {
|
||||
}
|
||||
|
||||
func (s *Store) HasActiveHostAgencySalaryPolicyForCycle(appCode string, regionID int64, cycleKey string, excludeID uint) (bool, error) {
|
||||
// 工资政策以周期为唯一选择边界;不同月份允许并存,同一区域同一月份只能有一条 active 配置。
|
||||
// 工资政策以周期为唯一选择边界;关联表索引先按区域收敛,再由主表校验 App、周期和状态。
|
||||
db := s.db.Model(&model.HostAgencySalaryPolicy{}).
|
||||
Where("app_code = ? AND region_id = ? AND cycle_key = ? AND status = ?", strings.TrimSpace(appCode), regionID, strings.TrimSpace(cycleKey), "active")
|
||||
Joins("JOIN admin_host_agency_salary_policy_regions policy_region ON policy_region.policy_id = admin_host_agency_salary_policies.id").
|
||||
Where("admin_host_agency_salary_policies.app_code = ? AND policy_region.region_id = ? AND admin_host_agency_salary_policies.cycle_key = ? AND admin_host_agency_salary_policies.status = ?", strings.TrimSpace(appCode), regionID, strings.TrimSpace(cycleKey), "active")
|
||||
if excludeID > 0 {
|
||||
db = db.Where("id <> ?", excludeID)
|
||||
}
|
||||
@ -156,3 +251,70 @@ func (s *Store) HasActiveHostAgencySalaryPolicyForCycle(appCode string, regionID
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) loadHostAgencySalaryPolicyRegions(db *gorm.DB, items []*model.HostAgencySalaryPolicy) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
policyIDs := make([]uint, 0, len(items))
|
||||
byPolicyID := make(map[uint]int, len(items))
|
||||
for index := range items {
|
||||
policyIDs = append(policyIDs, items[index].ID)
|
||||
byPolicyID[items[index].ID] = index
|
||||
}
|
||||
var rows []model.HostAgencySalaryPolicyRegion
|
||||
// 映射表按 policy_id 主键前缀批量读取,列表页只额外增加一次有界查询,避免 N+1。
|
||||
if err := db.Where("policy_id IN ?", policyIDs).Order("policy_id ASC, region_id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if index, ok := byPolicyID[row.PolicyID]; ok {
|
||||
items[index].RegionIDs = append(items[index].RegionIDs, row.RegionID)
|
||||
}
|
||||
}
|
||||
for index := range items {
|
||||
if len(items[index].RegionIDs) == 0 && items[index].RegionID > 0 {
|
||||
items[index].RegionIDs = []int64{items[index].RegionID}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceHostAgencySalaryPolicyRegions(tx *gorm.DB, policyID uint, regionIDs []int64) error {
|
||||
if err := tx.Where("policy_id = ?", policyID).Delete(&model.HostAgencySalaryPolicyRegion{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]model.HostAgencySalaryPolicyRegion, 0, len(regionIDs))
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
for _, regionID := range regionIDs {
|
||||
rows = append(rows, model.HostAgencySalaryPolicyRegion{PolicyID: policyID, RegionID: regionID, CreatedAtMS: nowMS})
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
}
|
||||
|
||||
func replaceHostAgencySalaryPolicyActiveScopes(tx *gorm.DB, item *model.HostAgencySalaryPolicy) error {
|
||||
// 先释放当前政策旧占位;删除锁会一直持有到事务提交,后续任一新区域冲突都会让整个事务回滚,
|
||||
// 因而 active->disabled、区域变更和创建都不会暴露半套占位。
|
||||
if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryPolicyActiveScope{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(item.Status) != "active" {
|
||||
return nil
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
rows := make([]model.HostAgencySalaryPolicyActiveScope, 0, len(item.RegionIDs))
|
||||
for _, regionID := range item.RegionIDs {
|
||||
rows = append(rows, model.HostAgencySalaryPolicyActiveScope{
|
||||
AppCode: item.AppCode, RegionID: regionID, CycleKey: item.CycleKey, PolicyID: item.ID,
|
||||
CreatedAtMS: nowMS, UpdatedAtMS: nowMS,
|
||||
})
|
||||
}
|
||||
if err := tx.Create(&rows).Error; err != nil {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return ErrHostAgencySalaryPolicyActiveScopeConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -72,6 +72,8 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.AppVersion{},
|
||||
&model.AppExploreTab{},
|
||||
&model.HostAgencySalaryPolicy{},
|
||||
&model.HostAgencySalaryPolicyRegion{},
|
||||
&model.HostAgencySalaryPolicyActiveScope{},
|
||||
&model.HostAgencySalaryLevel{},
|
||||
&model.PolicyTemplate{},
|
||||
&model.PolicyTemplateVersion{},
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 多区域映射只包含政策配置小表;主表保留 region_id 作为旧索引和旧客户端的首个区域兼容值。
|
||||
CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policy_regions (
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
|
||||
region_id BIGINT NOT NULL COMMENT '适用区域 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (policy_id, region_id),
|
||||
KEY idx_host_agency_policy_region (region_id, policy_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策多区域关联表';
|
||||
|
||||
-- 旧单区域政策逐行补一条映射;主键让重复执行幂等,且不扫描工资账户、结算或钱包流水大表。
|
||||
INSERT IGNORE INTO admin_host_agency_salary_policy_regions (policy_id, region_id, created_at_ms)
|
||||
SELECT id, region_id, created_at_ms
|
||||
FROM admin_host_agency_salary_policies
|
||||
WHERE region_id > 0;
|
||||
|
||||
-- 多区域映射允许一份政策覆盖多个区域;本占位表用业务主键把 active 唯一性下沉到 MySQL,
|
||||
-- 从而让并发新增、编辑和自动月复制不能同时占用同一 App/区域/月。表中只保存政策配置占位,不读取工资或钱包流水。
|
||||
CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policy_active_scopes (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
region_id BIGINT NOT NULL COMMENT '适用区域 ID',
|
||||
cycle_key VARCHAR(7) NOT NULL COMMENT '工资周期,UTC yyyy-MM',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '占用该区域/月的启用政策 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, region_id, cycle_key),
|
||||
KEY idx_host_agency_salary_policy_active_scope_policy (policy_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='启用工资政策区域月份占位表';
|
||||
|
||||
-- 同一政策的已有占位在重跑时跳过;若历史数据已经存在两个 active 政策覆盖同一区域/月,
|
||||
-- 主键冲突会让整条回填原子失败,要求先清理冲突,不能用 INSERT IGNORE 随机选择一份口径。
|
||||
INSERT INTO admin_host_agency_salary_policy_active_scopes (
|
||||
app_code, region_id, cycle_key, policy_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT policy.app_code, policy_region.region_id, policy.cycle_key, policy.id,
|
||||
policy.created_at_ms, policy.updated_at_ms
|
||||
FROM admin_host_agency_salary_policies policy
|
||||
JOIN admin_host_agency_salary_policy_regions policy_region ON policy_region.policy_id = policy.id
|
||||
WHERE policy.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM admin_host_agency_salary_policy_active_scopes active_scope
|
||||
WHERE active_scope.app_code = policy.app_code
|
||||
AND active_scope.region_id = policy_region.region_id
|
||||
AND active_scope.cycle_key = policy.cycle_key
|
||||
AND active_scope.policy_id = policy.id
|
||||
);
|
||||
@ -0,0 +1,68 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET time_zone = '+00:00';
|
||||
|
||||
-- 工资政策是小型配置表;这里只补复制来源和月查询索引,不读取工资账户、结算记录或钱包流水。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'copied_from_policy_id') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN copied_from_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''自动复制来源政策 ID'' AFTER published_by_admin_id, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'copied_from_cycle_key') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN copied_from_cycle_key VARCHAR(7) NOT NULL DEFAULT '''' COMMENT ''自动复制来源工资月,UTC yyyy-MM'' AFTER copied_from_policy_id, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 同名政策允许每月各有一个不可变版本;只在索引唯一性或有序列组成不正确时重建,
|
||||
-- 正确状态下重复执行迁移不触发 DDL,也不制造短暂的唯一约束空窗。
|
||||
SET @name_index_signature := (
|
||||
SELECT CONCAT(MIN(NON_UNIQUE), ':', GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','))
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_host_agency_salary_policies'
|
||||
AND INDEX_NAME = 'uk_admin_host_agency_salary_policy_name'
|
||||
);
|
||||
SET @ddl := IF(
|
||||
@name_index_signature IS NOT NULL
|
||||
AND @name_index_signature <> '0:app_code,region_id,cycle_key,name',
|
||||
'ALTER TABLE admin_host_agency_salary_policies DROP INDEX uk_admin_host_agency_salary_policy_name, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @name_index_signature := (
|
||||
SELECT CONCAT(MIN(NON_UNIQUE), ':', GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','))
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'admin_host_agency_salary_policies'
|
||||
AND INDEX_NAME = 'uk_admin_host_agency_salary_policy_name'
|
||||
);
|
||||
SET @ddl := IF(
|
||||
@name_index_signature IS NULL,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD UNIQUE INDEX uk_admin_host_agency_salary_policy_name (app_code, region_id, cycle_key, name), ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND INDEX_NAME = 'idx_admin_host_agency_salary_policy_month_copy') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD INDEX idx_admin_host_agency_salary_policy_month_copy (app_code, cycle_key, status, publish_status, id), ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,46 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 工资政策是按 App/月份维护的小型配置表;四次在线 DDL 只改表结构,不读取钱包、提现或工资流水。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'coin_seller_withdrawal_limit_period') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN coin_seller_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT ''month'' COMMENT ''用户找币商提现限制周期:day/week/month'' AFTER residual_diamond_to_usd_rate, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'coin_seller_withdrawal_limit_count') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN coin_seller_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT ''用户找币商提现次数,0 表示不限'' AFTER coin_seller_withdrawal_limit_period, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'platform_withdrawal_limit_period') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN platform_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT ''month'' COMMENT ''用户找平台提现限制周期:day/week/month'' AFTER coin_seller_withdrawal_limit_count, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'platform_withdrawal_limit_count') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN platform_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT ''用户找平台提现次数,0 表示不限'' AFTER platform_withdrawal_limit_period, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -6,8 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -19,8 +18,12 @@ const (
|
||||
MethodUSDTTRC20 = "usdt_trc20"
|
||||
)
|
||||
|
||||
// ErrApplicationOutcomeUnknown 表示 INSERT 的返回结果和后续事实查询都不可用。
|
||||
// 调用方不能把它当成“确认未落库”而释放已冻结资金,只能保留冻结并让同一业务幂等键后续重试收敛。
|
||||
var ErrApplicationOutcomeUnknown = errors.New("finance withdrawal application outcome is unknown")
|
||||
|
||||
// CreateApplicationCommand 是 H5 提交到后台审核链的提现申请事实。
|
||||
// gateway 在调用前已经完成身份、余额和收款地址校验,这里只保护表字段的持久化边界。
|
||||
// gateway 在调用前已经完成身份、收款地址校验以及 wallet 原子冻结,这里只保护表字段的持久化边界。
|
||||
type CreateApplicationCommand struct {
|
||||
AppCode string
|
||||
UserID int64
|
||||
@ -146,19 +149,11 @@ VALUES
|
||||
command.CreatedAtMS,
|
||||
)
|
||||
if err != nil {
|
||||
if isDuplicateWithdrawalFreezeCommand(err) {
|
||||
if existing, found, lookupErr := w.getApplicationByFreezeCommand(ctx, command.AppCode, command.FreezeCommandID); lookupErr != nil || found {
|
||||
if lookupErr != nil {
|
||||
return Application{}, lookupErr
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
return Application{}, err
|
||||
return w.reconcileApplicationAfterInsertError(ctx, command, err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
return w.reconcileApplicationAfterInsertError(ctx, command, err)
|
||||
}
|
||||
return Application{
|
||||
ID: id,
|
||||
@ -183,6 +178,26 @@ VALUES
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *MySQLWriter) reconcileApplicationAfterInsertError(ctx context.Context, command CreateApplicationCommand, insertErr error) (Application, error) {
|
||||
// ExecContext/LastInsertId 的错误不等于 INSERT 一定没有提交,例如响应在服务端提交后丢失。
|
||||
// 重查使用脱离请求取消信号的短上下文,否则原请求超时会让结果确认必然失败并错误回滚钱包冻结。
|
||||
lookupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
existing, found, lookupErr := w.getApplicationByFreezeCommand(lookupCtx, command.AppCode, command.FreezeCommandID)
|
||||
if found {
|
||||
return existing, nil
|
||||
}
|
||||
if lookupErr != nil {
|
||||
return Application{}, fmt.Errorf("%w: insert failed: %v; result lookup failed: %v", ErrApplicationOutcomeUnknown, insertErr, lookupErr)
|
||||
}
|
||||
return Application{}, insertErr
|
||||
}
|
||||
|
||||
// IsApplicationOutcomeUnknown 供冻结编排区分“确认未写入”和“无法确认”;只有前者允许补偿释放。
|
||||
func IsApplicationOutcomeUnknown(err error) bool {
|
||||
return errors.Is(err, ErrApplicationOutcomeUnknown)
|
||||
}
|
||||
|
||||
func (w *MySQLWriter) getApplicationByFreezeCommand(ctx context.Context, appCode string, freezeCommandID string) (Application, bool, error) {
|
||||
row := w.db.QueryRowContext(ctx, `
|
||||
SELECT id, app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor,
|
||||
@ -228,11 +243,6 @@ LIMIT 1`, appCode, freezeCommandID)
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
func isDuplicateWithdrawalFreezeCommand(err error) bool {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func normalizeCreateApplicationCommand(command CreateApplicationCommand) CreateApplicationCommand {
|
||||
command.AppCode = strings.TrimSpace(command.AppCode)
|
||||
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
|
||||
|
||||
@ -3,6 +3,7 @@ package financewithdrawal
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@ -142,6 +143,108 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateApplicationReconcilesAnyInsertErrorByFreezeCommand(t *testing.T) {
|
||||
db, mock, closeDB := newWriterSQLMock(t)
|
||||
defer closeDB()
|
||||
writer := &MySQLWriter{db: db}
|
||||
|
||||
mock.ExpectQuery(`(?s)FROM admin_user_withdrawal_applications\s+WHERE app_code = \? AND freeze_command_id = \?`).
|
||||
WithArgs("huwaa", "cmd-point-uncertain").
|
||||
WillReturnRows(withdrawalApplicationRows())
|
||||
// 非重复键错误同样可能发生在 INSERT 已提交但响应丢失之后,不能据此直接释放钱包冻结。
|
||||
mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`).
|
||||
WillReturnError(errors.New("insert result uncertain"))
|
||||
mock.ExpectQuery(`(?s)FROM admin_user_withdrawal_applications\s+WHERE app_code = \? AND freeze_command_id = \?`).
|
||||
WithArgs("huwaa", "cmd-point-uncertain").
|
||||
WillReturnRows(withdrawalApplicationRows().AddRow(
|
||||
int64(91),
|
||||
"huwaa",
|
||||
"42001",
|
||||
"POINT",
|
||||
"9.50",
|
||||
int64(1000000),
|
||||
int64(50000),
|
||||
int64(950000),
|
||||
int64(100000),
|
||||
int32(500),
|
||||
"huwaa-policy-v1",
|
||||
MethodUSDTTRC20,
|
||||
"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
"cmd-point-uncertain",
|
||||
"tx-freeze",
|
||||
StatusPending,
|
||||
OperationsStatusPending,
|
||||
int64(1700000000000),
|
||||
int64(1700000000000),
|
||||
))
|
||||
|
||||
got, err := writer.CreateApplication(context.Background(), CreateApplicationCommand{
|
||||
AppCode: "huwaa",
|
||||
UserID: 42001,
|
||||
SalaryAssetType: "POINT",
|
||||
WithdrawAmount: "9.50",
|
||||
WithdrawAmountMinor: 1_000_000,
|
||||
PointFeeAmount: 50_000,
|
||||
PointNetAmount: 950_000,
|
||||
PointsPerUSD: 100_000,
|
||||
PointFeeBPS: 500,
|
||||
PointPolicyInstance: "huwaa-policy-v1",
|
||||
WithdrawMethod: MethodUSDTTRC20,
|
||||
WithdrawAddress: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
FreezeCommandID: "cmd-point-uncertain",
|
||||
FreezeTransactionID: "tx-freeze",
|
||||
CreatedAtMS: 1700000000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateApplication should absorb uncertain insert result after finding persisted row: %v", err)
|
||||
}
|
||||
if got.ID != 91 || got.FreezeCommandID != "cmd-point-uncertain" || got.FreezeTransactionID != "tx-freeze" {
|
||||
t.Fatalf("reconciled withdrawal application mismatch: %+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateApplicationMarksOutcomeUnknownWhenInsertAndLookupBothFail(t *testing.T) {
|
||||
db, mock, closeDB := newWriterSQLMock(t)
|
||||
defer closeDB()
|
||||
writer := &MySQLWriter{db: db}
|
||||
|
||||
mock.ExpectQuery(`(?s)FROM admin_user_withdrawal_applications\s+WHERE app_code = \? AND freeze_command_id = \?`).
|
||||
WithArgs("huwaa", "cmd-point-unknown").
|
||||
WillReturnRows(withdrawalApplicationRows())
|
||||
mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`).
|
||||
WillReturnError(errors.New("insert result uncertain"))
|
||||
mock.ExpectQuery(`(?s)FROM admin_user_withdrawal_applications\s+WHERE app_code = \? AND freeze_command_id = \?`).
|
||||
WithArgs("huwaa", "cmd-point-unknown").
|
||||
WillReturnError(errors.New("result lookup unavailable"))
|
||||
|
||||
_, err := writer.CreateApplication(context.Background(), CreateApplicationCommand{
|
||||
AppCode: "huwaa",
|
||||
UserID: 42001,
|
||||
SalaryAssetType: "POINT",
|
||||
WithdrawAmount: "9.50",
|
||||
WithdrawAmountMinor: 1_000_000,
|
||||
PointFeeAmount: 50_000,
|
||||
PointNetAmount: 950_000,
|
||||
PointsPerUSD: 100_000,
|
||||
PointFeeBPS: 500,
|
||||
PointPolicyInstance: "huwaa-policy-v1",
|
||||
WithdrawMethod: MethodUSDTTRC20,
|
||||
WithdrawAddress: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
FreezeCommandID: "cmd-point-unknown",
|
||||
FreezeTransactionID: "tx-freeze",
|
||||
CreatedAtMS: 1700000000000,
|
||||
})
|
||||
if !IsApplicationOutcomeUnknown(err) {
|
||||
t.Fatalf("failed result confirmation must retain outcome-unknown classification, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawalApplicationRows() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"id",
|
||||
|
||||
@ -302,6 +302,7 @@ type WalletHandlers struct {
|
||||
HandleSalaryWalletWithdrawAddress http.HandlerFunc
|
||||
WithdrawSalaryWallet http.HandlerFunc
|
||||
GetPointWalletOverview http.HandlerFunc
|
||||
GetPointWalletExchangeConfig http.HandlerFunc
|
||||
WithdrawPointWallet http.HandlerFunc
|
||||
TransferPointWalletToCoinSeller http.HandlerFunc
|
||||
ExchangePointWalletToCoins http.HandlerFunc
|
||||
@ -727,6 +728,7 @@ func (r routes) registerWalletRoutes() {
|
||||
r.profile("/salary-wallet/withdraw-address", "", h.HandleSalaryWalletWithdrawAddress)
|
||||
r.profile("/salary-wallet/withdraw", http.MethodPost, h.WithdrawSalaryWallet)
|
||||
r.profile("/point-wallet/overview", http.MethodGet, h.GetPointWalletOverview)
|
||||
r.profile("/point-wallet/exchange-config", http.MethodGet, h.GetPointWalletExchangeConfig)
|
||||
r.profile("/point-wallet/withdraw", http.MethodPost, h.WithdrawPointWallet)
|
||||
r.profile("/point-wallet/transfer-to-coin-seller", http.MethodPost, h.TransferPointWalletToCoinSeller)
|
||||
r.profile("/point-wallet/exchange-to-coins", http.MethodPost, h.ExchangePointWalletToCoins)
|
||||
|
||||
@ -626,6 +626,8 @@ type fakeWalletClient struct {
|
||||
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
||||
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
|
||||
lastPointExchange *walletv1.ExchangePointToCoinRequest
|
||||
pointExchangeResp *walletv1.ExchangePointToCoinResponse
|
||||
lastTransactions *walletv1.ListWalletTransactionsRequest
|
||||
transactionsResp *walletv1.ListWalletTransactionsResponse
|
||||
lastVipPackages *walletv1.ListVipPackagesRequest
|
||||
@ -2369,6 +2371,17 @@ func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *wall
|
||||
return &walletv1.GetDiamondExchangeConfigResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ExchangePointToCoin(_ context.Context, req *walletv1.ExchangePointToCoinRequest) (*walletv1.ExchangePointToCoinResponse, error) {
|
||||
f.lastPointExchange = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.pointExchangeResp != nil {
|
||||
return f.pointExchangeResp, nil
|
||||
}
|
||||
return &walletv1.ExchangePointToCoinResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListWalletTransactions(_ context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
|
||||
f.lastTransactions = req
|
||||
if f.err != nil {
|
||||
|
||||
@ -117,6 +117,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
||||
HandleSalaryWalletWithdrawAddress: h.handleSalaryWalletWithdrawAddress,
|
||||
WithdrawSalaryWallet: h.withdrawSalaryWallet,
|
||||
GetPointWalletOverview: h.getPointWalletOverview,
|
||||
GetPointWalletExchangeConfig: h.getPointWalletExchangeConfig,
|
||||
WithdrawPointWallet: h.withdrawPointWallet,
|
||||
TransferPointWalletToCoinSeller: h.transferPointWalletToCoinSeller,
|
||||
ExchangePointWalletToCoins: h.exchangePointWalletToCoins,
|
||||
|
||||
@ -2,7 +2,8 @@ package walletapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -18,7 +19,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
pointWalletAssetType = "POINT"
|
||||
pointWalletAssetType = "POINT"
|
||||
pointWalletTargetAssetType = "COIN"
|
||||
pointWithdrawalRollbackAttempts = 3
|
||||
pointWithdrawalRollbackAttemptTimeout = 1500 * time.Millisecond
|
||||
pointWithdrawalRollbackBackoffBaseDelay = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
type pointWithdrawalGatewayClient interface {
|
||||
@ -61,6 +66,17 @@ type pointWalletBalanceData struct {
|
||||
DisplayUSD string `json:"display_usd"`
|
||||
}
|
||||
|
||||
type pointWalletExchangeConfigData struct {
|
||||
SourceAssetType string `json:"source_asset_type"`
|
||||
TargetAssetType string `json:"target_asset_type"`
|
||||
PointsPerUSD int64 `json:"points_per_usd"`
|
||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||
RatioPointAmount int64 `json:"ratio_point_amount"`
|
||||
RatioCoinAmount int64 `json:"ratio_coin_amount"`
|
||||
RoundingMode string `json:"rounding_mode"`
|
||||
PolicyInstanceCode string `json:"policy_instance_code"`
|
||||
}
|
||||
|
||||
type pointWalletWithdrawBody struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDCamel string `json:"commandId"`
|
||||
@ -242,6 +258,51 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
|
||||
})
|
||||
}
|
||||
|
||||
// getPointWalletExchangeConfig 只返回 Flutter 预览兑换所需的最小运行合同;
|
||||
// region 从当前登录用户资料解析,客户端不能通过自报地区选择其他政策。
|
||||
func (h *Handler) getPointWalletExchangeConfig(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if profileResp.GetUser() == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profileResp.GetUser().GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if !config.GetFound() || config.GetPointsPerUsd() <= 0 || config.GetCoinsPerUsd() <= 0 {
|
||||
// 没有完整已发布政策时不向 App 暴露伪造默认比例,兑换写入口也会在 wallet-service 拒绝。
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point exchange policy is not configured")
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, pointWalletExchangeConfigData{
|
||||
SourceAssetType: pointWalletAssetType, TargetAssetType: pointWalletTargetAssetType,
|
||||
PointsPerUSD: config.GetPointsPerUsd(), CoinsPerUSD: config.GetCoinsPerUsd(),
|
||||
RatioPointAmount: config.GetPointsPerUsd(), RatioCoinAmount: config.GetCoinsPerUsd(),
|
||||
RoundingMode: "floor", PolicyInstanceCode: config.GetPolicyInstanceCode(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
@ -292,27 +353,13 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
}
|
||||
feePoints := grossPoints * int64(config.GetFeeBps()) / 10000
|
||||
netPoints := grossPoints - feePoints
|
||||
balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
UserId: userID,
|
||||
AssetTypes: []string{pointWalletAssetType},
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
balance := firstBalance(balanceResp.GetBalances())
|
||||
if balance == nil || balance.GetAvailableAmount() < grossPoints {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "insufficient balance")
|
||||
return
|
||||
}
|
||||
address, ok := h.resolveSalaryWalletWithdrawAddress(writer, request, userID, body.usdtTRC20Address())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
freezeCommandID := pointWithdrawalFreezeCommandID(submittedCommandID, httpkit.RequestIDFromContext(request.Context()), nowMS)
|
||||
freezeCommandID := strings.TrimSpace(submittedCommandID)
|
||||
withdrawalRef := pointWithdrawalReference(freezeCommandID)
|
||||
freezeResp, err := pointWallet.FreezePointWithdrawal(request.Context(), &walletv1.FreezePointWithdrawalRequest{
|
||||
CommandId: freezeCommandID,
|
||||
UserId: userID,
|
||||
@ -324,22 +371,30 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
FeeBps: config.GetFeeBps(),
|
||||
Reason: "point withdrawal submitted",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
WithdrawalRef: httpkit.RequestIDFromContext(request.Context()),
|
||||
WithdrawalRef: withdrawalRef,
|
||||
RegionId: profile.GetRegionId(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
// 同一客户端 command_id 可能跨月、跨政策重试;wallet 命中首次冻结交易时返回的是首次账变快照。
|
||||
// 从这里开始只使用回执金额和比例,避免用本次配置创建不匹配的申请,或在失败补偿时释放错误金额。
|
||||
frozenGrossPoints := freezeResp.GetGrossPointAmount()
|
||||
frozenFeePoints := freezeResp.GetFeePointAmount()
|
||||
frozenNetPoints := freezeResp.GetNetPointAmount()
|
||||
frozenPointsPerUSD := freezeResp.GetPointsPerUsd()
|
||||
frozenFeeBPS := freezeResp.GetFeeBps()
|
||||
application, err := h.withdrawalWriter.CreateApplication(request.Context(), financewithdrawal.CreateApplicationCommand{
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserID: userID,
|
||||
SalaryAssetType: pointWalletAssetType,
|
||||
WithdrawAmount: formatPointUSD(netPoints, config.GetPointsPerUsd()),
|
||||
WithdrawAmountMinor: grossPoints,
|
||||
PointFeeAmount: feePoints,
|
||||
PointNetAmount: netPoints,
|
||||
PointsPerUSD: config.GetPointsPerUsd(),
|
||||
PointFeeBPS: config.GetFeeBps(),
|
||||
WithdrawAmount: formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
|
||||
WithdrawAmountMinor: frozenGrossPoints,
|
||||
PointFeeAmount: frozenFeePoints,
|
||||
PointNetAmount: frozenNetPoints,
|
||||
PointsPerUSD: frozenPointsPerUSD,
|
||||
PointFeeBPS: frozenFeeBPS,
|
||||
PointPolicyInstance: config.GetPolicyInstanceCode(),
|
||||
WithdrawMethod: financewithdrawal.MethodUSDTTRC20,
|
||||
WithdrawAddress: address,
|
||||
@ -348,7 +403,14 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
CreatedAtMS: nowMS,
|
||||
})
|
||||
if err != nil {
|
||||
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, grossPoints, feePoints, netPoints, config.GetPointsPerUsd(), config.GetFeeBps())
|
||||
if financewithdrawal.IsApplicationOutcomeUnknown(err) {
|
||||
// 申请可能已经落库,只是 INSERT 响应和事实重查同时失败;保留冻结后,同一客户端
|
||||
// command_id 的重试会先命中钱包幂等回执,再由 writer 重查申请并完成状态收敛。
|
||||
slog.Error("point_withdrawal_application_outcome_unknown", "user_id", userID, "app_code", appcode.FromContext(request.Context()), "freeze_command_id", freezeCommandID, "withdrawal_ref", withdrawalRef, "error", err)
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, frozenGrossPoints, frozenFeePoints, frozenNetPoints, frozenPointsPerUSD, frozenFeeBPS)
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
@ -363,16 +425,16 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
"status": application.Status,
|
||||
"operations_status": application.OperationsStatus,
|
||||
"asset_type": pointWalletAssetType,
|
||||
"gross_point_amount": grossPoints,
|
||||
"fee_point_amount": feePoints,
|
||||
"net_point_amount": netPoints,
|
||||
"points_per_usd": config.GetPointsPerUsd(),
|
||||
"fee_bps": config.GetFeeBps(),
|
||||
"gross_point_amount": frozenGrossPoints,
|
||||
"fee_point_amount": frozenFeePoints,
|
||||
"net_point_amount": frozenNetPoints,
|
||||
"points_per_usd": frozenPointsPerUSD,
|
||||
"fee_bps": frozenFeeBPS,
|
||||
"policy_instance_code": config.GetPolicyInstanceCode(),
|
||||
"gross_usd": formatPointUSD(grossPoints, config.GetPointsPerUsd()),
|
||||
"fee_usd": formatPointUSD(feePoints, config.GetPointsPerUsd()),
|
||||
"net_usd": formatPointUSD(netPoints, config.GetPointsPerUsd()),
|
||||
"balance_after": pointBalanceFromProto(freezeResp.GetBalance(), config.GetPointsPerUsd()),
|
||||
"gross_usd": formatPointUSD(frozenGrossPoints, frozenPointsPerUSD),
|
||||
"fee_usd": formatPointUSD(frozenFeePoints, frozenPointsPerUSD),
|
||||
"net_usd": formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
|
||||
"balance_after": pointBalanceFromProto(freezeResp.GetBalance(), frozenPointsPerUSD),
|
||||
"withdraw_method": application.WithdrawMethod,
|
||||
"withdraw_address": application.WithdrawAddress,
|
||||
"freeze_transaction_id": freezeResp.GetTransactionId(),
|
||||
@ -411,25 +473,19 @@ func formatPointUSD(points int64, pointsPerUSD int64) string {
|
||||
return formatUSDMinor(usdMinor)
|
||||
}
|
||||
|
||||
func pointWithdrawalFreezeCommandID(submittedCommandID string, requestID string, nowMS int64) string {
|
||||
if value := strings.TrimSpace(submittedCommandID); value != "" {
|
||||
return value
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID != "" {
|
||||
return "point-withdrawal:" + requestID
|
||||
}
|
||||
return fmt.Sprintf("point-withdrawal:%d", nowMS)
|
||||
func pointWithdrawalReference(freezeCommandID string) string {
|
||||
// request_id 只用于链路追踪,每次 HTTP 重试都会变化。对客户端幂等键做固定摘要,既让
|
||||
// freeze/rollback 命中同一 reservation,又把引用稳定限制在 VARCHAR(128) 边界内。
|
||||
digest := sha256.Sum256([]byte(strings.TrimSpace(freezeCommandID)))
|
||||
return "point-withdrawal:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
|
||||
rollbackCommandID := strings.TrimSpace(freezeCommandID) + ":rollback"
|
||||
if len(rollbackCommandID) > 128 {
|
||||
rollbackCommandID = fmt.Sprintf("point-withdrawal-rollback:%s", httpkit.RequestIDFromContext(request.Context()))
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(request.Context())), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := walletClient.ReleasePointWithdrawal(rollbackCtx, &walletv1.ReleasePointWithdrawalRequest{
|
||||
withdrawalRef := pointWithdrawalReference(freezeCommandID)
|
||||
// 回滚事务也必须跨 HTTP 重试保持同一个 command_id;直接由固定长度的 reservation 引用派生,
|
||||
// 不会因为原始 command_id 接近 128 字节而退化为不稳定的 request_id。
|
||||
rollbackCommandID := withdrawalRef + ":rollback"
|
||||
rollbackRequest := &walletv1.ReleasePointWithdrawalRequest{
|
||||
CommandId: rollbackCommandID,
|
||||
UserId: userID,
|
||||
AssetType: pointWalletAssetType,
|
||||
@ -440,10 +496,23 @@ func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWith
|
||||
FeeBps: feeBPS,
|
||||
Reason: "point withdrawal application create failed",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("point_withdrawal_freeze_rollback_failed", "user_id", userID, "gross_point_amount", grossPoints, "freeze_command_id", freezeCommandID, "error", err)
|
||||
WithdrawalRef: withdrawalRef,
|
||||
}
|
||||
baseCtx := appcode.WithContext(context.Background(), appcode.FromContext(request.Context()))
|
||||
var rollbackErr error
|
||||
for attempt := 1; attempt <= pointWithdrawalRollbackAttempts; attempt++ {
|
||||
// 每次 RPC 使用独立短超时;前一次连接结果不确定时,后续尝试携带相同幂等键只会确认或返回同一回执。
|
||||
rollbackCtx, cancel := context.WithTimeout(baseCtx, pointWithdrawalRollbackAttemptTimeout)
|
||||
_, rollbackErr = walletClient.ReleasePointWithdrawal(rollbackCtx, rollbackRequest)
|
||||
cancel()
|
||||
if rollbackErr == nil {
|
||||
return
|
||||
}
|
||||
if attempt < pointWithdrawalRollbackAttempts {
|
||||
time.Sleep(time.Duration(attempt) * pointWithdrawalRollbackBackoffBaseDelay)
|
||||
}
|
||||
}
|
||||
slog.Error("point_withdrawal_freeze_rollback_failed", "user_id", userID, "gross_point_amount", grossPoints, "freeze_command_id", freezeCommandID, "withdrawal_ref", withdrawalRef, "attempts", pointWithdrawalRollbackAttempts, "error", rollbackErr)
|
||||
}
|
||||
|
||||
// transferPointWalletToCoinSeller 只接收目标和 POINT 金额;wallet 事务会重新读取 active 白名单与比例。
|
||||
@ -508,6 +577,7 @@ func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, re
|
||||
CommandId: commandID, AppCode: appcode.FromContext(request.Context()), SourceUserId: userID,
|
||||
SellerUserId: sellerUserID, PointAmount: pointAmount,
|
||||
SourceCountryCode: pointWalletCountryCode(profileResp.GetUser()), Reason: "point wallet withdrawal to coin seller",
|
||||
RegionId: profileResp.GetUser().GetRegionId(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@ -36,7 +37,7 @@ func TestWithdrawPointWalletRequiresHuwaaAndClientCommandID(t *testing.T) {
|
||||
writer := &fakePointWithdrawalWriter{}
|
||||
handler = New(Config{
|
||||
WalletClient: wallet,
|
||||
UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"},
|
||||
UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
|
||||
WithdrawalWriter: writer,
|
||||
})
|
||||
missingCommandRecorder := httptest.NewRecorder()
|
||||
@ -52,12 +53,17 @@ func TestWithdrawPointWalletRequiresHuwaaAndClientCommandID(t *testing.T) {
|
||||
|
||||
func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{
|
||||
balances: []*walletv1.AssetBalance{{AssetType: "POINT", AvailableAmount: 2_000_000, FrozenAmount: 0, Version: 3}},
|
||||
// Gateway 不读取该余额快照;真正的 available 校验必须和冻结写入一起由 wallet 事务完成。
|
||||
balances: []*walletv1.AssetBalance{{AssetType: "POINT", AvailableAmount: 0, FrozenAmount: 0, Version: 3}},
|
||||
pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{
|
||||
{Found: true, PointsPerUsd: 100_000, CoinsPerUsd: 80_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"},
|
||||
{Found: true, PointsPerUsd: 200_000, CoinsPerUsd: 160_000, FeeBps: 1_000, MinimumPoints: 1_000_000, PolicyInstanceCode: "changed-policy"},
|
||||
},
|
||||
}
|
||||
writer := &fakePointWithdrawalWriter{}
|
||||
handler := New(Config{
|
||||
WalletClient: wallet,
|
||||
UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"},
|
||||
UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
|
||||
WithdrawalWriter: writer,
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -68,12 +74,8 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("POINT withdrawal status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastBalance == nil ||
|
||||
wallet.lastBalance.GetAppCode() != "huwaa" ||
|
||||
wallet.lastBalance.GetUserId() != 42001 ||
|
||||
len(wallet.lastBalance.GetAssetTypes()) != 1 ||
|
||||
wallet.lastBalance.GetAssetTypes()[0] != "POINT" {
|
||||
t.Fatalf("point balance request mismatch: %+v", wallet.lastBalance)
|
||||
if wallet.lastBalance != nil {
|
||||
t.Fatalf("withdraw handler must leave atomic balance validation to wallet freeze, got preflight request %+v", wallet.lastBalance)
|
||||
}
|
||||
if wallet.lastFreeze == nil ||
|
||||
wallet.lastFreeze.GetCommandId() != "client-point-withdraw-1" ||
|
||||
@ -82,6 +84,7 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
wallet.lastFreeze.GetGrossPointAmount() != 1_000_000 ||
|
||||
wallet.lastFreeze.GetFeePointAmount() != 50_000 ||
|
||||
wallet.lastFreeze.GetNetPointAmount() != 950_000 ||
|
||||
wallet.lastFreeze.GetRegionId() != 25 ||
|
||||
wallet.lastFreeze.GetWithdrawalRef() == "" {
|
||||
t.Fatalf("FreezePointWithdrawal payload mismatch: %+v", wallet.lastFreeze)
|
||||
}
|
||||
@ -117,13 +120,111 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
response.Data["net_usd"] != "9.50" {
|
||||
t.Fatalf("POINT withdrawal response mismatch: %+v", response)
|
||||
}
|
||||
|
||||
firstRequestID := recorder.Header().Get("X-Request-ID")
|
||||
firstWithdrawalRef := wallet.lastFreeze.GetWithdrawalRef()
|
||||
if firstRequestID == "" || firstWithdrawalRef == "" || len(firstWithdrawalRef) > 128 || firstWithdrawalRef == firstRequestID {
|
||||
t.Fatalf("withdrawal ref must be a bounded business id, request_id=%q withdrawal_ref=%q", firstRequestID, firstWithdrawalRef)
|
||||
}
|
||||
retryRecorder := httptest.NewRecorder()
|
||||
retryRequest := pointWalletRequest(`{"command_id":"client-point-withdraw-1","gross_point_amount":1000000}`, "huwaa")
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(retryRecorder, retryRequest)
|
||||
if retryRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("same-command retry status mismatch: got %d body=%s", retryRecorder.Code, retryRecorder.Body.String())
|
||||
}
|
||||
secondRequestID := retryRecorder.Header().Get("X-Request-ID")
|
||||
if secondRequestID == "" || secondRequestID == firstRequestID {
|
||||
t.Fatalf("test did not establish distinct tracing IDs: first=%q second=%q", firstRequestID, secondRequestID)
|
||||
}
|
||||
if wallet.lastFreeze.GetCommandId() != "client-point-withdraw-1" ||
|
||||
wallet.lastFreeze.GetWithdrawalRef() != firstWithdrawalRef ||
|
||||
wallet.lastFreeze.GetFeePointAmount() != 100_000 ||
|
||||
wallet.lastFreeze.GetPointsPerUsd() != 200_000 ||
|
||||
writer.calls != 2 {
|
||||
t.Fatalf("same command must retain freeze identity across request IDs: freeze=%+v first_ref=%q writer_calls=%d", wallet.lastFreeze, firstWithdrawalRef, writer.calls)
|
||||
}
|
||||
if writer.last.WithdrawAmountMinor != 1_000_000 ||
|
||||
writer.last.PointFeeAmount != 50_000 ||
|
||||
writer.last.PointNetAmount != 950_000 ||
|
||||
writer.last.PointsPerUSD != 100_000 ||
|
||||
writer.last.PointFeeBPS != 500 ||
|
||||
writer.last.WithdrawAmount != "9.50" {
|
||||
t.Fatalf("same-command retry must create application from first wallet receipt, got %+v", writer.last)
|
||||
}
|
||||
var retryResponse struct {
|
||||
Code string `json:"code"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(retryRecorder.Body).Decode(&retryResponse); err != nil {
|
||||
t.Fatalf("decode retry response failed: %v", err)
|
||||
}
|
||||
if retryResponse.Code != httpkit.CodeOK || retryResponse.Data["fee_point_amount"] != float64(50_000) || retryResponse.Data["points_per_usd"] != float64(100_000) || retryResponse.Data["net_usd"] != "9.50" {
|
||||
t.Fatalf("retry response must expose first wallet receipt: %+v", retryResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawPointWalletApplicationFailureRollsBackSameLimitReservation(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{
|
||||
releaseFailures: 2,
|
||||
freezeReceipts: map[string]*walletv1.FreezePointWithdrawalResponse{
|
||||
"client-point-withdraw-rollback": {
|
||||
TransactionId: "point-freeze-existing", GrossPointAmount: 1_000_000, FeePointAmount: 75_000,
|
||||
NetPointAmount: 925_000, PointsPerUsd: 125_000, FeeBps: 750,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := New(Config{
|
||||
WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
|
||||
WithdrawalWriter: &fakePointWithdrawalWriter{err: errors.New("admin insert failed")},
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := pointWalletRequest(`{"command_id":"client-point-withdraw-rollback","gross_point_amount":1000000}`, "huwaa")
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("application failure status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastFreeze == nil || wallet.lastRelease == nil ||
|
||||
wallet.lastRelease.GetCommandId() != wallet.lastFreeze.GetWithdrawalRef()+":rollback" ||
|
||||
wallet.lastRelease.GetWithdrawalRef() == "" ||
|
||||
wallet.lastRelease.GetWithdrawalRef() != wallet.lastFreeze.GetWithdrawalRef() ||
|
||||
len(wallet.lastRelease.GetWithdrawalRef()) > 128 ||
|
||||
len(wallet.releaseRequests) != pointWithdrawalRollbackAttempts {
|
||||
t.Fatalf("rollback must target the freeze reservation: freeze=%+v release=%+v", wallet.lastFreeze, wallet.lastRelease)
|
||||
}
|
||||
if wallet.lastRelease.GetGrossPointAmount() != 1_000_000 || wallet.lastRelease.GetFeePointAmount() != 75_000 || wallet.lastRelease.GetNetPointAmount() != 925_000 || wallet.lastRelease.GetPointsPerUsd() != 125_000 || wallet.lastRelease.GetFeeBps() != 750 {
|
||||
t.Fatalf("rollback must release the first wallet receipt snapshot: %+v", wallet.lastRelease)
|
||||
}
|
||||
for index, release := range wallet.releaseRequests {
|
||||
if release.GetCommandId() != wallet.lastRelease.GetCommandId() || release.GetWithdrawalRef() != wallet.lastFreeze.GetWithdrawalRef() {
|
||||
t.Fatalf("rollback retry %d changed idempotency identity: %+v", index+1, release)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawPointWalletUnknownApplicationOutcomeKeepsFreeze(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
handler := New(Config{
|
||||
WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
|
||||
WithdrawalWriter: &fakePointWithdrawalWriter{err: financewithdrawal.ErrApplicationOutcomeUnknown},
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := pointWalletRequest(`{"command_id":"client-point-withdraw-unknown","gross_point_amount":1000000}`, "huwaa")
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("unknown application outcome status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastFreeze == nil || wallet.lastRelease != nil || len(wallet.releaseRequests) != 0 {
|
||||
t.Fatalf("outcome-unknown application must retain freeze for same-command reconciliation: freeze=%+v releases=%+v", wallet.lastFreeze, wallet.releaseRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferPointWalletToCoinSellerUsesStringIDAndTrustedCountry(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
handler := New(Config{
|
||||
WalletClient: wallet,
|
||||
UserProfileClient: &fakePointUserProfileClient{country: "SA"},
|
||||
UserProfileClient: &fakePointUserProfileClient{country: "SA", regionID: 26},
|
||||
UserHostClient: &fakePointUserHostClient{},
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -134,7 +235,7 @@ func TestTransferPointWalletToCoinSellerUsesStringIDAndTrustedCountry(t *testing
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("point seller transfer status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastPointTransfer == nil || wallet.lastPointTransfer.GetSellerUserId() != 9007199254740001 || wallet.lastPointTransfer.GetSourceUserId() != 42001 || wallet.lastPointTransfer.GetSourceCountryCode() != "SA" || wallet.lastPointTransfer.GetAppCode() != "fami" {
|
||||
if wallet.lastPointTransfer == nil || wallet.lastPointTransfer.GetSellerUserId() != 9007199254740001 || wallet.lastPointTransfer.GetSourceUserId() != 42001 || wallet.lastPointTransfer.GetSourceCountryCode() != "SA" || wallet.lastPointTransfer.GetAppCode() != "fami" || wallet.lastPointTransfer.GetRegionId() != 26 {
|
||||
t.Fatalf("point seller transfer request mismatch: %+v", wallet.lastPointTransfer)
|
||||
}
|
||||
}
|
||||
@ -155,15 +256,57 @@ func TestExchangePointWalletToCoinsUsesAuthenticatedUserAndClientCommandID(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPointWalletExchangeConfigUsesTrustedRegionAndPublishedPolicy(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{regionID: 24}})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/point-wallet/exchange-config", nil)
|
||||
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
|
||||
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.getPointWalletExchangeConfig)).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("point exchange config status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastPointConfig == nil || wallet.lastPointConfig.GetAppCode() != "fami" || wallet.lastPointConfig.GetRegionId() != 24 || wallet.lastPointConfig.GetNowMs() <= 0 {
|
||||
t.Fatalf("point exchange config request mismatch: %+v", wallet.lastPointConfig)
|
||||
}
|
||||
var response struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
SourceAssetType string `json:"source_asset_type"`
|
||||
TargetAssetType string `json:"target_asset_type"`
|
||||
PointsPerUSD int64 `json:"points_per_usd"`
|
||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||
RatioPointAmount int64 `json:"ratio_point_amount"`
|
||||
RatioCoinAmount int64 `json:"ratio_coin_amount"`
|
||||
RoundingMode string `json:"rounding_mode"`
|
||||
PolicyInstanceCode string `json:"policy_instance_code"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode point exchange config failed: %v", err)
|
||||
}
|
||||
if response.Code != "OK" || response.Data.SourceAssetType != "POINT" || response.Data.TargetAssetType != "COIN" || response.Data.PointsPerUSD != 100_000 || response.Data.CoinsPerUSD != 80_000 || response.Data.RatioPointAmount != 100_000 || response.Data.RatioCoinAmount != 80_000 || response.Data.RoundingMode != "floor" || response.Data.PolicyInstanceCode != "test-policy" {
|
||||
t.Fatalf("point exchange config response mismatch: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePointWalletClient struct {
|
||||
client.WalletClient
|
||||
balances []*walletv1.AssetBalance
|
||||
lastBalance *walletv1.GetBalancesRequest
|
||||
lastFreeze *walletv1.FreezePointWithdrawalRequest
|
||||
lastRelease *walletv1.ReleasePointWithdrawalRequest
|
||||
releaseRequests []*walletv1.ReleasePointWithdrawalRequest
|
||||
lastPointTransfer *walletv1.TransferPointToCoinSellerRequest
|
||||
lastPointExchange *walletv1.ExchangePointToCoinRequest
|
||||
lastPointConfig *walletv1.GetPointWithdrawalConfigRequest
|
||||
freezeErr error
|
||||
releaseFailures int
|
||||
freezeReceipts map[string]*walletv1.FreezePointWithdrawalResponse
|
||||
pointConfigs []*walletv1.GetPointWithdrawalConfigResponse
|
||||
pointConfigCalls int
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||
@ -180,7 +323,10 @@ func (f *fakePointWalletClient) FreezePointWithdrawal(_ context.Context, req *wa
|
||||
if f.freezeErr != nil {
|
||||
return nil, f.freezeErr
|
||||
}
|
||||
return &walletv1.FreezePointWithdrawalResponse{
|
||||
if existing := f.freezeReceipts[req.GetCommandId()]; existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
receipt := &walletv1.FreezePointWithdrawalResponse{
|
||||
TransactionId: "point-freeze-tx",
|
||||
GrossPointAmount: req.GetGrossPointAmount(),
|
||||
FeePointAmount: req.GetFeePointAmount(),
|
||||
@ -188,15 +334,33 @@ func (f *fakePointWalletClient) FreezePointWithdrawal(_ context.Context, req *wa
|
||||
PointsPerUsd: req.GetPointsPerUsd(),
|
||||
FeeBps: req.GetFeeBps(),
|
||||
Balance: &walletv1.AssetBalance{AssetType: "POINT", AvailableAmount: 1_000_000, FrozenAmount: req.GetGrossPointAmount(), Version: 4},
|
||||
}, nil
|
||||
}
|
||||
if f.freezeReceipts == nil {
|
||||
f.freezeReceipts = make(map[string]*walletv1.FreezePointWithdrawalResponse)
|
||||
}
|
||||
f.freezeReceipts[req.GetCommandId()] = receipt
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) ReleasePointWithdrawal(_ context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) {
|
||||
f.lastRelease = req
|
||||
f.releaseRequests = append(f.releaseRequests, req)
|
||||
if len(f.releaseRequests) <= f.releaseFailures {
|
||||
return nil, errors.New("temporary point release failure")
|
||||
}
|
||||
return &walletv1.ReleasePointWithdrawalResponse{TransactionId: "point-release-tx"}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) GetPointWithdrawalConfig(_ context.Context, _ *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) {
|
||||
func (f *fakePointWalletClient) GetPointWithdrawalConfig(_ context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) {
|
||||
f.lastPointConfig = req
|
||||
if len(f.pointConfigs) > 0 {
|
||||
index := f.pointConfigCalls
|
||||
if index >= len(f.pointConfigs) {
|
||||
index = len(f.pointConfigs) - 1
|
||||
}
|
||||
f.pointConfigCalls++
|
||||
return f.pointConfigs[index], nil
|
||||
}
|
||||
return &walletv1.GetPointWithdrawalConfigResponse{Found: true, PointsPerUsd: 100_000, CoinsPerUsd: 80_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -298,6 +298,10 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
|
||||
settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual',
|
||||
gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例快照',
|
||||
residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例',
|
||||
coin_seller_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找币商提现限制周期:day/week/month',
|
||||
coin_seller_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找币商提现次数,0 表示不限',
|
||||
platform_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找平台提现限制周期:day/week/month',
|
||||
platform_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找平台提现次数,0 表示不限',
|
||||
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms',
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
@ -306,6 +310,38 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
|
||||
KEY idx_host_agency_salary_policy_cycle (app_code, region_id, cycle_key, status, settlement_mode, settlement_trigger_mode)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策运行快照表';
|
||||
|
||||
-- 计数按完整主键点查/行锁,不读取钱包交易或提现申请流水,限制检查的成本与历史交易量无关。
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
user_id BIGINT NOT NULL COMMENT '提现用户 ID',
|
||||
channel VARCHAR(24) NOT NULL COMMENT '提现渠道:coin_seller/platform',
|
||||
period VARCHAR(16) NOT NULL COMMENT '限制周期类型:day/week/month',
|
||||
period_key VARCHAR(10) NOT NULL COMMENT 'UTC 周期键:yyyy-MM-dd/yyyy-MM',
|
||||
withdrawal_count BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '周期内已计次数',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, channel, period, period_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自然周期提现次数计数表';
|
||||
|
||||
-- 平台提现只有申请创建失败回滚才释放 reservation;审核驳回仍保留已发起次数。
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
withdrawal_ref VARCHAR(128) NOT NULL COMMENT 'gateway 生成的申请前置引用',
|
||||
freeze_command_id VARCHAR(128) NOT NULL COMMENT '首次冻结命令幂等 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '提现用户 ID',
|
||||
channel VARCHAR(24) NOT NULL COMMENT '提现渠道,当前为 platform',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '命中的工资政策 ID',
|
||||
policy_version BIGINT UNSIGNED NOT NULL COMMENT '命中的不可变政策版本',
|
||||
period VARCHAR(16) NOT NULL COMMENT '命中的限制周期类型:day/week/month',
|
||||
period_key VARCHAR(10) NOT NULL COMMENT '命中的 UTC 限制周期键',
|
||||
counted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否实际占用了非零次数限制',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/released',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '创建失败回滚时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, withdrawal_ref),
|
||||
UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, freeze_command_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表';
|
||||
|
||||
-- 周期绑定一旦插入不可更新;复合主键保证新月份政策无法覆盖历史月份结算口径。
|
||||
CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
@ -313,9 +349,9 @@ CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
|
||||
cycle_key VARCHAR(7) NOT NULL COMMENT '工资周期键,UTC yyyy-MM',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '绑定的不可变政策 ID',
|
||||
policy_version BIGINT UNSIGNED NOT NULL COMMENT '绑定的不可变政策版本',
|
||||
source_cycle_key VARCHAR(7) NOT NULL DEFAULT '' COMMENT '来源月份;等于 cycle_key 表示显式发布,否则表示继承',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '首次绑定时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, region_id, cycle_key),
|
||||
UNIQUE KEY uk_host_salary_policy_cycle_version (app_code, policy_id, policy_version)
|
||||
PRIMARY KEY (app_code, region_id, cycle_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期不可变政策绑定表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels (
|
||||
@ -479,9 +515,9 @@ WHERE cycle_key = '' OR policy_version = 0;
|
||||
|
||||
-- 旧库同一区域同月若有多条快照,选择生效时间和 policy_id 最大的一条作为一次性迁移绑定;之后主键禁止覆盖。
|
||||
INSERT IGNORE INTO host_salary_policy_cycle_bindings (
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
|
||||
)
|
||||
SELECT policy.app_code, policy.region_id, policy.cycle_key, policy.policy_id, policy.policy_version, policy.created_at_ms
|
||||
SELECT policy.app_code, policy.region_id, policy.cycle_key, policy.policy_id, policy.policy_version, policy.cycle_key, policy.created_at_ms
|
||||
FROM host_agency_salary_policies policy
|
||||
WHERE policy.cycle_key <> ''
|
||||
AND policy.status = 'active'
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET time_zone = '+00:00';
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- 一份政策可绑定多个区域和多个继承周期;删除旧唯一键只修改小型绑定表元数据,不读取钱包流水或周期账户。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_salary_policy_cycle_bindings' AND INDEX_NAME = 'uk_host_salary_policy_cycle_version') > 0,
|
||||
'ALTER TABLE host_salary_policy_cycle_bindings DROP INDEX uk_host_salary_policy_cycle_version, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,20 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET time_zone = '+00:00';
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- 绑定表是按区域和月份唯一的小型配置表;来源字段允许显式新月版本替换系统继承绑定,同时绝不覆盖显式历史版本。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_salary_policy_cycle_bindings' AND COLUMN_NAME = 'source_cycle_key') = 0,
|
||||
'ALTER TABLE host_salary_policy_cycle_bindings ADD COLUMN source_cycle_key VARCHAR(7) NOT NULL DEFAULT '''' COMMENT ''来源月份;等于 cycle_key 表示显式发布,否则表示继承'' AFTER policy_version, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 迁移前的绑定均按显式版本处理;运行时继承代码会为之后的新绑定写入真实上月来源。
|
||||
UPDATE host_salary_policy_cycle_bindings
|
||||
SET source_cycle_key = cycle_key
|
||||
WHERE source_cycle_key = '';
|
||||
@ -0,0 +1,81 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET time_zone = '+00:00';
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- 运行政策是小型不可变快照;在线补列不扫描 wallet_transactions 或提现申请流水。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'coin_seller_withdrawal_limit_period') = 0,
|
||||
'ALTER TABLE host_agency_salary_policies ADD COLUMN coin_seller_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT ''month'' COMMENT ''用户找币商提现限制周期:day/week/month'' AFTER residual_diamond_to_usd_rate, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'coin_seller_withdrawal_limit_count') = 0,
|
||||
'ALTER TABLE host_agency_salary_policies ADD COLUMN coin_seller_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT ''用户找币商提现次数,0 表示不限'' AFTER coin_seller_withdrawal_limit_period, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'platform_withdrawal_limit_period') = 0,
|
||||
'ALTER TABLE host_agency_salary_policies ADD COLUMN platform_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT ''month'' COMMENT ''用户找平台提现限制周期:day/week/month'' AFTER coin_seller_withdrawal_limit_count, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies'
|
||||
AND COLUMN_NAME = 'platform_withdrawal_limit_count') = 0,
|
||||
'ALTER TABLE host_agency_salary_policies ADD COLUMN platform_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT ''用户找平台提现次数,0 表示不限'' AFTER platform_withdrawal_limit_period, ALGORITHM=INPLACE, LOCK=NONE',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 运行时只按完整主键点查并锁定一个用户当前周期的计数行,不扫描钱包或提现流水。
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
user_id BIGINT NOT NULL COMMENT '提现用户 ID',
|
||||
channel VARCHAR(24) NOT NULL COMMENT '提现渠道:coin_seller/platform',
|
||||
period VARCHAR(16) NOT NULL COMMENT '限制周期类型:day/week/month',
|
||||
period_key VARCHAR(10) NOT NULL COMMENT 'UTC 周期键:yyyy-MM-dd/yyyy-MM',
|
||||
withdrawal_count BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '周期内已计次数',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, channel, period, period_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自然周期提现次数计数表';
|
||||
|
||||
-- 平台提现先冻结后创建申请;reservation 让“创建失败释放”可幂等撤销次数,人工驳回不修改本表状态。
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
withdrawal_ref VARCHAR(128) NOT NULL COMMENT 'gateway 生成的申请前置引用',
|
||||
freeze_command_id VARCHAR(128) NOT NULL COMMENT '首次冻结命令幂等 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '提现用户 ID',
|
||||
channel VARCHAR(24) NOT NULL COMMENT '提现渠道,当前为 platform',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '命中的工资政策 ID',
|
||||
policy_version BIGINT UNSIGNED NOT NULL COMMENT '命中的不可变政策版本',
|
||||
period VARCHAR(16) NOT NULL COMMENT '命中的限制周期类型:day/week/month',
|
||||
period_key VARCHAR(10) NOT NULL COMMENT '命中的 UTC 限制周期键',
|
||||
counted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否实际占用了非零次数限制',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/released',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '创建失败回滚时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, withdrawal_ref),
|
||||
UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, freeze_command_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表';
|
||||
@ -30,18 +30,22 @@ type HostSalarySettlementBatchResult struct {
|
||||
type HostSalaryPolicy struct {
|
||||
PolicyID uint64
|
||||
// CycleKey 与 PolicyVersion 共同标识已发布的不可变周期快照;结算只能按账户 cycle_key 精确命中。
|
||||
CycleKey string
|
||||
PolicyVersion uint64
|
||||
Name string
|
||||
RegionID int64
|
||||
Status string
|
||||
SettlementMode string
|
||||
SettlementTriggerMode string
|
||||
GiftCoinToDiamondRatio string
|
||||
ResidualDiamondToUSDRate string
|
||||
EffectiveFromMs int64
|
||||
EffectiveToMs int64
|
||||
Levels []HostSalaryPolicyLevel
|
||||
CycleKey string
|
||||
PolicyVersion uint64
|
||||
Name string
|
||||
RegionID int64
|
||||
Status string
|
||||
SettlementMode string
|
||||
SettlementTriggerMode string
|
||||
GiftCoinToDiamondRatio string
|
||||
ResidualDiamondToUSDRate string
|
||||
CoinSellerWithdrawalLimitPeriod string
|
||||
CoinSellerWithdrawalLimitCount int64
|
||||
PlatformWithdrawalLimitPeriod string
|
||||
PlatformWithdrawalLimitCount int64
|
||||
EffectiveFromMs int64
|
||||
EffectiveToMs int64
|
||||
Levels []HostSalaryPolicyLevel
|
||||
}
|
||||
|
||||
// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。
|
||||
|
||||
@ -21,6 +21,7 @@ type PointToCoinSellerCommand struct {
|
||||
SellerUserID int64
|
||||
PointAmount int64
|
||||
SourceCountryCode string
|
||||
RegionID int64
|
||||
Reason string
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,8 @@ type SalaryWithdrawalCommand struct {
|
||||
Reason string
|
||||
WithdrawalRef string
|
||||
WithdrawalApplicationID string
|
||||
// RegionID 只在 POINT 首次冻结时由 gateway 注入,用于绑定当前 UTC 月工资政策。
|
||||
RegionID int64
|
||||
}
|
||||
|
||||
// SalaryWithdrawalReceipt 返回单边工资钱包变更后的余额,供 gateway/admin 回写申请单和前端展示。
|
||||
@ -51,6 +53,7 @@ type PointWithdrawalCommand struct {
|
||||
Reason string
|
||||
WithdrawalRef string
|
||||
WithdrawalApplicationID string
|
||||
RegionID int64
|
||||
}
|
||||
|
||||
// PointWithdrawalReceipt 返回 POINT 钱包冻结/扣冻/释放后的余额和费用快照。
|
||||
|
||||
@ -24,7 +24,7 @@ func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode st
|
||||
|
||||
// TransferPointToCoinSeller 验证客户端允许提交的最小字段;兑换比例由 repository 事务内锁定配置解析。
|
||||
func (s *Service) TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) {
|
||||
if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.PointAmount <= 0 {
|
||||
if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.PointAmount <= 0 || command.RegionID <= 0 {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point coin seller transfer command is incomplete")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
|
||||
@ -16,7 +16,7 @@ const (
|
||||
|
||||
// FreezePointWithdrawal 把已启用 App 的收益积分从 available 冻结到 frozen;gateway 必须先读取已发布政策快照。
|
||||
func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) {
|
||||
if err := normalizePointWithdrawalCommand(&command, false); err != nil {
|
||||
if err := normalizePointWithdrawalCommand(&command, false, true); err != nil {
|
||||
return ledger.PointWithdrawalReceipt{}, err
|
||||
}
|
||||
if command.Reason == "" {
|
||||
@ -36,7 +36,7 @@ func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.Poin
|
||||
|
||||
// SettlePointWithdrawal 在财务审核通过时扣掉 frozen 积分;冻结时已扣 available,因此这里不能再次扣 available。
|
||||
func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) {
|
||||
if err := normalizePointWithdrawalCommand(&command, true); err != nil {
|
||||
if err := normalizePointWithdrawalCommand(&command, true, false); err != nil {
|
||||
return ledger.PointWithdrawalReceipt{}, err
|
||||
}
|
||||
if command.Reason == "" {
|
||||
@ -56,7 +56,7 @@ func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.Poin
|
||||
|
||||
// ReleasePointWithdrawal 在审核拒绝或申请落库失败时把 frozen 积分返还 available,保证用户不会被永久冻结。
|
||||
func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) {
|
||||
if err := normalizePointWithdrawalCommand(&command, false); err != nil {
|
||||
if err := normalizePointWithdrawalCommand(&command, false, false); err != nil {
|
||||
return ledger.PointWithdrawalReceipt{}, err
|
||||
}
|
||||
if command.Reason == "" {
|
||||
@ -74,13 +74,16 @@ func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.Poi
|
||||
return pointWithdrawalReceiptFromSalary(receipt), nil
|
||||
}
|
||||
|
||||
func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool) error {
|
||||
func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool, requireRegion bool) error {
|
||||
if command == nil || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.GrossPointAmount <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "point withdrawal command is incomplete")
|
||||
}
|
||||
if requireOperator && command.OperatorUserID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "operator_user_id is required")
|
||||
}
|
||||
if requireRegion && command.RegionID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "region_id is required")
|
||||
}
|
||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||
if strings.TrimSpace(command.AppCode) == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "app_code is required")
|
||||
@ -141,6 +144,7 @@ func pointWithdrawalAsSalaryCommand(command ledger.PointWithdrawalCommand) ledge
|
||||
Reason: command.Reason,
|
||||
WithdrawalRef: command.WithdrawalRef,
|
||||
WithdrawalApplicationID: command.WithdrawalApplicationID,
|
||||
RegionID: command.RegionID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2334,6 +2334,17 @@ func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode string, policyID uint64, regionID int64, coinSellerPeriod string, coinSellerCount int64, platformPeriod string, platformCount int64) {
|
||||
repository.SetHostSalaryPolicyForApp(appCode, ledger.HostSalaryPolicy{
|
||||
PolicyID: policyID, PolicyVersion: policyID, CycleKey: time.Now().UTC().Format("2006-01"),
|
||||
Name: "point withdrawal limit policy", RegionID: regionID, Status: "active",
|
||||
SettlementMode: ledger.HostSalarySettlementModeDaily, SettlementTriggerMode: ledger.HostSalarySettlementTriggerAutomatic,
|
||||
GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0",
|
||||
CoinSellerWithdrawalLimitPeriod: coinSellerPeriod, CoinSellerWithdrawalLimitCount: coinSellerCount,
|
||||
PlatformWithdrawalLimitPeriod: platformPeriod, PlatformWithdrawalLimitCount: platformCount,
|
||||
})
|
||||
}
|
||||
|
||||
func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) {
|
||||
repository := &fakePointGateRepository{}
|
||||
svc := walletservice.New(repository)
|
||||
@ -2359,6 +2370,7 @@ func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) {
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
RegionID: 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Huwaa POINT withdrawal should reach repository: %v", err)
|
||||
@ -2381,6 +2393,7 @@ func TestFamiPointWithdrawalAllowsAdminConfiguredZeroFee(t *testing.T) {
|
||||
receipt, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-zero-fee", UserID: 23013,
|
||||
AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, PointsPerUSD: 200_000, FeeBPS: 0,
|
||||
RegionID: 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("zero-fee Fami point withdrawal failed: %v", err)
|
||||
@ -2394,6 +2407,7 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("huwaa", 22001, ledger.AssetPoint, 2_000_000)
|
||||
repository.SetAssetBalanceForApp("huwaa", 22002, ledger.AssetPoint, 2_000_000)
|
||||
seedPointWithdrawalSalaryPolicy(repository, "huwaa", 220, 25, "month", 0, "month", 0)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
_, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
|
||||
@ -2415,6 +2429,8 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
RegionID: 25,
|
||||
WithdrawalRef: "point-freeze-22001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FreezePointWithdrawal failed: %v", err)
|
||||
@ -2449,6 +2465,8 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
RegionID: 25,
|
||||
WithdrawalRef: "point-freeze-22002",
|
||||
}); err != nil {
|
||||
t.Fatalf("FreezePointWithdrawal for release failed: %v", err)
|
||||
}
|
||||
@ -2470,6 +2488,84 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
requireWalletBalanceForApp(t, svc, "huwaa", 22002, ledger.AssetPoint, 2_000_000, 0)
|
||||
}
|
||||
|
||||
func TestPointWithdrawalPolicyLimitRollbackOnlyReleasesFailedApplicationReservation(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("huwaa", 22006, ledger.AssetPoint, 3_000_000)
|
||||
seedPointWithdrawalSalaryPolicy(repository, "huwaa", 222, 25, "month", 0, "day", 1)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
first := ledger.PointWithdrawalCommand{
|
||||
AppCode: "huwaa", CommandID: "point-limit-freeze-1", UserID: 22006, AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000, FeeBPS: 500, RegionID: 25, WithdrawalRef: "point-limit-ref-1",
|
||||
}
|
||||
firstReceipt, err := svc.FreezePointWithdrawal(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("first limited platform withdrawal failed: %v", err)
|
||||
}
|
||||
// request_id、用户当前 region 和服务端政策快照都可能在 HTTP 重试前变化;客户端稳定 command 必须返回首次冻结回执。
|
||||
replayedFirst := first
|
||||
replayedFirst.RegionID = 26
|
||||
replayedFirst.PointsPerUSD = 777_777
|
||||
replayedFirst.FeeBPS = 0
|
||||
replayedFirst.FeePointAmount = 0
|
||||
replayedFirst.NetPointAmount = 0
|
||||
replayedReceipt, err := svc.FreezePointWithdrawal(context.Background(), replayedFirst)
|
||||
if err != nil || replayedReceipt.TransactionID != firstReceipt.TransactionID || replayedReceipt.FeeBPS != firstReceipt.FeeBPS || replayedReceipt.PointsPerUSD != firstReceipt.PointsPerUSD {
|
||||
t.Fatalf("derived policy changes must replay the first freeze receipt: first=%+v replay=%+v err=%v", firstReceipt, replayedReceipt, err)
|
||||
}
|
||||
// 用户 region 变化后会命中另一份政策,但自然日计数属于用户/渠道周期,不能因为政策版本不同获得新额度。
|
||||
seedPointWithdrawalSalaryPolicy(repository, "huwaa", 223, 26, "month", 0, "day", 1)
|
||||
changedRegion := first
|
||||
changedRegion.CommandID = "point-limit-region-change"
|
||||
changedRegion.WithdrawalRef = "point-limit-region-change-ref"
|
||||
changedRegion.RegionID = 26
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), changedRegion); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
|
||||
t.Fatalf("region change must preserve the shared natural-period count, got %v", err)
|
||||
}
|
||||
// 同月从继承版本切到显式发布版本也只能改变后续限额值,不能用新的 policy_version 重置已用次数。
|
||||
seedPointWithdrawalSalaryPolicy(repository, "huwaa", 224, 25, "month", 0, "day", 1)
|
||||
replacedPolicy := first
|
||||
replacedPolicy.CommandID = "point-limit-policy-replaced"
|
||||
replacedPolicy.WithdrawalRef = "point-limit-policy-replaced-ref"
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), replacedPolicy); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
|
||||
t.Fatalf("same-cycle policy replacement must preserve the shared natural-period count, got %v", err)
|
||||
}
|
||||
second := first
|
||||
second.CommandID = "point-limit-freeze-2"
|
||||
second.WithdrawalRef = "point-limit-ref-2"
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), second); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
|
||||
t.Fatalf("second platform withdrawal must hit day limit, got %v", err)
|
||||
} else if metadata := xerr.MetadataOf(err); metadata["channel"] != "platform" || metadata["period"] != "day" || metadata["limit"] != "1" {
|
||||
t.Fatalf("limit metadata mismatch: %+v", metadata)
|
||||
}
|
||||
|
||||
// gateway 创建申请失败时传回原 withdrawal_ref;计数和冻结余额在同一补偿事务中一起恢复。
|
||||
if _, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
|
||||
AppCode: "huwaa", CommandID: "point-limit-freeze-1:rollback", UserID: 22006, AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000, FeeBPS: 500, WithdrawalRef: "retry-request-ref-changed",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed-application rollback must release reservation: %v", err)
|
||||
}
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), first); !xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||||
t.Fatalf("rolled-back freeze command must not replay a stale success receipt, got %v", err)
|
||||
}
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), second); err != nil {
|
||||
t.Fatalf("withdrawal after failed-application rollback should reuse the slot: %v", err)
|
||||
}
|
||||
if _, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
|
||||
AppCode: "huwaa", CommandID: "point-limit-manual-reject", UserID: 22006, AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000, FeeBPS: 500, WithdrawalApplicationID: "point-application-2",
|
||||
}); err != nil {
|
||||
t.Fatalf("manual rejection release failed: %v", err)
|
||||
}
|
||||
third := second
|
||||
third.CommandID = "point-limit-freeze-3"
|
||||
third.WithdrawalRef = "point-limit-ref-3"
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), third); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
|
||||
t.Fatalf("manual rejection must still count as an application, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalaryWithdrawalTerminalReusesLegacyCommandAcrossReviewerRetryAndRejectsOppositeDecision(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("lalu", 22003, ledger.AssetHostSalaryUSD, 10_000)
|
||||
@ -2524,6 +2620,7 @@ func TestSalaryWithdrawalTerminalReusesLegacyCommandAcrossReviewerRetryAndReject
|
||||
func TestPointWithdrawalTerminalReusesLegacyCommandWithNormalizedPolicySnapshot(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("huwaa", 22005, ledger.AssetPoint, 2_000_000)
|
||||
seedPointWithdrawalSalaryPolicy(repository, "huwaa", 221, 25, "month", 0, "month", 0)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
|
||||
@ -2531,6 +2628,7 @@ func TestPointWithdrawalTerminalReusesLegacyCommandWithNormalizedPolicySnapshot(
|
||||
AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000,
|
||||
FeePointAmount: 50_000, NetPointAmount: 950_000, PointsPerUSD: 100_000, FeeBPS: 500,
|
||||
WithdrawalRef: "point-legacy-freeze",
|
||||
RegionID: 25,
|
||||
}); err != nil {
|
||||
t.Fatalf("freeze point withdrawal failed: %v", err)
|
||||
}
|
||||
@ -2625,6 +2723,7 @@ func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("fami", 22501, ledger.AssetPoint, 2_000_000)
|
||||
repository.SetPointWithdrawalCoinSellerConfig("fami", 22502, 100_000, 92_000, []string{"SA"}, "active", 1)
|
||||
seedPointWithdrawalSalaryPolicy(repository, "fami", 225, 26, "month", 1, "month", 0)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "SA", false)
|
||||
@ -2638,6 +2737,7 @@ func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) {
|
||||
command := ledger.PointToCoinSellerCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-point-seller-1", SourceUserID: 22501,
|
||||
SellerUserID: 22502, PointAmount: 1_000_000, SourceCountryCode: "SA", Reason: "withdraw to seller",
|
||||
RegionID: 26,
|
||||
}
|
||||
receipt, err := svc.TransferPointToCoinSeller(context.Background(), command)
|
||||
if err != nil {
|
||||
@ -2657,11 +2757,12 @@ func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = svc.TransferPointToCoinSeller(context.Background(), ledger.PointToCoinSellerCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-point-seller-country-denied", SourceUserID: 22501,
|
||||
SellerUserID: 22502, PointAmount: 100_000, SourceCountryCode: "TR",
|
||||
AppCode: "fami", CommandID: "cmd-fami-point-seller-limit", SourceUserID: 22501,
|
||||
SellerUserID: 22502, PointAmount: 100_000, SourceCountryCode: "SA",
|
||||
RegionID: 26,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("seller country restriction must reject transfer")
|
||||
if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
|
||||
t.Fatalf("second unique coin-seller withdrawal must hit monthly limit, got %v", err)
|
||||
}
|
||||
requireWalletBalanceForApp(t, svc, "fami", 22501, ledger.AssetPoint, 1_000_000, 0)
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
@ -75,6 +76,7 @@ type hostSalarySettlementMetadata struct {
|
||||
type hostSalaryPolicyQuerier interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// ProcessHostSalarySettlementBatch 按周期账户扫描并结算主播美元、主播金币奖励和代理美元工资。
|
||||
@ -139,6 +141,10 @@ func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledge
|
||||
policyModeClause := ""
|
||||
userFilterClause := ""
|
||||
args := []any{appcode.FromContext(ctx), command.CycleKey}
|
||||
previousCycleKey, err := previousHostSalaryCycleKey(command.CycleKey)
|
||||
if err != nil {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "cycle_key is invalid")
|
||||
}
|
||||
var policyModeArgs []any
|
||||
if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd {
|
||||
// 日结和半月结必须精确匹配政策配置的结算模式;月底清算复用有效政策处理剩余钻石和周期关闭。
|
||||
@ -153,7 +159,8 @@ func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledge
|
||||
args = append(args, id)
|
||||
}
|
||||
}
|
||||
args = append(args, command.TriggerMode)
|
||||
// 候选扫描也接受紧邻上月的绑定;具体结算事务会把它复制到当前周期,避免扫描阶段漏掉应继承的主播。
|
||||
args = append(args, previousCycleKey, command.TriggerMode)
|
||||
args = append(args, policyModeArgs...)
|
||||
// 日结/半月结只扫描“本周期钻石比上次结算更多”的主播,避免 cron 重跑时反复锁定无新增收益的账户。
|
||||
// 月底批次必须扫描全部未关闭周期账户,因为它还负责剩余钻石折美元和周期逻辑关闭。
|
||||
@ -179,12 +186,10 @@ func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledge
|
||||
JOIN host_agency_salary_policies p
|
||||
ON p.app_code = binding.app_code
|
||||
AND p.policy_id = binding.policy_id
|
||||
AND p.region_id = binding.region_id
|
||||
AND p.cycle_key = binding.cycle_key
|
||||
AND p.policy_version = binding.policy_version
|
||||
WHERE binding.app_code = a.app_code
|
||||
AND binding.region_id = a.region_id
|
||||
AND binding.cycle_key = a.cycle_key
|
||||
AND binding.cycle_key IN (a.cycle_key, ?)
|
||||
AND p.status = 'active'
|
||||
AND p.settlement_trigger_mode = ?
|
||||
%s
|
||||
@ -444,6 +449,22 @@ func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, re
|
||||
}
|
||||
|
||||
func (r *Repository) queryHostSalaryPolicyByCycle(ctx context.Context, q hostSalaryPolicyQuerier, regionID int64, cycleKey string, settlementMode string, triggerMode string) (ledger.HostSalaryPolicy, bool, error) {
|
||||
policy, found, err := r.queryBoundHostSalaryPolicy(ctx, q, regionID, cycleKey, settlementMode, triggerMode)
|
||||
if err != nil || found {
|
||||
return policy, found, err
|
||||
}
|
||||
// 当前周期无绑定时,仅复制紧邻上月同条件的不可变版本;已有周期绑定从不覆盖,历史工资口径保持可追溯。
|
||||
previousCycleKey, err := previousHostSalaryCycleKey(cycleKey)
|
||||
if err != nil {
|
||||
return ledger.HostSalaryPolicy{}, false, nil
|
||||
}
|
||||
if err := r.inheritHostSalaryPolicyBinding(ctx, q, regionID, previousCycleKey, cycleKey, settlementMode, triggerMode); err != nil {
|
||||
return ledger.HostSalaryPolicy{}, false, err
|
||||
}
|
||||
return r.queryBoundHostSalaryPolicy(ctx, q, regionID, cycleKey, settlementMode, triggerMode)
|
||||
}
|
||||
|
||||
func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalaryPolicyQuerier, regionID int64, cycleKey string, settlementMode string, triggerMode string) (ledger.HostSalaryPolicy, bool, error) {
|
||||
modeClause := ""
|
||||
triggerClause := ""
|
||||
args := []any{appcode.FromContext(ctx), regionID, cycleKey}
|
||||
@ -458,17 +479,17 @@ func (r *Repository) queryHostSalaryPolicyByCycle(ctx context.Context, q hostSal
|
||||
args = append(args, settlementMode)
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT p.policy_id, p.cycle_key, p.policy_version, p.name, p.region_id, p.status,
|
||||
SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, binding.region_id, p.status,
|
||||
p.settlement_mode, p.settlement_trigger_mode,
|
||||
CAST(p.gift_coin_to_diamond_ratio AS CHAR),
|
||||
CAST(p.residual_diamond_to_usd_rate AS CHAR),
|
||||
p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count,
|
||||
p.platform_withdrawal_limit_period, p.platform_withdrawal_limit_count,
|
||||
p.effective_from_ms, p.effective_to_ms
|
||||
FROM host_salary_policy_cycle_bindings binding
|
||||
JOIN host_agency_salary_policies p
|
||||
ON p.app_code = binding.app_code
|
||||
AND p.policy_id = binding.policy_id
|
||||
AND p.region_id = binding.region_id
|
||||
AND p.cycle_key = binding.cycle_key
|
||||
AND p.policy_version = binding.policy_version
|
||||
WHERE binding.app_code = ?
|
||||
AND binding.region_id = ?
|
||||
@ -478,7 +499,13 @@ func (r *Repository) queryHostSalaryPolicyByCycle(ctx context.Context, q hostSal
|
||||
%s
|
||||
LIMIT 1`, triggerClause, modeClause)
|
||||
var policy ledger.HostSalaryPolicy
|
||||
err := q.QueryRowContext(ctx, query, args...).Scan(&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.RegionID, &policy.Status, &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.EffectiveFromMs, &policy.EffectiveToMs)
|
||||
err := q.QueryRowContext(ctx, query, args...).Scan(
|
||||
&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.RegionID, &policy.Status,
|
||||
&policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate,
|
||||
&policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount,
|
||||
&policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount,
|
||||
&policy.EffectiveFromMs, &policy.EffectiveToMs,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ledger.HostSalaryPolicy{}, false, nil
|
||||
@ -493,6 +520,57 @@ func (r *Repository) queryHostSalaryPolicyByCycle(ctx context.Context, q hostSal
|
||||
return policy, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) inheritHostSalaryPolicyBinding(ctx context.Context, q hostSalaryPolicyQuerier, regionID int64, previousCycleKey string, cycleKey string, settlementMode string, triggerMode string) error {
|
||||
modeClause := ""
|
||||
triggerClause := ""
|
||||
args := []any{appcode.FromContext(ctx), regionID, previousCycleKey}
|
||||
if strings.TrimSpace(triggerMode) != "" {
|
||||
triggerClause = "AND p.settlement_trigger_mode = ?"
|
||||
args = append(args, triggerMode)
|
||||
}
|
||||
if strings.TrimSpace(settlementMode) != "" {
|
||||
modeClause = "AND p.settlement_mode = ?"
|
||||
args = append(args, settlementMode)
|
||||
}
|
||||
var policyID, policyVersion uint64
|
||||
err := q.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT binding.policy_id, binding.policy_version
|
||||
FROM host_salary_policy_cycle_bindings binding
|
||||
JOIN host_agency_salary_policies p
|
||||
ON p.app_code = binding.app_code
|
||||
AND p.policy_id = binding.policy_id
|
||||
AND p.policy_version = binding.policy_version
|
||||
WHERE binding.app_code = ?
|
||||
AND binding.region_id = ?
|
||||
AND binding.cycle_key = ?
|
||||
AND p.status = 'active'
|
||||
%s
|
||||
%s
|
||||
LIMIT 1`, triggerClause, modeClause), args...).Scan(&policyID, &policyVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// INSERT IGNORE 只吸收并发继承;它绝不改写已经存在的当前周期绑定。
|
||||
_, err = q.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO host_salary_policy_cycle_bindings (
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
appcode.FromContext(ctx), regionID, cycleKey, policyID, policyVersion, previousCycleKey, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func previousHostSalaryCycleKey(cycleKey string) (string, error) {
|
||||
trimmed := strings.TrimSpace(cycleKey)
|
||||
parsed, err := time.Parse("2006-01", trimmed)
|
||||
if err != nil || parsed.UTC().Format("2006-01") != trimmed {
|
||||
return "", errors.New("cycle_key is invalid")
|
||||
}
|
||||
return parsed.AddDate(0, -1, 0).Format("2006-01"), nil
|
||||
}
|
||||
|
||||
func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, q hostSalaryPolicyQuerier, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) {
|
||||
// 等级必须按 required_diamonds 升序返回,highestHostSalaryLevel 依赖这个顺序找到最高已达档位。
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
|
||||
@ -298,6 +298,7 @@ type salaryWithdrawalMetadata struct {
|
||||
Reason string `json:"reason"`
|
||||
WithdrawalRef string `json:"withdrawal_ref,omitempty"`
|
||||
WithdrawalApplicationID string `json:"withdrawal_application_id,omitempty"`
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
AvailableAfter int64 `json:"available_after"`
|
||||
FrozenAfter int64 `json:"frozen_after"`
|
||||
Version int64 `json:"version"`
|
||||
|
||||
@ -21,6 +21,7 @@ type pointToCoinSellerMetadata struct {
|
||||
SourceUserID int64 `json:"source_user_id"`
|
||||
SellerUserID int64 `json:"seller_user_id"`
|
||||
SourceCountryCode string `json:"source_country_code"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
SourceAssetType string `json:"source_asset_type"`
|
||||
SellerAssetType string `json:"seller_asset_type"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
@ -91,9 +92,9 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%d|%s|%s",
|
||||
requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%d|%s|%d|%s",
|
||||
command.AppCode, command.SourceUserID, command.SellerUserID, command.PointAmount,
|
||||
strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)), strings.TrimSpace(command.Reason)))
|
||||
strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)), command.RegionID, strings.TrimSpace(command.Reason)))
|
||||
if txRow, exists, lookupErr := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypePointTransferToCoinSeller, xerr.IdempotencyConflict); lookupErr != nil || exists {
|
||||
if lookupErr != nil || !exists {
|
||||
return ledger.PointToCoinSellerReceipt{}, lookupErr
|
||||
@ -126,6 +127,9 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg
|
||||
if source.AvailableAmount < command.PointAmount {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient POINT balance")
|
||||
}
|
||||
if err := r.consumePointWithdrawalLimit(ctx, tx, command.SourceUserID, command.RegionID, pointWithdrawalChannelCoinSeller, command.CommandID, "", nowMS); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMS)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
@ -139,6 +143,7 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg
|
||||
metadata := pointToCoinSellerMetadata{
|
||||
AppCode: command.AppCode, SourceUserID: command.SourceUserID, SellerUserID: command.SellerUserID,
|
||||
SourceCountryCode: countryCode, SourceAssetType: ledger.AssetPoint, SellerAssetType: ledger.AssetCoinSellerCoin,
|
||||
RegionID: command.RegionID,
|
||||
PointAmount: command.PointAmount, SellerCoinAmount: sellerCoinAmount,
|
||||
RatioPointAmount: config.PointAmount, RatioSellerCoinAmount: config.SellerCoinAmount,
|
||||
SourcePointBalanceAfter: sourceAfter, SellerBalanceAfter: sellerAfter, Reason: command.Reason, CreatedAtMS: nowMS,
|
||||
|
||||
@ -0,0 +1,239 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
const (
|
||||
pointWithdrawalChannelCoinSeller = "coin_seller"
|
||||
pointWithdrawalChannelPlatform = "platform"
|
||||
|
||||
withdrawalLimitPeriodDay = "day"
|
||||
withdrawalLimitPeriodWeek = "week"
|
||||
withdrawalLimitPeriodMonth = "month"
|
||||
)
|
||||
|
||||
type pointWithdrawalPolicyLimit struct {
|
||||
PolicyID uint64
|
||||
PolicyVersion uint64
|
||||
Period string
|
||||
Limit int64
|
||||
PeriodKey string
|
||||
}
|
||||
|
||||
// consumePointWithdrawalLimit 在真实账变事务内解析当前 UTC 月绑定政策并锁定一条计数主键。
|
||||
// 调用方必须先完成 wallet transaction 幂等命中检查;这样重放直接返回旧回执,不会重复消耗次数。
|
||||
func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, channel string, commandID string, withdrawalRef string, nowMS int64) error {
|
||||
if userID <= 0 || regionID <= 0 || (channel != pointWithdrawalChannelCoinSeller && channel != pointWithdrawalChannelPlatform) {
|
||||
return xerr.New(xerr.InvalidArgument, "point withdrawal limit target is invalid")
|
||||
}
|
||||
limit, err := r.resolvePointWithdrawalPolicyLimit(ctx, tx, regionID, channel, nowMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if channel == pointWithdrawalChannelPlatform && strings.TrimSpace(withdrawalRef) == "" {
|
||||
// 平台先冻结再建申请;没有稳定引用就无法在建单失败时精确撤销这一次 reservation。
|
||||
return xerr.New(xerr.InvalidArgument, "withdrawal_ref is required for platform withdrawal")
|
||||
}
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
if limit.Limit > 0 {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO point_withdrawal_policy_counters (
|
||||
app_code, user_id, channel, period, period_key,
|
||||
withdrawal_count, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE period_key = VALUES(period_key)`,
|
||||
appCode, userID, channel, limit.Period, limit.PeriodKey, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
var current int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT withdrawal_count
|
||||
FROM point_withdrawal_policy_counters
|
||||
WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?
|
||||
FOR UPDATE`, appCode, userID, channel, limit.Period, limit.PeriodKey,
|
||||
).Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
if current >= limit.Limit {
|
||||
return xerr.NewWithMetadata(xerr.PointWithdrawalLimitReached, "point withdrawal limit reached", map[string]string{
|
||||
"channel": channel,
|
||||
"period": limit.Period,
|
||||
"limit": strconv.FormatInt(limit.Limit, 10),
|
||||
})
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE point_withdrawal_policy_counters
|
||||
SET withdrawal_count = withdrawal_count + 1, updated_at_ms = ?
|
||||
WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?`,
|
||||
nowMS, appCode, userID, channel, limit.Period, limit.PeriodKey,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if channel != pointWithdrawalChannelPlatform {
|
||||
return nil
|
||||
}
|
||||
// reservation 与冻结账变同事务提交;后续只允许“申请创建失败”按 withdrawal_ref 撤销,人工驳回仍计一次发起。
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO point_withdrawal_limit_reservations (
|
||||
app_code, withdrawal_ref, freeze_command_id, user_id, channel,
|
||||
policy_id, policy_version, period, period_key, counted, status, created_at_ms, released_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 0)`,
|
||||
appCode, strings.TrimSpace(withdrawalRef), strings.TrimSpace(commandID), userID, channel,
|
||||
limit.PolicyID, limit.PolicyVersion, limit.Period, limit.PeriodKey, limit.Limit > 0, nowMS,
|
||||
)
|
||||
if isMySQLDuplicateError(err) {
|
||||
return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation already belongs to another command")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64) (pointWithdrawalPolicyLimit, error) {
|
||||
if nowMS <= 0 {
|
||||
nowMS = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
now := time.UnixMilli(nowMS).UTC()
|
||||
cycleKey := now.Format("2006-01")
|
||||
// 复用月工资政策的不可变周期绑定解析;当前月缺失时沿用同一套“只复制紧邻上月”规则,限制与发薪不会选到不同版本。
|
||||
policy, found, err := r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, "", "")
|
||||
if err != nil {
|
||||
return pointWithdrawalPolicyLimit{}, err
|
||||
}
|
||||
if !found {
|
||||
return pointWithdrawalPolicyLimit{}, xerr.New(xerr.PermissionDenied, "host salary policy is not configured for withdrawal region")
|
||||
}
|
||||
limit := pointWithdrawalPolicyLimit{PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion}
|
||||
switch channel {
|
||||
case pointWithdrawalChannelCoinSeller:
|
||||
limit.Period, limit.Limit = policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount
|
||||
case pointWithdrawalChannelPlatform:
|
||||
limit.Period, limit.Limit = policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount
|
||||
default:
|
||||
return pointWithdrawalPolicyLimit{}, xerr.New(xerr.InvalidArgument, "point withdrawal channel is invalid")
|
||||
}
|
||||
limit.Period = strings.ToLower(strings.TrimSpace(limit.Period))
|
||||
if limit.Limit < 0 {
|
||||
return pointWithdrawalPolicyLimit{}, xerr.New(xerr.Internal, "point withdrawal policy limit is invalid")
|
||||
}
|
||||
if limit.Period == "" {
|
||||
limit.Period = withdrawalLimitPeriodMonth
|
||||
}
|
||||
switch limit.Period {
|
||||
case withdrawalLimitPeriodDay:
|
||||
limit.PeriodKey = now.Format("2006-01-02")
|
||||
case withdrawalLimitPeriodWeek:
|
||||
// 周期固定为 UTC 周一 00:00 起;保存周一日期避免跨年 ISO 周编号歧义。
|
||||
mondayOffset := (int(now.Weekday()) + 6) % 7
|
||||
limit.PeriodKey = now.AddDate(0, 0, -mondayOffset).Format("2006-01-02")
|
||||
case withdrawalLimitPeriodMonth:
|
||||
limit.PeriodKey = cycleKey
|
||||
default:
|
||||
return pointWithdrawalPolicyLimit{}, xerr.New(xerr.Internal, "point withdrawal policy period is invalid")
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
// releasePointWithdrawalLimitReservation 只服务 gateway 建单失败补偿。
|
||||
// application_id 非空的人工驳回不会调用这里,因此驳回申请仍占用原限制周期的一次次数。
|
||||
func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, tx *sql.Tx, userID int64, withdrawalRef string, rollbackCommandID string, nowMS int64) error {
|
||||
withdrawalRef = strings.TrimSpace(withdrawalRef)
|
||||
appCode := appcode.FromContext(ctx)
|
||||
var reservedUserID int64
|
||||
var channel string
|
||||
var period string
|
||||
var periodKey string
|
||||
var counted bool
|
||||
var status string
|
||||
queryByRef := withdrawalRef != ""
|
||||
var err error
|
||||
if queryByRef {
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status
|
||||
FROM point_withdrawal_limit_reservations
|
||||
WHERE app_code = ? AND withdrawal_ref = ?
|
||||
FOR UPDATE`, appCode, withdrawalRef,
|
||||
).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status)
|
||||
}
|
||||
if !queryByRef || errors.Is(err, sql.ErrNoRows) {
|
||||
// HTTP 重试会生成新的 request_id,但 freeze command_id 是客户端稳定幂等键;按 :rollback 约定回退到唯一命令索引。
|
||||
freezeCommandID := strings.TrimSuffix(strings.TrimSpace(rollbackCommandID), ":rollback")
|
||||
if freezeCommandID == strings.TrimSpace(rollbackCommandID) || freezeCommandID == "" {
|
||||
if errors.Is(err, sql.ErrNoRows) || !queryByRef {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status
|
||||
FROM point_withdrawal_limit_reservations
|
||||
WHERE app_code = ? AND freeze_command_id = ?
|
||||
FOR UPDATE`, appCode, freezeCommandID,
|
||||
).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status)
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 旧冻结记录没有 reservation,释放余额仍按原兼容行为继续。
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reservedUserID != userID || channel != pointWithdrawalChannelPlatform {
|
||||
return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation does not match rollback")
|
||||
}
|
||||
if status == "released" {
|
||||
return nil
|
||||
}
|
||||
if status != "active" {
|
||||
return xerr.New(xerr.Internal, "point withdrawal reservation status is invalid")
|
||||
}
|
||||
if counted {
|
||||
// policy_id/version 只保留为命中政策审计;自然周期计数跨区域和同月显式政策切换连续,释放也必须命中共享周期键。
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE point_withdrawal_policy_counters
|
||||
SET withdrawal_count = withdrawal_count - 1, updated_at_ms = ?
|
||||
WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?
|
||||
AND withdrawal_count > 0`, nowMS, appCode, userID, channel, period, periodKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return xerr.New(xerr.Internal, "point withdrawal reservation counter is missing")
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE point_withdrawal_limit_reservations
|
||||
SET status = 'released', released_at_ms = ?
|
||||
WHERE app_code = ? AND withdrawal_ref = ? AND status = 'active'`, nowMS, appCode, withdrawalRef)
|
||||
return err
|
||||
}
|
||||
|
||||
// pointWithdrawalReservationWasReleased 防止已补偿冻结的 command_id 重放旧成功回执。
|
||||
// reservation 与冻结交易同事务落库,因此 active/不存在仍可按原幂等语义返回;released 必须要求客户端换新命令。
|
||||
func (r *Repository) pointWithdrawalReservationWasReleased(ctx context.Context, tx *sql.Tx, commandID string) (bool, error) {
|
||||
var status string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT status
|
||||
FROM point_withdrawal_limit_reservations
|
||||
WHERE app_code = ? AND freeze_command_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(commandID)).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status == "released", nil
|
||||
}
|
||||
@ -77,6 +77,15 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S
|
||||
if err != nil || !exists {
|
||||
return ledger.SalaryWithdrawalReceipt{}, err
|
||||
}
|
||||
if mutation.BizType == bizTypeSalaryWithdrawalFreeze && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) {
|
||||
released, reservationErr := r.pointWithdrawalReservationWasReleased(ctx, tx, command.CommandID)
|
||||
if reservationErr != nil {
|
||||
return ledger.SalaryWithdrawalReceipt{}, reservationErr
|
||||
}
|
||||
if released {
|
||||
return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.IdempotencyConflict, "point withdrawal freeze was rolled back; use a new command_id")
|
||||
}
|
||||
}
|
||||
return r.receiptForSalaryWithdrawalTransaction(ctx, tx, txRow.TransactionID)
|
||||
}
|
||||
|
||||
@ -88,6 +97,18 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S
|
||||
if account.AvailableAmount+mutation.AvailableDelta < 0 || account.FrozenAmount+mutation.FrozenDelta < 0 {
|
||||
return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
// 只有 POINT 的首次冻结代表用户新发起一次“找平台提现”;审核扣款、驳回释放和其他工资资产都不能重复计数。
|
||||
if mutation.BizType == bizTypeSalaryWithdrawalFreeze && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) {
|
||||
if err := r.consumePointWithdrawalLimit(ctx, tx, command.UserID, command.RegionID, pointWithdrawalChannelPlatform, command.CommandID, command.WithdrawalRef, nowMs); err != nil {
|
||||
return ledger.SalaryWithdrawalReceipt{}, err
|
||||
}
|
||||
}
|
||||
if mutation.BizType == bizTypeSalaryWithdrawalRelease && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) && strings.TrimSpace(command.WithdrawalApplicationID) == "" {
|
||||
// 只有 gateway 建单失败补偿会携带 withdrawal_ref 且没有 application_id;人工驳回必须继续占用发起次数。
|
||||
if err := r.releasePointWithdrawalLimitReservation(ctx, tx, command.UserID, command.WithdrawalRef, command.CommandID, nowMs); err != nil {
|
||||
return ledger.SalaryWithdrawalReceipt{}, err
|
||||
}
|
||||
}
|
||||
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
availableAfter := account.AvailableAmount + mutation.AvailableDelta
|
||||
@ -105,6 +126,7 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S
|
||||
Reason: command.Reason,
|
||||
WithdrawalRef: command.WithdrawalRef,
|
||||
WithdrawalApplicationID: command.WithdrawalApplicationID,
|
||||
RegionID: command.RegionID,
|
||||
AvailableAfter: availableAfter,
|
||||
FrozenAfter: frozenAfter,
|
||||
Version: account.Version + 1,
|
||||
@ -140,6 +162,17 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S
|
||||
}
|
||||
|
||||
func salaryWithdrawalRequestHash(command ledger.SalaryWithdrawalCommand, bizType string) string {
|
||||
if bizType == bizTypeSalaryWithdrawalFreeze && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) {
|
||||
// POINT 的费率、换算比例和 region 均由服务端按当前用户/政策注入,同一客户端 command 重试时这些快照可能已经变化。
|
||||
// 哈希只锁定用户稳定提现意图;命中旧 transaction 后直接返回首次账变快照,既不重新选政策,也不重复消耗次数。
|
||||
return stableHash(fmt.Sprintf("point_withdrawal_freeze|%s|%d|%s|%d|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.UserID,
|
||||
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
|
||||
command.SalaryUSDMinor,
|
||||
strings.TrimSpace(command.WithdrawalRef),
|
||||
))
|
||||
}
|
||||
if strings.TrimSpace(command.WithdrawalApplicationID) != "" && (bizType == bizTypeSalaryWithdrawalSettle || bizType == bizTypeSalaryWithdrawalRelease) {
|
||||
// 审核人和备注是钱包 metadata 快照,不决定资金突变。将它们排除后,admin 事务失败换人/改备注重试仍命中原交易。
|
||||
// bizType 仍在哈希中,且 lookupSalaryWithdrawalTerminal 会在申请维度检查相反终局,settle/release 绝不能共存。
|
||||
|
||||
@ -66,6 +66,61 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
|
||||
ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '不可变政策版本,等于后台政策 ID' AFTER cycle_key`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
// 提现限制随工资政策版本发布;启动兼容只补小型配置表列,绝不回查钱包或提现流水推导计数。
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE host_agency_salary_policies
|
||||
ADD COLUMN coin_seller_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找币商提现限制周期:day/week/month' AFTER residual_diamond_to_usd_rate`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE host_agency_salary_policies
|
||||
ADD COLUMN coin_seller_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找币商提现次数,0 表示不限' AFTER coin_seller_withdrawal_limit_period`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE host_agency_salary_policies
|
||||
ADD COLUMN platform_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找平台提现限制周期:day/week/month' AFTER coin_seller_withdrawal_limit_count`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE host_agency_salary_policies
|
||||
ADD COLUMN platform_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找平台提现次数,0 表示不限' AFTER platform_withdrawal_limit_period`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
channel VARCHAR(24) NOT NULL,
|
||||
period VARCHAR(16) NOT NULL,
|
||||
period_key VARCHAR(10) NOT NULL,
|
||||
withdrawal_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id, channel, period, period_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自然周期提现次数计数表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
withdrawal_ref VARCHAR(128) NOT NULL,
|
||||
freeze_command_id VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
channel VARCHAR(24) NOT NULL,
|
||||
policy_id BIGINT UNSIGNED NOT NULL,
|
||||
policy_version BIGINT UNSIGNED NOT NULL,
|
||||
period VARCHAR(16) NOT NULL,
|
||||
period_key VARCHAR(10) NOT NULL,
|
||||
counted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
released_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (app_code, withdrawal_ref),
|
||||
UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, freeze_command_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
@ -73,12 +128,41 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
|
||||
cycle_key VARCHAR(7) NOT NULL,
|
||||
policy_id BIGINT UNSIGNED NOT NULL,
|
||||
policy_version BIGINT UNSIGNED NOT NULL,
|
||||
source_cycle_key VARCHAR(7) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, region_id, cycle_key),
|
||||
UNIQUE KEY uk_host_salary_policy_cycle_version (app_code, policy_id, policy_version)
|
||||
PRIMARY KEY (app_code, region_id, cycle_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期不可变政策绑定表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE host_salary_policy_cycle_bindings
|
||||
ADD COLUMN source_cycle_key VARCHAR(7) NOT NULL DEFAULT '' COMMENT '来源月份;等于 cycle_key 表示显式发布,否则表示继承' AFTER policy_version`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
// 迁移前绑定视为显式版本;只有新代码的运行时继承会写入与目标月不同的来源月。
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE host_salary_policy_cycle_bindings
|
||||
SET source_cycle_key = cycle_key
|
||||
WHERE source_cycle_key = ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
var legacyBindingIndexCount int
|
||||
// 旧唯一键会阻止同一政策覆盖多个区域或被复制到新周期;信息 schema 是常数级元数据查询,避免盲目 DDL 报错。
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'host_salary_policy_cycle_bindings'
|
||||
AND INDEX_NAME = 'uk_host_salary_policy_cycle_version'`).Scan(&legacyBindingIndexCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyBindingIndexCount > 0 {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE host_salary_policy_cycle_bindings
|
||||
DROP INDEX uk_host_salary_policy_cycle_version, ALGORITHM=INPLACE, LOCK=NONE`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE host_agency_salary_policies
|
||||
SET cycle_key = DATE_FORMAT(
|
||||
@ -91,9 +175,9 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO host_salary_policy_cycle_bindings (
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
|
||||
)
|
||||
SELECT policy.app_code, policy.region_id, policy.cycle_key, policy.policy_id, policy.policy_version, policy.created_at_ms
|
||||
SELECT policy.app_code, policy.region_id, policy.cycle_key, policy.policy_id, policy.policy_version, policy.cycle_key, policy.created_at_ms
|
||||
FROM host_agency_salary_policies policy
|
||||
WHERE policy.cycle_key <> ''
|
||||
AND policy.status = 'active'
|
||||
|
||||
@ -916,7 +916,16 @@ func (r *Repository) WalletOutboxPayload(appCode string, eventType string, userI
|
||||
|
||||
// SetHostSalaryPolicy seeds the wallet runtime policy snapshot used by salary settlement tests.
|
||||
func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) {
|
||||
r.SetHostSalaryPolicyForApp("lalu", policy)
|
||||
}
|
||||
|
||||
// SetHostSalaryPolicyForApp 模拟 Admin 向 wallet 发布一份指定 App 的不可变月政策和周期绑定。
|
||||
func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.HostSalaryPolicy) {
|
||||
r.t.Helper()
|
||||
appCode = strings.TrimSpace(appCode)
|
||||
if appCode == "" {
|
||||
appCode = "lalu"
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
if policy.PolicyID == 0 {
|
||||
@ -948,11 +957,18 @@ func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) {
|
||||
if policy.ResidualDiamondToUSDRate == "" {
|
||||
policy.ResidualDiamondToUSDRate = "0"
|
||||
}
|
||||
if policy.CoinSellerWithdrawalLimitPeriod == "" {
|
||||
policy.CoinSellerWithdrawalLimitPeriod = "month"
|
||||
}
|
||||
if policy.PlatformWithdrawalLimitPeriod == "" {
|
||||
policy.PlatformWithdrawalLimitPeriod = "month"
|
||||
}
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO host_agency_salary_policies (
|
||||
policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode, gift_coin_to_diamond_ratio,
|
||||
residual_diamond_to_usd_rate, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
app_code, policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode, gift_coin_to_diamond_ratio,
|
||||
residual_diamond_to_usd_rate, coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
cycle_key = VALUES(cycle_key),
|
||||
policy_version = VALUES(policy_version),
|
||||
@ -963,14 +979,21 @@ func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) {
|
||||
settlement_trigger_mode = VALUES(settlement_trigger_mode),
|
||||
gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio),
|
||||
residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate),
|
||||
coin_seller_withdrawal_limit_period = VALUES(coin_seller_withdrawal_limit_period),
|
||||
coin_seller_withdrawal_limit_count = VALUES(coin_seller_withdrawal_limit_count),
|
||||
platform_withdrawal_limit_period = VALUES(platform_withdrawal_limit_period),
|
||||
platform_withdrawal_limit_count = VALUES(platform_withdrawal_limit_count),
|
||||
effective_from_ms = VALUES(effective_from_ms),
|
||||
effective_to_ms = VALUES(effective_to_ms),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, policy.PolicyID, policy.CycleKey, policy.PolicyVersion, policy.Name, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, policy.ResidualDiamondToUSDRate, policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs)
|
||||
`, appCode, policy.PolicyID, policy.CycleKey, policy.PolicyVersion, policy.Name, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, policy.ResidualDiamondToUSDRate,
|
||||
policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount,
|
||||
policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount,
|
||||
policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("seed host salary policy failed: %v", err)
|
||||
}
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE policy_id = ?`, policy.PolicyID); err != nil {
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, appCode, policy.PolicyID); err != nil {
|
||||
r.t.Fatalf("clear host salary policy levels failed: %v", err)
|
||||
}
|
||||
for _, level := range policy.Levels {
|
||||
@ -979,10 +1002,10 @@ func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) {
|
||||
}
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO host_agency_salary_policy_levels (
|
||||
policy_id, level_no, required_diamonds, host_salary_usd_minor,
|
||||
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor,
|
||||
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
appCode, policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs,
|
||||
); err != nil {
|
||||
r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err)
|
||||
@ -991,11 +1014,11 @@ func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) {
|
||||
// 测试 helper 同时建立周期绑定,模拟后台一次完整发布;生产发布路径不允许 ON DUPLICATE 覆盖。
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO host_salary_policy_cycle_bindings (
|
||||
region_id, cycle_key, policy_id, policy_version, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
policy_id = VALUES(policy_id), policy_version = VALUES(policy_version)`,
|
||||
policy.RegionID, policy.CycleKey, policy.PolicyID, policy.PolicyVersion, nowMs,
|
||||
appCode, policy.RegionID, policy.CycleKey, policy.PolicyID, policy.PolicyVersion, nowMs,
|
||||
); err != nil {
|
||||
r.t.Fatalf("seed host salary policy cycle binding failed: %v", err)
|
||||
}
|
||||
@ -1017,6 +1040,7 @@ func validateTableName(table string) string {
|
||||
"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", "agency_point_share_entries",
|
||||
"point_withdrawal_policy_counters", "point_withdrawal_limit_reservations",
|
||||
"vip_program_configs", "vip_levels", "vip_level_benefits", "user_vip_memberships", "user_vip_settings", "vip_purchase_orders", "user_vip_history", "user_vip_trial_cards", "vip_command_locks", "vip_daily_coin_rebate_runs", "vip_daily_coin_rebates",
|
||||
"resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "wallet_gift_prices", "user_gift_wall", "wallet_projection_events",
|
||||
"resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":
|
||||
|
||||
@ -32,6 +32,7 @@ func (s *Server) TransferPointToCoinSeller(ctx context.Context, req *walletv1.Tr
|
||||
AppCode: req.GetAppCode(), CommandID: req.GetCommandId(), SourceUserID: req.GetSourceUserId(),
|
||||
SellerUserID: req.GetSellerUserId(), PointAmount: req.GetPointAmount(),
|
||||
SourceCountryCode: req.GetSourceCountryCode(), Reason: req.GetReason(),
|
||||
RegionID: req.GetRegionId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
|
||||
@ -24,6 +24,7 @@ func (s *Server) FreezePointWithdrawal(ctx context.Context, req *walletv1.Freeze
|
||||
FeeBPS: int64(req.GetFeeBps()),
|
||||
Reason: req.GetReason(),
|
||||
WithdrawalRef: req.GetWithdrawalRef(),
|
||||
RegionID: req.GetRegionId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
@ -86,6 +87,7 @@ func (s *Server) ReleasePointWithdrawal(ctx context.Context, req *walletv1.Relea
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
Reason: req.GetReason(),
|
||||
WithdrawalApplicationID: req.GetWithdrawalApplicationId(),
|
||||
WithdrawalRef: req.GetWithdrawalRef(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user