主播政策相关

This commit is contained in:
zhx 2026-07-20 16:48:30 +08:00
parent d4a9fbec01
commit c9d8c75d59
54 changed files with 2769 additions and 406 deletions

View File

@ -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,6 +4406,8 @@ 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"`
// 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
}
@ -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,6 +25832,8 @@ 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"`
// 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
}
@ -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" +

View File

@ -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 {

View 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 成功回执更新当前页面,其他页面进入时重新读取钱包余额。

View File

@ -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"),

View File

@ -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 等级当前不可购买。

View File

@ -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,

View File

@ -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,7 +445,12 @@ 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"`
@ -454,6 +461,9 @@ type HostAgencySalaryPolicy struct {
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 政策”;
// 该表用业务主键把并发校验下沉到 MySQLpolicy_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"`

View File

@ -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)
}

View File

@ -3,14 +3,17 @@ package hostagencypolicy
type policyRequest struct {
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"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
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"`
}

View File

@ -11,6 +11,7 @@ type policyDTO struct {
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"`
@ -18,8 +19,10 @@ type policyDTO struct {
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"`
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"`
@ -27,6 +30,9 @@ type policyDTO struct {
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"`
@ -58,6 +64,7 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
AppCode: item.AppCode,
Name: item.Name,
RegionID: item.RegionID,
RegionIDs: responseRegionIDs(item),
CycleKey: item.CycleKey,
PolicyVersion: item.PolicyVersion,
Status: item.Status,
@ -65,8 +72,10 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
EffectiveFromMS: item.EffectiveFromMS,
EffectiveToMS: item.EffectiveToMS,
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,
@ -74,12 +83,25 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
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)

View File

@ -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,18 +284,54 @@ 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)
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
}
_, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资")
if !runtimeHostSalaryPolicyMatches(existing, item, policyVersion) {
return errors.New("wallet 已存在内容不一致的工资政策快照")
}
expectedLevels, err := runtimeHostSalaryLevelsFromModel(item.Levels)
if err != nil {
return err
}
// 运行表保存美分整数,避免 wallet-service 结算时再解析 decimal 字符串造成重复口径。
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,
hostSalaryMinor,
level.HostCoinReward,
agencySalaryMinor,
level.Status,
level.SortOrder,
nonZeroMS(level.CreatedAtMS, publishedAtMS),
publishedAtMS,
item.AppCode, item.ID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder,
publishedAtMS, publishedAtMS,
); err != nil {
return err
}
}
// 周期绑定的复合主键是最终防线:同一区域同一周期只能首次绑定一次,后续月份发布不会影响它。
if _, err := tx.ExecContext(ctx, `
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 nil, 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
}
}
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, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?)`,
item.AppCode, item.RegionID, item.CycleKey, item.ID, policyVersion, publishedAtMS,
); err != nil {
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 个字符")
@ -328,15 +657,19 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
return model.HostAgencySalaryPolicy{
AppCode: appCode,
Name: name,
RegionID: req.RegionID,
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
RegionID: regionIDs[0],
RegionIDs: regionIDs,
CycleKey: cycleKey,
Status: status,
SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode,
GiftCoinToDiamondRatio: giftRatio,
ResidualDiamondToUSDRate: residualRate,
EffectiveFromMS: req.EffectiveFromMS,
EffectiveToMS: req.EffectiveToMS,
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
PlatformWithdrawalLimitCount: platformLimitCount,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
@ -344,6 +677,21 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
}, 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("请选择工资周期")
}

View File

@ -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,
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",
@ -104,13 +143,12 @@ func TestPolicyModelFromRequestRejectsMissingExplicitCycle(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Legacy cycle",
RegionID: 101,
EffectiveFromMS: 1782835200000, // 月初本地零点可能仍属于上一个 UTC 月,不能用它推导周期。
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)
}
}

View File

@ -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...)

View File

@ -332,7 +332,8 @@ func legacyPaidCoinSellerRechargeBillDTO(sourceConfig config.FinanceBillSourceCo
return rechargeBillDTO{
AppCode: sourceConfig.AppCode,
TransactionID: legacyDocumentID(document.ID),
RechargeType: "coin_seller",
// Legacy H5 FREIGHT_GOLD 是支付渠道成功单,归入币商-三方;运营手工选择的 USDT 只来自 admin 订单台账。
RechargeType: financeCoinSellerThirdPartyType,
Status: "succeeded",
ExternalRef: strings.TrimSpace(document.OrderID),
SellerUserID: document.AcceptUserID,

View File

@ -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,
}

View File

@ -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,15 +169,17 @@ 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 {
if rechargeSource != repository.CoinSellerRechargeSourceUSDT {
paidSource := h.financePaidCoinSellerRechargeSource(appCode)
if paidSource != nil {
paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query)
if err != nil {
return stats, err
@ -160,9 +194,11 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
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,7 +341,9 @@ 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 {
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
@ -310,6 +352,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
items = append(items, paidItems...)
total += paidTotal
}
}
if h != nil && h.store != nil {
orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{
Page: 1,
@ -317,6 +360,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
PageSizeLimit: prefetch,
AppCode: appCode,
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
RechargeSource: rechargeSource,
Keyword: query.Keyword,
TargetUserID: query.TargetUserID,
RegionID: query.RegionID,
@ -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,

View File

@ -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) {

View File

@ -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

View File

@ -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":

View File

@ -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

View File

@ -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,

View File

@ -16,6 +16,12 @@ 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"
@ -28,6 +34,7 @@ type CoinSellerRechargeOrderListOptions struct {
VerifyStatus string
GrantStatus string
ProviderCode string
RechargeSource string
Keyword string
TargetUserID int64
RegionID int64
@ -39,6 +46,7 @@ type CoinSellerRechargeOrderListOptions struct {
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

View File

@ -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",
@ -35,6 +35,7 @@ func TestCoinSellerRechargeOrderStatsAggregatesVerifiedAndGrantedOverlap(t *test
stats, err := store.CoinSellerRechargeOrderStats(CoinSellerRechargeOrderStatsOptions{
AppCode: "lalu",
RechargeSource: CoinSellerRechargeSourceUSDT,
RegionID: 101,
TargetUserID: 9001,
Keyword: "merchant-1",

View File

@ -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
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
}

View File

@ -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{},

View File

@ -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
);

View File

@ -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;

View File

@ -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;

View File

@ -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))

View File

@ -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",

View File

@ -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)

View File

@ -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 {

View File

@ -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,

View File

@ -2,7 +2,8 @@ package walletapi
import (
"context"
"fmt"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"strconv"
@ -19,6 +20,10 @@ import (
const (
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)

View File

@ -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
}

View File

@ -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'

View File

@ -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;

View File

@ -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 = '';

View File

@ -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='平台积分提现次数预留表';

View File

@ -39,6 +39,10 @@ type HostSalaryPolicy struct {
SettlementTriggerMode string
GiftCoinToDiamondRatio string
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
EffectiveFromMs int64
EffectiveToMs int64
Levels []HostSalaryPolicyLevel

View File

@ -21,6 +21,7 @@ type PointToCoinSellerCommand struct {
SellerUserID int64
PointAmount int64
SourceCountryCode string
RegionID int64
Reason string
}

View File

@ -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 钱包冻结/扣冻/释放后的余额和费用快照。

View File

@ -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)

View File

@ -16,7 +16,7 @@ const (
// FreezePointWithdrawal 把已启用 App 的收益积分从 available 冻结到 frozengateway 必须先读取已发布政策快照。
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,
}
}

View File

@ -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)
}

View File

@ -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, `

View File

@ -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"`

View File

@ -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,

View File

@ -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(&current); 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
}

View File

@ -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 绝不能共存。

View File

@ -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'

View File

@ -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":

View File

@ -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)

View File

@ -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)