主播政策相关

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,8 +4406,10 @@ type ReleasePointWithdrawalRequest struct {
Reason string `protobuf:"bytes,10,opt,name=reason,proto3" json:"reason,omitempty"`
AppCode string `protobuf:"bytes,11,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
WithdrawalApplicationId string `protobuf:"bytes,12,opt,name=withdrawal_application_id,json=withdrawalApplicationId,proto3" json:"withdrawal_application_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// withdrawal_ref 只用于申请创建失败回滚对应的次数 reservation人工驳回不传。
WithdrawalRef string `protobuf:"bytes,13,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ReleasePointWithdrawalRequest) Reset() {
@ -4515,6 +4526,13 @@ func (x *ReleasePointWithdrawalRequest) GetWithdrawalApplicationId() string {
return ""
}
func (x *ReleasePointWithdrawalRequest) GetWithdrawalRef() string {
if x != nil {
return x.WithdrawalRef
}
return ""
}
type ReleasePointWithdrawalResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
@ -25814,8 +25832,10 @@ type TransferPointToCoinSellerRequest struct {
// source_country_code 由 gateway 从用户资料读取wallet 会再次校验币商服务国家。
SourceCountryCode string `protobuf:"bytes,6,opt,name=source_country_code,json=sourceCountryCode,proto3" json:"source_country_code,omitempty"`
Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// region_id 只能由 gateway 从登录用户资料注入,用于解析当前 UTC 月工资政策。
RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TransferPointToCoinSellerRequest) Reset() {
@ -25897,6 +25917,13 @@ func (x *TransferPointToCoinSellerRequest) GetReason() string {
return ""
}
func (x *TransferPointToCoinSellerRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type TransferPointToCoinSellerResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
@ -26899,7 +26926,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x1fReleaseSalaryWithdrawalResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12(\n" +
"\x10salary_usd_minor\x18\x03 \x01(\x03R\x0esalaryUsdMinor\"\x90\x03\n" +
"\x10salary_usd_minor\x18\x03 \x01(\x03R\x0esalaryUsdMinor\"\xad\x03\n" +
"\x1cFreezePointWithdrawalRequest\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
@ -26914,7 +26941,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x06reason\x18\t \x01(\tR\x06reason\x12\x19\n" +
"\bapp_code\x18\n" +
" \x01(\tR\aappCode\x12%\n" +
"\x0ewithdrawal_ref\x18\v \x01(\tR\rwithdrawalRef\"\xc0\x02\n" +
"\x0ewithdrawal_ref\x18\v \x01(\tR\rwithdrawalRef\x12\x1b\n" +
"\tregion_id\x18\f \x01(\x03R\bregionId\"\xc0\x02\n" +
"\x1dFreezePointWithdrawalResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" +
@ -26946,7 +26974,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x10fee_point_amount\x18\x04 \x01(\x03R\x0efeePointAmount\x12(\n" +
"\x10net_point_amount\x18\x05 \x01(\x03R\x0enetPointAmount\x12$\n" +
"\x0epoints_per_usd\x18\x06 \x01(\x03R\fpointsPerUsd\x12\x17\n" +
"\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xd0\x03\n" +
"\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xf7\x03\n" +
"\x1dReleasePointWithdrawalRequest\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
@ -26962,7 +26990,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x06reason\x18\n" +
" \x01(\tR\x06reason\x12\x19\n" +
"\bapp_code\x18\v \x01(\tR\aappCode\x12:\n" +
"\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\"\xc1\x02\n" +
"\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\x12%\n" +
"\x0ewithdrawal_ref\x18\r \x01(\tR\rwithdrawalRef\"\xc1\x02\n" +
"\x1eReleasePointWithdrawalResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" +
@ -29109,7 +29138,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\fcountry_code\x18\x03 \x01(\tR\vcountryCode\x12)\n" +
"\x10include_disabled\x18\x04 \x01(\bR\x0fincludeDisabled\"t\n" +
"&ListPointWithdrawalCoinSellersResponse\x12J\n" +
"\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\x93\x02\n" +
"\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\xb0\x02\n" +
" TransferPointToCoinSellerRequest\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" +
@ -29118,7 +29147,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0eseller_user_id\x18\x04 \x01(\x03R\fsellerUserId\x12!\n" +
"\fpoint_amount\x18\x05 \x01(\x03R\vpointAmount\x12.\n" +
"\x13source_country_code\x18\x06 \x01(\tR\x11sourceCountryCode\x12\x16\n" +
"\x06reason\x18\a \x01(\tR\x06reason\"\xf1\x02\n" +
"\x06reason\x18\a \x01(\tR\x06reason\x12\x1b\n" +
"\tregion_id\x18\b \x01(\x03R\bregionId\"\xf1\x02\n" +
"!TransferPointToCoinSellerResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12;\n" +
"\x1asource_point_balance_after\x18\x02 \x01(\x03R\x17sourcePointBalanceAfter\x120\n" +

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,17 +445,25 @@ type HostAgencySalaryPolicy struct {
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
// 时间范围保留为运营展示元数据;工资结算只按 CycleKey 绑定版本,不能再用执行时间选政策。
// 提现次数限制随月度政策形成不可变快照count=0 表示不限period 只允许 day/week/month。
CoinSellerWithdrawalLimitPeriod string `gorm:"column:coin_seller_withdrawal_limit_period;size:16;not null;default:month" json:"coinSellerWithdrawalLimitPeriod"`
CoinSellerWithdrawalLimitCount int64 `gorm:"column:coin_seller_withdrawal_limit_count;not null;default:0" json:"coinSellerWithdrawalLimitCount"`
PlatformWithdrawalLimitPeriod string `gorm:"column:platform_withdrawal_limit_period;size:16;not null;default:month" json:"platformWithdrawalLimitPeriod"`
PlatformWithdrawalLimitCount int64 `gorm:"column:platform_withdrawal_limit_count;not null;default:0" json:"platformWithdrawalLimitCount"`
// Effective* 仅保留兼容既有表结构;后台不再配置或展示,工资结算只按 CycleKey 绑定版本。
EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"`
EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"`
Description string `gorm:"size:255;not null;default:''" json:"description"`
CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"`
UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"`
// 发布字段只记录 admin 配置同步到 wallet 运行快照的结果;结算仍只读取 wallet-service 自己的运行表。
PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"`
PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"`
PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"`
PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"`
PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"`
PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"`
PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"`
PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"`
// CopiedFrom* 记录系统补齐缺失月份时的来源;新月份拥有独立 policy_version后续编辑不会改动上月口径。
CopiedFromPolicyID uint `gorm:"column:copied_from_policy_id;not null;default:0" json:"copiedFromPolicyId"`
CopiedFromCycleKey string `gorm:"column:copied_from_cycle_key;size:7;not null;default:''" json:"copiedFromCycleKey"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
Levels []HostAgencySalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"`
@ -463,6 +473,33 @@ func (HostAgencySalaryPolicy) TableName() string {
return "admin_host_agency_salary_policies"
}
// HostAgencySalaryPolicyRegion 把一份政策关联到多个区域,避免复制多份可独立漂移的等级配置。
type HostAgencySalaryPolicyRegion struct {
PolicyID uint `gorm:"column:policy_id;primaryKey"`
RegionID int64 `gorm:"column:region_id;primaryKey;index:idx_host_agency_policy_region,priority:1"`
CreatedAtMS int64 `gorm:"column:created_at_ms;not null"`
}
func (HostAgencySalaryPolicyRegion) TableName() string {
return "admin_host_agency_salary_policy_regions"
}
// HostAgencySalaryPolicyActiveScope 是启用政策的数据库级区域/月占位。
// 普通区域映射允许一份政策覆盖多区,但无法表达“同一区域同月只能有一份 active 政策”;
// 该表用业务主键把并发校验下沉到 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

@ -1,18 +1,21 @@
package hostagencypolicy
type policyRequest struct {
Name string `json:"name"`
RegionID int64 `json:"region_id"`
CycleKey string `json:"cycle_key"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
Description string `json:"description"`
Levels []levelRequest `json:"levels"`
Name string `json:"name"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids"`
CycleKey string `json:"cycle_key"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
Description string `json:"description"`
Levels []levelRequest `json:"levels"`
}
type levelRequest struct {

View File

@ -7,29 +7,35 @@ import (
)
type policyDTO struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
RegionID int64 `json:"region_id"`
CycleKey string `json:"cycle_key"`
PolicyVersion uint64 `json:"policy_version"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
Description string `json:"description"`
CreatedByAdminID uint `json:"created_by_admin_id"`
UpdatedByAdminID uint `json:"updated_by_admin_id"`
PublishStatus string `json:"publish_status"`
PublishError string `json:"publish_error"`
PublishedAtMS int64 `json:"published_at_ms"`
PublishedByAdminID uint `json:"published_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Levels []levelDTO `json:"levels"`
ID uint `json:"id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids"`
CycleKey string `json:"cycle_key"`
PolicyVersion uint64 `json:"policy_version"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
Description string `json:"description"`
CreatedByAdminID uint `json:"created_by_admin_id"`
UpdatedByAdminID uint `json:"updated_by_admin_id"`
PublishStatus string `json:"publish_status"`
PublishError string `json:"publish_error"`
PublishedAtMS int64 `json:"published_at_ms"`
PublishedByAdminID uint `json:"published_by_admin_id"`
CopiedFromPolicyID uint `json:"copied_from_policy_id"`
CopiedFromCycleKey string `json:"copied_from_cycle_key"`
Inherited bool `json:"inherited"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Levels []levelDTO `json:"levels"`
}
type levelDTO struct {
@ -54,32 +60,48 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
}
// 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。
return policyDTO{
ID: item.ID,
AppCode: item.AppCode,
Name: item.Name,
RegionID: item.RegionID,
CycleKey: item.CycleKey,
PolicyVersion: item.PolicyVersion,
Status: item.Status,
SettlementMode: item.SettlementMode,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
EffectiveFromMS: item.EffectiveFromMS,
EffectiveToMS: item.EffectiveToMS,
Description: item.Description,
CreatedByAdminID: item.CreatedByAdminID,
UpdatedByAdminID: item.UpdatedByAdminID,
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
PublishError: item.PublishError,
PublishedAtMS: item.PublishedAtMS,
PublishedByAdminID: item.PublishedByAdminID,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
Levels: levels,
ID: item.ID,
AppCode: item.AppCode,
Name: item.Name,
RegionID: item.RegionID,
RegionIDs: responseRegionIDs(item),
CycleKey: item.CycleKey,
PolicyVersion: item.PolicyVersion,
Status: item.Status,
SettlementMode: item.SettlementMode,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount,
Description: item.Description,
CreatedByAdminID: item.CreatedByAdminID,
UpdatedByAdminID: item.UpdatedByAdminID,
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
PublishError: item.PublishError,
PublishedAtMS: item.PublishedAtMS,
PublishedByAdminID: item.PublishedByAdminID,
CopiedFromPolicyID: item.CopiedFromPolicyID,
CopiedFromCycleKey: item.CopiedFromCycleKey,
Inherited: item.CopiedFromPolicyID > 0 && item.CopiedFromCycleKey != "",
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
Levels: levels,
}
}
func responseRegionIDs(item model.HostAgencySalaryPolicy) []int64 {
if len(item.RegionIDs) > 0 {
return append([]int64(nil), item.RegionIDs...)
}
if item.RegionID > 0 {
return []int64{item.RegionID}
}
return []int64{}
}
func levelFromModel(item model.HostAgencySalaryLevel) levelDTO {
hostSalary := trimDecimalZeros(item.HostSalaryUSD)
agencySalary := trimDecimalZeros(item.AgencySalaryUSD)

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,17 +284,53 @@ func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id
return policyFromModel(item), nil
}
func normalizePolicyPersistenceError(err error) error {
if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
return errors.New("同一区域同一工资周期只能存在一个启用政策")
}
return err
}
func (s *Service) runtimePolicySnapshotExists(ctx context.Context, item model.HostAgencySalaryPolicy) (bool, error) {
if s == nil || s.walletDB == nil {
// 编辑/删除属于破坏不可变发布事实的高风险动作;无法核对 wallet 时必须失败关闭,不能假定未发布。
return false, errors.New("wallet mysql is not configured")
}
if item.ID == 0 {
return false, nil
}
policyVersion := item.PolicyVersion
if policyVersion == 0 {
policyVersion = uint64(item.ID)
}
var exists int
err := s.walletDB.QueryRowContext(ctx, `
SELECT 1
FROM host_agency_salary_policies
WHERE app_code = ? AND policy_id = ? AND policy_version = ?
LIMIT 1`, item.AppCode, item.ID, policyVersion).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, excludeID uint) error {
// 停用政策允许提前配置多套草稿;只有 active 才会参与结算匹配,所以冲突检查只约束启用态。
if item.Status != policyStatusActive {
return nil
}
overlap, err := s.store.HasActiveHostAgencySalaryPolicyForCycle(item.AppCode, item.RegionID, item.CycleKey, excludeID)
if err != nil {
return err
}
if overlap {
return errors.New("同一区域同一工资周期只能存在一个启用政策")
for _, regionID := range item.RegionIDs {
overlap, err := s.store.HasActiveHostAgencySalaryPolicyForCycle(item.AppCode, regionID, item.CycleKey, excludeID)
if err != nil {
return err
}
if overlap {
return errors.New("同一区域同一工资周期只能存在一个启用政策")
}
}
return nil
}
@ -198,71 +353,240 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
// 仅兼容迁移前创建但尚未发布的草稿;新记录在 admin 仓储创建事务中固定为 policy ID。
policyVersion = uint64(item.ID)
}
// policy 主表和等级表只允许首次插入;任何重复主键或同周期绑定冲突都会回滚整包发布,禁止覆盖历史版本。
// 跨库状态回写失败后重试时wallet 可能已经拥有完整快照;内容一致视为幂等成功,
// 但任何业务字段或等级不一致都拒绝覆盖,继续保持已发布版本不可变。
if err := ensureRuntimeHostSalaryPolicySnapshot(ctx, tx, item, policyVersion, publishedAtMS); err != nil {
return err
}
// 每个区域各自拥有不可覆盖的周期绑定,但同一政策快照和等级只写入一次,避免多区域版本漂移。
// 系统继承绑定的 source_cycle_key 指向上月,允许随后自动生成或人工创建的本月独立版本接管;显式绑定永不覆盖。
for _, regionID := range item.RegionIDs {
if err := bindExplicitHostSalaryPolicy(ctx, tx, item, regionID, policyVersion, publishedAtMS); err != nil {
return err
}
}
return tx.Commit()
}
type runtimeHostSalaryPolicySnapshot struct {
CycleKey string
PolicyVersion uint64
Name string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
}
type runtimeHostSalaryLevelSnapshot struct {
LevelNo int32
RequiredDiamonds int64
HostSalaryUSDMinor int64
HostCoinReward int64
AgencySalaryUSDMinor int64
Status string
SortOrder int32
}
func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error {
// 先以完整快照尝试原子占位;主键已存在时只做 policy_id 自赋值,绝不改写任何业务字段。
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policies (
app_code, policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate,
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.AppCode,
item.ID,
item.CycleKey,
policyVersion,
item.Name,
item.RegionID,
item.Status,
item.SettlementMode,
firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
item.GiftCoinToDiamondRatio,
item.ResidualDiamondToUSDRate,
item.EffectiveFromMS,
item.EffectiveToMS,
nonZeroMS(item.CreatedAtMS, publishedAtMS),
publishedAtMS,
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
platform_withdrawal_limit_period, platform_withdrawal_limit_count,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id)`,
item.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, item.RegionID, item.Status,
item.SettlementMode, firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
item.GiftCoinToDiamondRatio, item.ResidualDiamondToUSDRate,
firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
nonZeroMS(item.CreatedAtMS, publishedAtMS), publishedAtMS,
); err != nil {
return err
}
for _, level := range item.Levels {
_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
var existing runtimeHostSalaryPolicySnapshot
err := tx.QueryRowContext(ctx, `
SELECT cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
CAST(gift_coin_to_diamond_ratio AS CHAR), CAST(residual_diamond_to_usd_rate AS CHAR),
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
platform_withdrawal_limit_period, platform_withdrawal_limit_count
FROM host_agency_salary_policies
WHERE app_code = ? AND policy_id = ?
FOR UPDATE`, item.AppCode, item.ID).Scan(
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.RegionID, &existing.Status,
&existing.SettlementMode, &existing.SettlementTriggerMode,
&existing.GiftCoinToDiamondRatio, &existing.ResidualDiamondToUSDRate,
&existing.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount,
&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
)
if err != nil {
return err
}
if !runtimeHostSalaryPolicyMatches(existing, item, policyVersion) {
return errors.New("wallet 已存在内容不一致的工资政策快照")
}
expectedLevels, err := runtimeHostSalaryLevelsFromModel(item.Levels)
if err != nil {
return err
}
existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID)
if err != nil {
return err
}
if len(existingLevels) == 0 {
// 主快照写锁仍由当前事务持有;只有第一个成功占位的事务会看到空等级并整包插入,
// 后续并发发布等待主键锁后会读取完整等级,不会重复插入。
for _, level := range expectedLevels {
// 运行表保存美分整数,避免 wallet-service 结算时再次解析 decimal 字符串造成口径漂移。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor,
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.AppCode, item.ID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder,
publishedAtMS, publishedAtMS,
); err != nil {
return err
}
}
existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID)
if err != nil {
return err
}
}
if !runtimeHostSalaryLevelsMatch(existingLevels, expectedLevels) {
return errors.New("wallet 已存在内容不一致的工资政策等级快照")
}
return nil
}
func runtimeHostSalaryPolicyMatches(existing runtimeHostSalaryPolicySnapshot, item model.HostAgencySalaryPolicy, policyVersion uint64) bool {
return existing.CycleKey == item.CycleKey && existing.PolicyVersion == policyVersion && existing.Name == item.Name &&
existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
existing.CoinSellerWithdrawalLimitCount == item.CoinSellerWithdrawalLimitCount &&
existing.PlatformWithdrawalLimitPeriod == firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
existing.PlatformWithdrawalLimitCount == item.PlatformWithdrawalLimitCount
}
func runtimeHostSalaryLevelsFromModel(levels []model.HostAgencySalaryLevel) ([]runtimeHostSalaryLevelSnapshot, error) {
models := append([]model.HostAgencySalaryLevel(nil), levels...)
sort.Slice(models, func(i, j int) bool { return models[i].LevelNo < models[j].LevelNo })
out := make([]runtimeHostSalaryLevelSnapshot, 0, len(models))
for _, level := range models {
_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
if err != nil {
return nil, err
}
_, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资")
if err != nil {
return err
return nil, err
}
// 运行表保存美分整数,避免 wallet-service 结算时再解析 decimal 字符串造成重复口径。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor,
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.AppCode,
item.ID,
level.LevelNo,
level.RequiredDiamonds,
hostSalaryMinor,
level.HostCoinReward,
agencySalaryMinor,
level.Status,
level.SortOrder,
nonZeroMS(level.CreatedAtMS, publishedAtMS),
publishedAtMS,
); err != nil {
return err
out = append(out, runtimeHostSalaryLevelSnapshot{
LevelNo: level.LevelNo, RequiredDiamonds: level.RequiredDiamonds, HostSalaryUSDMinor: hostSalaryMinor,
HostCoinReward: level.HostCoinReward, AgencySalaryUSDMinor: agencySalaryMinor, Status: level.Status, SortOrder: level.SortOrder,
})
}
return out, nil
}
func loadRuntimeHostSalaryLevels(ctx context.Context, tx *sql.Tx, appCode string, policyID uint) ([]runtimeHostSalaryLevelSnapshot, error) {
rows, err := tx.QueryContext(ctx, `
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
agency_salary_usd_minor, status, sort_order
FROM host_agency_salary_policy_levels
WHERE app_code = ? AND policy_id = ?
ORDER BY level_no ASC`, appCode, policyID)
if err != nil {
return nil, err
}
defer rows.Close()
var levels []runtimeHostSalaryLevelSnapshot
for rows.Next() {
var level runtimeHostSalaryLevelSnapshot
if err := rows.Scan(&level.LevelNo, &level.RequiredDiamonds, &level.HostSalaryUSDMinor, &level.HostCoinReward,
&level.AgencySalaryUSDMinor, &level.Status, &level.SortOrder); err != nil {
return nil, err
}
levels = append(levels, level)
}
return levels, rows.Err()
}
func runtimeHostSalaryLevelsMatch(existing []runtimeHostSalaryLevelSnapshot, expected []runtimeHostSalaryLevelSnapshot) bool {
if len(existing) != len(expected) {
return false
}
for index := range expected {
if existing[index] != expected[index] {
return false
}
}
// 周期绑定的复合主键是最终防线:同一区域同一周期只能首次绑定一次,后续月份发布不会影响它。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_salary_policy_cycle_bindings (
app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?)`,
item.AppCode, item.RegionID, item.CycleKey, item.ID, policyVersion, publishedAtMS,
); err != nil {
return true
}
func bindExplicitHostSalaryPolicy(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, regionID int64, policyVersion uint64, publishedAtMS int64) error {
var existingPolicyID, existingPolicyVersion uint64
var sourceCycleKey string
err := tx.QueryRowContext(ctx, `
SELECT policy_id, policy_version, source_cycle_key
FROM host_salary_policy_cycle_bindings
WHERE app_code = ? AND region_id = ? AND cycle_key = ?
FOR UPDATE`, item.AppCode, regionID, item.CycleKey,
).Scan(&existingPolicyID, &existingPolicyVersion, &sourceCycleKey)
if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, `
INSERT INTO host_salary_policy_cycle_bindings (
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
item.AppCode, regionID, item.CycleKey, item.ID, policyVersion, item.CycleKey, publishedAtMS,
)
return err
}
return tx.Commit()
if err != nil {
return err
}
if existingPolicyID == uint64(item.ID) && existingPolicyVersion == policyVersion {
return nil
}
if strings.TrimSpace(sourceCycleKey) == "" || strings.TrimSpace(sourceCycleKey) == item.CycleKey {
return errors.New("该区域工资月已经绑定其他显式政策版本")
}
// 只替换明确标记为跨月继承的绑定WHERE 再次约束来源,防止未来代码改动误覆盖显式历史版本。
result, err := tx.ExecContext(ctx, `
UPDATE host_salary_policy_cycle_bindings
SET policy_id = ?, policy_version = ?, source_cycle_key = ?, created_at_ms = ?
WHERE app_code = ? AND region_id = ? AND cycle_key = ? AND source_cycle_key <> cycle_key`,
item.ID, policyVersion, item.CycleKey, publishedAtMS, item.AppCode, regionID, item.CycleKey,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return errors.New("工资月继承绑定已被并发修改,请重试")
}
return nil
}
func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.HostAgencySalaryPolicy, error) {
@ -270,11 +594,11 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
if name == "" || len([]rune(name)) > 120 {
return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确")
}
// 当前阶段保持单区域政策:一个区域同一工资周期只能命中一条 active 规则。
if req.RegionID <= 0 {
return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域")
regionIDs, err := normalizePolicyRegionIDs(req.RegionID, req.RegionIDs)
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
cycleKey, err := normalizePolicyCycleKey(req.CycleKey, req.EffectiveFromMS)
cycleKey, err := normalizePolicyCycleKey(req.CycleKey)
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
@ -302,9 +626,6 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
if !validSettlementTriggerMode(settlementTriggerMode) {
return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确")
}
if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) {
return model.HostAgencySalaryPolicy{}, errors.New("政策生效时间不正确")
}
// 送礼入主播周期钻石的实际比例已经由“礼物钻石”配置在 wallet 扣礼物时写入周期账户;
// 这里不再接受工资政策单独覆盖,避免同一笔礼物在入账和结算阶段出现两套钻石口径。
giftRatio, _, err := parseFixedDecimal(defaultGiftCoinToDiamondRatio, 6, false, "金币转钻石比例")
@ -316,6 +637,14 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
coinSellerLimitPeriod, coinSellerLimitCount, err := normalizeWithdrawalLimit(req.CoinSellerWithdrawalLimitPeriod, req.CoinSellerWithdrawalLimitCount, "用户找币商提现")
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
platformLimitPeriod, platformLimitCount, err := normalizeWithdrawalLimit(req.PlatformWithdrawalLimitPeriod, req.PlatformWithdrawalLimitCount, "用户找平台提现")
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
description := strings.TrimSpace(req.Description)
if len([]rune(description)) > 255 {
return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符")
@ -326,24 +655,43 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
return model.HostAgencySalaryPolicy{}, err
}
return model.HostAgencySalaryPolicy{
AppCode: appCode,
Name: name,
RegionID: req.RegionID,
CycleKey: cycleKey,
Status: status,
SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode,
GiftCoinToDiamondRatio: giftRatio,
ResidualDiamondToUSDRate: residualRate,
EffectiveFromMS: req.EffectiveFromMS,
EffectiveToMS: req.EffectiveToMS,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
Levels: levels,
AppCode: appCode,
Name: name,
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
RegionID: regionIDs[0],
RegionIDs: regionIDs,
CycleKey: cycleKey,
Status: status,
SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode,
GiftCoinToDiamondRatio: giftRatio,
ResidualDiamondToUSDRate: residualRate,
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
PlatformWithdrawalLimitCount: platformLimitCount,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
Levels: levels,
}, nil
}
// normalizeWithdrawalLimit 固定月度政策允许的周期枚举;次数为 0 时保留周期但运行侧直接按不限处理。
func normalizeWithdrawalLimit(period string, count int64, field string) (string, int64, error) {
period = strings.ToLower(strings.TrimSpace(period))
if period == "" {
period = withdrawalLimitPeriodMonth
}
if period != withdrawalLimitPeriodDay && period != withdrawalLimitPeriodWeek && period != withdrawalLimitPeriodMonth {
return "", 0, fmt.Errorf("%s限制周期不正确", field)
}
if count < 0 {
return "", 0, fmt.Errorf("%s限制次数不能小于 0", field)
}
return period, count, nil
}
func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) {
if len(requests) == 0 {
return nil, errors.New("至少需要配置一个等级")
@ -526,11 +874,41 @@ func nonZeroMS(value int64, fallback int64) int64 {
return fallback
}
func normalizePolicyCycleKey(raw string, _ int64) (string, error) {
func normalizePolicyRegionIDs(legacyRegionID int64, rawRegionIDs []int64) ([]int64, error) {
// region_ids 是新接口的唯一覆盖集合region_id 仅在旧客户端未提交数组时回退,避免旧值悄悄扩大新配置范围。
regionIDs := rawRegionIDs
if len(regionIDs) == 0 && legacyRegionID > 0 {
regionIDs = []int64{legacyRegionID}
}
seen := make(map[int64]struct{}, len(regionIDs))
normalized := make([]int64, 0, len(regionIDs))
for _, regionID := range regionIDs {
if regionID <= 0 {
return nil, errors.New("请选择适用区域")
}
if _, exists := seen[regionID]; exists {
continue
}
seen[regionID] = struct{}{}
normalized = append(normalized, regionID)
}
if len(normalized) == 0 {
return nil, errors.New("请选择适用区域")
}
// 固定顺序让首个兼容 region_id 和 API 返回值稳定,避免相同集合因提交顺序不同制造无意义更新。
for i := 1; i < len(normalized); i++ {
for j := i; j > 0 && normalized[j-1] > normalized[j]; j-- {
normalized[j-1], normalized[j] = normalized[j], normalized[j-1]
}
}
return normalized, nil
}
func normalizePolicyCycleKey(raw string) (string, error) {
cycleKey := strings.TrimSpace(raw)
// 工资周期是独立业务键不能从生效时刻反推后台的“7 月 1 日 00:00”会按
// 工资周期是唯一生效边界,不能从历史 Effective* 字段反推后台的“7 月 1 日 00:00”会按
// 浏览器时区转成 6 月 30 日 UTC再取 UTC 月份会把 7 月政策错绑到 6 月。
// 请求必须显式提交 cycle_key让生效区间只负责时间范围周期归属不再受时区换算影响。
// 请求必须显式提交 cycle_key;历史时间字段仅兼容既有表结构,不再参与配置、展示或选政策
if len(cycleKey) != len("2006-01") {
return "", errors.New("请选择工资周期")
}

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,
CycleKey: "2026-06",
Status: "active",
SettlementMode: "half-month",
SettlementTriggerMode: "manual",
GiftCoinToDiamondRatio: "9",
ResidualDiamondToUSDRate: "0.000001",
Name: "Lalu 默认政策",
RegionIDs: []int64{102, 101, 102},
CycleKey: "2026-06",
Status: "active",
SettlementMode: "half-month",
SettlementTriggerMode: "manual",
GiftCoinToDiamondRatio: "9",
ResidualDiamondToUSDRate: "0.000001",
CoinSellerWithdrawalLimitPeriod: "week",
CoinSellerWithdrawalLimitCount: 3,
PlatformWithdrawalLimitPeriod: "day",
PlatformWithdrawalLimitCount: 1,
Levels: []levelRequest{
{Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
@ -23,10 +37,16 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
if item.AppCode != "lalu" || item.CycleKey != "2026-06" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual {
t.Fatalf("policy fields mismatch: %+v", item)
}
if item.RegionID != 101 || len(item.RegionIDs) != 2 || item.RegionIDs[0] != 101 || item.RegionIDs[1] != 102 {
t.Fatalf("multi-region scope should be sorted and deduplicated: %+v", item.RegionIDs)
}
// 工资政策不能单独改送礼入主播周期钻石的比例;该比例只由礼物钻石配置页控制。
if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
t.Fatalf("ratio fields mismatch: %+v", item)
}
if item.CoinSellerWithdrawalLimitPeriod != "week" || item.CoinSellerWithdrawalLimitCount != 3 || item.PlatformWithdrawalLimitPeriod != "day" || item.PlatformWithdrawalLimitCount != 1 {
t.Fatalf("withdrawal limit fields mismatch: %+v", item)
}
if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 {
t.Fatalf("levels should be sorted: %+v", item.Levels)
}
@ -35,6 +55,25 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
}
}
func TestPolicyModelFromRequestRejectsInvalidWithdrawalLimit(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid withdrawal limit", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
CoinSellerWithdrawalLimitPeriod: "year", CoinSellerWithdrawalLimitCount: 1,
Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
})
if err == nil {
t.Fatal("unsupported withdrawal limit period must fail")
}
_, err = policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid withdrawal count", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitCount: -1,
Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
})
if err == nil {
t.Fatal("negative withdrawal limit count must fail")
}
}
func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid",
@ -102,15 +141,14 @@ func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T)
func TestPolicyModelFromRequestRejectsMissingExplicitCycle(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Legacy cycle",
RegionID: 101,
EffectiveFromMS: 1782835200000, // 月初本地零点可能仍属于上一个 UTC 月,不能用它推导周期。
Name: "Legacy cycle",
RegionID: 101,
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err == nil {
t.Fatal("missing cycle_key must fail instead of deriving a timezone-dependent month")
t.Fatal("missing cycle_key must fail because it cannot be derived from a removed effective-time field")
}
}
@ -127,3 +165,98 @@ func TestPolicyModelFromRequestRejectsInvalidCycle(t *testing.T) {
t.Fatal("expected invalid cycle to fail")
}
}
func TestCloneHostAgencySalaryPolicyForNextCycleKeepsIndependentDraft(t *testing.T) {
source := model.HostAgencySalaryPolicy{
ID: 71, AppCode: "fami", Name: "July policy", RegionID: 25, RegionIDs: []int64{25, 26},
CycleKey: "2026-07", PolicyVersion: 71, Status: policyStatusActive, PublishStatus: publishStatusPublished,
PublishedAtMS: 100, Levels: []model.HostAgencySalaryLevel{{ID: 9, PolicyID: 71, LevelNo: 1, RequiredDiamonds: 100}},
}
clone := cloneHostAgencySalaryPolicyForCycle(source, "2026-08")
if clone.ID != 0 || clone.PolicyVersion != 0 || clone.CycleKey != "2026-08" || clone.PublishStatus != publishStatusDraft {
t.Fatalf("next-cycle copy must be an independent draft: %+v", clone)
}
if clone.CopiedFromPolicyID != 71 || clone.CopiedFromCycleKey != "2026-07" || len(clone.RegionIDs) != 2 {
t.Fatalf("copy provenance or regions mismatch: %+v", clone)
}
if len(clone.Levels) != 1 || clone.Levels[0].ID != 0 || clone.Levels[0].PolicyID != 0 {
t.Fatalf("copied levels must receive new identities: %+v", clone.Levels)
}
}
func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin transaction: %v", err)
}
item := model.HostAgencySalaryPolicy{
ID: 81, AppCode: "fami", Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81,
Status: policyStatusActive, SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
CoinSellerWithdrawalLimitPeriod: "week", CoinSellerWithdrawalLimitCount: 2,
PlatformWithdrawalLimitPeriod: "day", PlatformWithdrawalLimitCount: 1,
Levels: []model.HostAgencySalaryLevel{{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSD: "1.50", HostCoinReward: 20, AgencySalaryUSD: "0.50", Status: policyStatusActive, SortOrder: 1}},
}
mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{
"cycle_key", "policy_version", "name", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
"gift_ratio", "residual_rate", "coin_period", "coin_count", "platform_period", "platform_count",
}).AddRow("2026-08", 81, "August policy", 25, "active", "daily", "automatic", "1.000000", "0.000000000000", "week", 2, "day", 1))
mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{
"level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order",
}).AddRow(1, 100, 150, 20, 50, "active", 1))
if err := ensureRuntimeHostSalaryPolicySnapshot(context.Background(), tx, item, 81, 200); err != nil {
t.Fatalf("identical existing runtime snapshot must be idempotent: %v", err)
}
mock.ExpectRollback()
_ = tx.Rollback()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestRuntimeHostSalaryPolicySnapshotRejectsDifferentContent(t *testing.T) {
item := model.HostAgencySalaryPolicy{
Name: "August policy", RegionID: 25, CycleKey: "2026-08", Status: policyStatusActive,
SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0",
CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month",
}
existing := runtimeHostSalaryPolicySnapshot{
Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81, Status: policyStatusActive,
SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month",
PlatformWithdrawalLimitCount: 2,
}
if runtimeHostSalaryPolicyMatches(existing, item, 81) {
t.Fatal("different withdrawal limit content must never be accepted as an idempotent snapshot")
}
}
func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
gormDB, err := gorm.Open(gormMySQL.New(gormMySQL.Config{Conn: db, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
if err != nil {
t.Fatalf("open gorm: %v", err)
}
store := repository.New(gormDB)
mock.ExpectExec("UPDATE .*admin_host_agency_salary_policies.*publish_status <> \\? AND published_at_ms = 0").
WillReturnResult(sqlmock.NewResult(0, 0))
if err := store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished("fami", 91, "duplicate publish", 7); err != nil {
t.Fatalf("conditional failure update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("failure update must retain the published guard: %v", err)
}
}

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

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

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,32 +169,36 @@ func (h *Handler) applyFinanceCoinSellerOverview(ctx context.Context, appCode st
func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) {
stats := financeCoinSellerRechargeStats{Daily: map[string]rechargeBillSummaryBucketDTO{}}
rechargeType := normalizedRechargeType(query.RechargeType)
if rechargeType != "" && rechargeType != "coin_seller" {
rechargeSource, coinSellerType := financeCoinSellerRechargeSource(query.RechargeType)
if !coinSellerType {
return stats, nil
}
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return stats, nil
}
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil {
paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query)
if err != nil {
return stats, err
}
// Aslan legacy H5 的 SUCCESS/FREIGHT_GOLD Mongo 订单是支付账单事实;
// 这里只合并该事实,不读取 WebConsole 手填金额或钱包发货流水。
stats.addBucket(paidStats.Total)
for date, bucket := range paidStats.Daily {
stats.addDaily(date, bucket)
}
for _, region := range paidStats.Regions {
stats.addRegion(region.RegionID, region.BillCount, region.UsdMinorAmount)
if rechargeSource != repository.CoinSellerRechargeSourceUSDT {
paidSource := h.financePaidCoinSellerRechargeSource(appCode)
if paidSource != nil {
paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query)
if err != nil {
return stats, err
}
// Aslan legacy H5 的 SUCCESS/FREIGHT_GOLD Mongo 订单是支付账单事实;
// 这里只合并该事实,不读取 WebConsole 手填金额或钱包发货流水。
stats.addBucket(paidStats.Total)
for date, bucket := range paidStats.Daily {
stats.addDaily(date, bucket)
}
for _, region := range paidStats.Regions {
stats.addRegion(region.RegionID, region.BillCount, region.UsdMinorAmount)
}
}
}
if h != nil && h.store != nil {
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode,
RechargeSource: rechargeSource,
RegionID: query.RegionID,
TargetUserID: query.TargetUserID,
Keyword: query.Keyword,
@ -185,7 +221,7 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
stats.addRegion(row.RegionID, row.Count, row.USDMinor)
}
}
if h != nil && h.walletDB != nil {
if rechargeSource != repository.CoinSellerRechargeSourceUSDT && h != nil && h.walletDB != nil {
if err := h.addH5CoinSellerRechargeStats(ctx, appCode, query, &stats); err != nil {
return stats, err
}
@ -293,6 +329,10 @@ func (h *Handler) addH5CoinSellerRechargeStats(ctx context.Context, appCode stri
}
func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) {
rechargeSource, coinSellerType := financeCoinSellerRechargeSource(query.RechargeType)
if !coinSellerType {
return []rechargeBillDTO{}, 0, nil
}
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return []rechargeBillDTO{}, 0, nil
}
@ -301,27 +341,31 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*2)
var total int64
if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil {
paidItems, paidTotal, err := paidSource.listFinancePaidCoinSellerRechargeBills(ctx, query, prefetch)
if err != nil {
return nil, 0, err
if rechargeSource != repository.CoinSellerRechargeSourceUSDT {
paidSource := h.financePaidCoinSellerRechargeSource(appCode)
if paidSource != nil {
paidItems, paidTotal, err := paidSource.listFinancePaidCoinSellerRechargeBills(ctx, query, prefetch)
if err != nil {
return nil, 0, err
}
// 真实 H5 账单先进入结果;若同一个支付订单后来又被后台绑定,去重时保留原始账单金额和商品金币。
items = append(items, paidItems...)
total += paidTotal
}
// 真实 H5 账单先进入结果;若同一个支付订单后来又被后台绑定,去重时保留原始账单金额和商品金币。
items = append(items, paidItems...)
total += paidTotal
}
if h != nil && h.store != nil {
orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{
Page: 1,
PageSize: prefetch,
PageSizeLimit: prefetch,
AppCode: appCode,
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
Keyword: query.Keyword,
TargetUserID: query.TargetUserID,
RegionID: query.RegionID,
CreatedFromMS: query.StartAtMS,
CreatedToMS: query.EndAtMS,
Page: 1,
PageSize: prefetch,
PageSizeLimit: prefetch,
AppCode: appCode,
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
RechargeSource: rechargeSource,
Keyword: query.Keyword,
TargetUserID: query.TargetUserID,
RegionID: query.RegionID,
CreatedFromMS: query.StartAtMS,
CreatedToMS: query.EndAtMS,
})
if err != nil {
return nil, 0, err
@ -334,7 +378,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
items = append(items, financeCoinSellerRechargeOrderBill(order))
}
}
if h != nil && h.walletDB != nil {
if rechargeSource != repository.CoinSellerRechargeSourceUSDT && h != nil && h.walletDB != nil {
h5Items, h5Total, err := h.listH5CoinSellerRechargeBills(ctx, appCode, query, prefetch)
if err != nil {
return nil, 0, err
@ -450,7 +494,7 @@ func (h *Handler) listFinanceAllRechargeBills(ctx context.Context, appCode strin
}
coinItems, coinTotal, err := h.listFinanceCoinSellerRechargeBills(ctx, appCode, financeCoinSellerRechargeQuery{
RechargeType: "coin_seller",
RechargeType: financeCoinSellerRechargeType,
Status: query.Status,
PaidState: query.PaidState,
Keyword: query.Keyword,
@ -575,7 +619,8 @@ func (h *Handler) listH5CoinSellerRechargeBills(ctx context.Context, appCode str
return nil, 0, err
}
item.TransactionID = firstNonEmptyPaymentString(walletTransactionID, item.ExternalRef)
item.RechargeType = "coin_seller"
// H5 币商身份订单通过支付渠道完成,属于币商-三方;只有运营台账 provider_code=usdt 才进入币商-USDT。
item.RechargeType = financeCoinSellerThirdPartyType
item.Status = "succeeded"
item.TargetRegionID = item.SellerRegionID
item.PolicyID = productID
@ -609,7 +654,7 @@ func financeCoinSellerRechargeOrderBill(order model.CoinSellerRechargeOrder) rec
AppCode: order.AppCode,
TransactionID: firstNonEmptyPaymentString(order.WalletTransactionID, fmt.Sprintf("coin-seller-order:%d", order.ID)),
CommandID: order.WalletCommandID,
RechargeType: "coin_seller",
RechargeType: financeCoinSellerBillType(order.ProviderCode),
Status: "succeeded",
ExternalRef: firstNonEmptyPaymentString(order.ProviderOrderID, order.ExternalOrderNo),
SellerUserID: order.TargetUserID,

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,29 +16,37 @@ var (
ErrCoinSellerRechargeOrderGranted = errors.New("coin seller recharge order already granted")
)
const (
// RechargeSource 只描述运营录入币商充值时的付款大类provider_code 仍保留具体支付商,不能用展示分组覆盖审计事实。
CoinSellerRechargeSourceThirdParty = "third_party"
CoinSellerRechargeSourceUSDT = "usdt"
)
// coinSellerRechargeBusinessTimeSQL 让新订单按运营选择的业务时间归档,同时保持历史 order_date_ms=0 数据仍按录入时间查询。
const coinSellerRechargeBusinessTimeSQL = "CASE WHEN order_date_ms > 0 THEN order_date_ms ELSE created_at_ms END"
type CoinSellerRechargeOrderListOptions struct {
Page int
PageSize int
PageSizeLimit int
AppCode string
Status string
VerifyStatus string
GrantStatus string
ProviderCode string
Keyword string
TargetUserID int64
RegionID int64
OperatorID uint
CreatedFromMS int64
CreatedToMS int64
Page int
PageSize int
PageSizeLimit int
AppCode string
Status string
VerifyStatus string
GrantStatus string
ProviderCode string
RechargeSource string
Keyword string
TargetUserID int64
RegionID int64
OperatorID uint
CreatedFromMS int64
CreatedToMS int64
}
type CoinSellerRechargeOrderStatsOptions struct {
AppCode string
Status string
RechargeSource string
Keyword string
TargetUserID int64
RegionID int64
@ -368,6 +376,7 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha
if providerCode := strings.TrimSpace(options.ProviderCode); providerCode != "" && providerCode != "all" {
query = query.Where("provider_code = ?", providerCode)
}
query = applyCoinSellerRechargeSourceFilter(query, options.RechargeSource)
if options.TargetUserID > 0 {
query = query.Where("target_user_id = ?", options.TargetUserID)
}
@ -399,6 +408,7 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
query = query.Where("status = ?", status)
}
query = applyCoinSellerRechargeSourceFilter(query, options.RechargeSource)
if options.TargetUserID > 0 {
query = query.Where("target_user_id = ?", options.TargetUserID)
}
@ -421,6 +431,19 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller
return query
}
// applyCoinSellerRechargeSourceFilter 把财务展示分组还原到台账的权威 provider_code。
// 当前创建入口只允许 mifapay/v5pay/usdt使用等值或 IN 条件可以继续利用 provider_code 索引,避免为了筛选扫描并在 Go 内丢弃整页数据。
func applyCoinSellerRechargeSourceFilter(query *gorm.DB, rechargeSource string) *gorm.DB {
switch strings.ToLower(strings.TrimSpace(rechargeSource)) {
case CoinSellerRechargeSourceThirdParty:
return query.Where("provider_code IN ?", []string{"mifapay", "v5pay"})
case CoinSellerRechargeSourceUSDT:
return query.Where("provider_code = ?", "usdt")
default:
return query
}
}
func (s *Store) scanCoinSellerRechargeOrderDailyStats(options CoinSellerRechargeOrderStatsOptions, includeAllVerified bool, out map[string]CoinSellerRechargeOrderStatsBucket) error {
if out == nil {
return nil

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",
@ -34,12 +34,13 @@ func TestCoinSellerRechargeOrderStatsAggregatesVerifiedAndGrantedOverlap(t *test
AddRow(int64(101), int64(91), int64(2), int64(2_000), int64(20_000)))
stats, err := store.CoinSellerRechargeOrderStats(CoinSellerRechargeOrderStatsOptions{
AppCode: "lalu",
RegionID: 101,
TargetUserID: 9001,
Keyword: "merchant-1",
CreatedFromMS: 1_783_468_800_000,
CreatedToMS: 1_783_555_200_000,
AppCode: "lalu",
RechargeSource: CoinSellerRechargeSourceUSDT,
RegionID: 101,
TargetUserID: 9001,
Keyword: "merchant-1",
CreatedFromMS: 1_783_468_800_000,
CreatedToMS: 1_783_555_200_000,
})
if err != nil {
t.Fatalf("CoinSellerRechargeOrderStats failed: %v", err)

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
return item, err
if err != nil {
return item, err
}
if err := s.loadHostAgencySalaryPolicyRegions(s.db, []*model.HostAgencySalaryPolicy{&item}); err != nil {
return model.HostAgencySalaryPolicy{}, err
}
return item, nil
}
// ListHostAgencySalaryPolicyRegionIDsForCycle 返回目标月已经被任意显式或自动政策占用的区域。
// region_ids 来自单份政策的有界集合,查询走关联表 (region_id, policy_id) 主键/索引,不扫描工资或钱包流水。
func (s *Store) ListHostAgencySalaryPolicyRegionIDsForCycle(appCode string, cycleKey string, regionIDs []int64) ([]int64, error) {
if len(regionIDs) == 0 {
return nil, nil
}
var covered []int64
err := s.db.Model(&model.HostAgencySalaryPolicyRegion{}).
Distinct("admin_host_agency_salary_policy_regions.region_id").
Joins("JOIN admin_host_agency_salary_policies policy ON policy.id = admin_host_agency_salary_policy_regions.policy_id").
Where("policy.app_code = ? AND policy.cycle_key = ? AND admin_host_agency_salary_policy_regions.region_id IN ?", strings.TrimSpace(appCode), strings.TrimSpace(cycleKey), regionIDs).
Order("admin_host_agency_salary_policy_regions.region_id ASC").
Pluck("admin_host_agency_salary_policy_regions.region_id", &covered).Error
return covered, err
}
// ListPublishedActiveHostAgencySalaryPoliciesForCycle 返回可安全继承的完整不可变版本。
// 只复制已成功发布且仍启用的政策,避免把草稿或失败配置悄悄带入新工资月。
func (s *Store) ListPublishedActiveHostAgencySalaryPoliciesForCycle(appCode string, cycleKey string) ([]model.HostAgencySalaryPolicy, error) {
var items []model.HostAgencySalaryPolicy
err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
return tx.Order("level_no ASC")
}).Where(
"app_code = ? AND cycle_key = ? AND status = ? AND publish_status = ?",
strings.TrimSpace(appCode), strings.TrimSpace(cycleKey), "active", "published",
).Order("id ASC").Find(&items).Error
if err != nil {
return nil, err
}
refs := make([]*model.HostAgencySalaryPolicy, len(items))
for index := range items {
refs[index] = &items[index]
}
if err := s.loadHostAgencySalaryPolicyRegions(s.db, refs); err != nil {
return nil, err
}
return items, nil
}
func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error {
@ -80,6 +143,12 @@ func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy)
if err := tx.Model(item).UpdateColumn("policy_version", item.PolicyVersion).Error; err != nil {
return err
}
if err := replaceHostAgencySalaryPolicyRegions(tx, item.ID, item.RegionIDs); err != nil {
return err
}
if err := replaceHostAgencySalaryPolicyActiveScopes(tx, item); err != nil {
return err
}
for index := range levels {
levels[index].PolicyID = item.ID
}
@ -102,6 +171,12 @@ func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy)
if err := tx.Save(item).Error; err != nil {
return err
}
if err := replaceHostAgencySalaryPolicyRegions(tx, item.ID, item.RegionIDs); err != nil {
return err
}
if err := replaceHostAgencySalaryPolicyActiveScopes(tx, item); err != nil {
return err
}
if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil {
return err
}
@ -133,9 +208,28 @@ func (s *Store) UpdateHostAgencySalaryPolicyPublishState(appCode string, id uint
}).Error
}
func (s *Store) UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished(appCode string, id uint, publishError string, actorID uint) error {
// wallet 提交与 admin 状态回写跨库,失败回写必须带状态条件:并发成功发布已经写入 published 后,
// 较晚返回的重复发布错误不能把真实成功状态覆盖成 failed也不能清空成功时间。
return s.db.Model(&model.HostAgencySalaryPolicy{}).
Where("app_code = ? AND id = ? AND publish_status <> ? AND published_at_ms = 0", strings.TrimSpace(appCode), id, "published").
Updates(map[string]any{
"publish_status": "failed",
"publish_error": strings.TrimSpace(publishError),
"published_by_admin_id": actorID,
"updated_by_admin_id": actorID,
}).Error
}
func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 先删明细再删主表,兼容没有外键级联的部署环境。
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryPolicyActiveScope{}).Error; err != nil {
return err
}
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryPolicyRegion{}).Error; err != nil {
return err
}
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil {
return err
}
@ -144,9 +238,10 @@ func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error {
}
func (s *Store) HasActiveHostAgencySalaryPolicyForCycle(appCode string, regionID int64, cycleKey string, excludeID uint) (bool, error) {
// 工资政策以周期为唯一选择边界;不同月份允许并存,同一区域同一月份只能有一条 active 配置
// 工资政策以周期为唯一选择边界;关联表索引先按区域收敛,再由主表校验 App、周期和状态
db := s.db.Model(&model.HostAgencySalaryPolicy{}).
Where("app_code = ? AND region_id = ? AND cycle_key = ? AND status = ?", strings.TrimSpace(appCode), regionID, strings.TrimSpace(cycleKey), "active")
Joins("JOIN admin_host_agency_salary_policy_regions policy_region ON policy_region.policy_id = admin_host_agency_salary_policies.id").
Where("admin_host_agency_salary_policies.app_code = ? AND policy_region.region_id = ? AND admin_host_agency_salary_policies.cycle_key = ? AND admin_host_agency_salary_policies.status = ?", strings.TrimSpace(appCode), regionID, strings.TrimSpace(cycleKey), "active")
if excludeID > 0 {
db = db.Where("id <> ?", excludeID)
}
@ -156,3 +251,70 @@ func (s *Store) HasActiveHostAgencySalaryPolicyForCycle(appCode string, regionID
}
return count > 0, nil
}
func (s *Store) loadHostAgencySalaryPolicyRegions(db *gorm.DB, items []*model.HostAgencySalaryPolicy) error {
if len(items) == 0 {
return nil
}
policyIDs := make([]uint, 0, len(items))
byPolicyID := make(map[uint]int, len(items))
for index := range items {
policyIDs = append(policyIDs, items[index].ID)
byPolicyID[items[index].ID] = index
}
var rows []model.HostAgencySalaryPolicyRegion
// 映射表按 policy_id 主键前缀批量读取,列表页只额外增加一次有界查询,避免 N+1。
if err := db.Where("policy_id IN ?", policyIDs).Order("policy_id ASC, region_id ASC").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if index, ok := byPolicyID[row.PolicyID]; ok {
items[index].RegionIDs = append(items[index].RegionIDs, row.RegionID)
}
}
for index := range items {
if len(items[index].RegionIDs) == 0 && items[index].RegionID > 0 {
items[index].RegionIDs = []int64{items[index].RegionID}
}
}
return nil
}
func replaceHostAgencySalaryPolicyRegions(tx *gorm.DB, policyID uint, regionIDs []int64) error {
if err := tx.Where("policy_id = ?", policyID).Delete(&model.HostAgencySalaryPolicyRegion{}).Error; err != nil {
return err
}
rows := make([]model.HostAgencySalaryPolicyRegion, 0, len(regionIDs))
nowMS := time.Now().UTC().UnixMilli()
for _, regionID := range regionIDs {
rows = append(rows, model.HostAgencySalaryPolicyRegion{PolicyID: policyID, RegionID: regionID, CreatedAtMS: nowMS})
}
return tx.Create(&rows).Error
}
func replaceHostAgencySalaryPolicyActiveScopes(tx *gorm.DB, item *model.HostAgencySalaryPolicy) error {
// 先释放当前政策旧占位;删除锁会一直持有到事务提交,后续任一新区域冲突都会让整个事务回滚,
// 因而 active->disabled、区域变更和创建都不会暴露半套占位。
if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryPolicyActiveScope{}).Error; err != nil {
return err
}
if strings.TrimSpace(item.Status) != "active" {
return nil
}
nowMS := time.Now().UTC().UnixMilli()
rows := make([]model.HostAgencySalaryPolicyActiveScope, 0, len(item.RegionIDs))
for _, regionID := range item.RegionIDs {
rows = append(rows, model.HostAgencySalaryPolicyActiveScope{
AppCode: item.AppCode, RegionID: regionID, CycleKey: item.CycleKey, PolicyID: item.ID,
CreatedAtMS: nowMS, UpdatedAtMS: nowMS,
})
}
if err := tx.Create(&rows).Error; err != nil {
var mysqlErr *mysqlDriver.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return ErrHostAgencySalaryPolicyActiveScopeConflict
}
return err
}
return nil
}

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"
@ -18,7 +19,11 @@ import (
)
const (
pointWalletAssetType = "POINT"
pointWalletAssetType = "POINT"
pointWalletTargetAssetType = "COIN"
pointWithdrawalRollbackAttempts = 3
pointWithdrawalRollbackAttemptTimeout = 1500 * time.Millisecond
pointWithdrawalRollbackBackoffBaseDelay = 100 * time.Millisecond
)
type pointWithdrawalGatewayClient interface {
@ -61,6 +66,17 @@ type pointWalletBalanceData struct {
DisplayUSD string `json:"display_usd"`
}
type pointWalletExchangeConfigData struct {
SourceAssetType string `json:"source_asset_type"`
TargetAssetType string `json:"target_asset_type"`
PointsPerUSD int64 `json:"points_per_usd"`
CoinsPerUSD int64 `json:"coins_per_usd"`
RatioPointAmount int64 `json:"ratio_point_amount"`
RatioCoinAmount int64 `json:"ratio_coin_amount"`
RoundingMode string `json:"rounding_mode"`
PolicyInstanceCode string `json:"policy_instance_code"`
}
type pointWalletWithdrawBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
@ -242,6 +258,51 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
})
}
// getPointWalletExchangeConfig 只返回 Flutter 预览兑换所需的最小运行合同;
// region 从当前登录用户资料解析,客户端不能通过自报地区选择其他政策。
func (h *Handler) getPointWalletExchangeConfig(writer http.ResponseWriter, request *http.Request) {
if !isPointWalletRequest(request) {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return
}
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
if !ok || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userID := auth.UserIDFromContext(request.Context())
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""), UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if profileResp.GetUser() == nil {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
return
}
config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
RegionId: profileResp.GetUser().GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if !config.GetFound() || config.GetPointsPerUsd() <= 0 || config.GetCoinsPerUsd() <= 0 {
// 没有完整已发布政策时不向 App 暴露伪造默认比例,兑换写入口也会在 wallet-service 拒绝。
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point exchange policy is not configured")
return
}
httpkit.WriteOK(writer, request, pointWalletExchangeConfigData{
SourceAssetType: pointWalletAssetType, TargetAssetType: pointWalletTargetAssetType,
PointsPerUSD: config.GetPointsPerUsd(), CoinsPerUSD: config.GetCoinsPerUsd(),
RatioPointAmount: config.GetPointsPerUsd(), RatioCoinAmount: config.GetCoinsPerUsd(),
RoundingMode: "floor", PolicyInstanceCode: config.GetPolicyInstanceCode(),
})
}
func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.Request) {
if !isPointWalletRequest(request) {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
@ -292,27 +353,13 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
}
feePoints := grossPoints * int64(config.GetFeeBps()) / 10000
netPoints := grossPoints - feePoints
balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: userID,
AssetTypes: []string{pointWalletAssetType},
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
balance := firstBalance(balanceResp.GetBalances())
if balance == nil || balance.GetAvailableAmount() < grossPoints {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "insufficient balance")
return
}
address, ok := h.resolveSalaryWalletWithdrawAddress(writer, request, userID, body.usdtTRC20Address())
if !ok {
return
}
nowMS := time.Now().UTC().UnixMilli()
freezeCommandID := pointWithdrawalFreezeCommandID(submittedCommandID, httpkit.RequestIDFromContext(request.Context()), nowMS)
freezeCommandID := strings.TrimSpace(submittedCommandID)
withdrawalRef := pointWithdrawalReference(freezeCommandID)
freezeResp, err := pointWallet.FreezePointWithdrawal(request.Context(), &walletv1.FreezePointWithdrawalRequest{
CommandId: freezeCommandID,
UserId: userID,
@ -324,22 +371,30 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
FeeBps: config.GetFeeBps(),
Reason: "point withdrawal submitted",
AppCode: appcode.FromContext(request.Context()),
WithdrawalRef: httpkit.RequestIDFromContext(request.Context()),
WithdrawalRef: withdrawalRef,
RegionId: profile.GetRegionId(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
// 同一客户端 command_id 可能跨月、跨政策重试wallet 命中首次冻结交易时返回的是首次账变快照。
// 从这里开始只使用回执金额和比例,避免用本次配置创建不匹配的申请,或在失败补偿时释放错误金额。
frozenGrossPoints := freezeResp.GetGrossPointAmount()
frozenFeePoints := freezeResp.GetFeePointAmount()
frozenNetPoints := freezeResp.GetNetPointAmount()
frozenPointsPerUSD := freezeResp.GetPointsPerUsd()
frozenFeeBPS := freezeResp.GetFeeBps()
application, err := h.withdrawalWriter.CreateApplication(request.Context(), financewithdrawal.CreateApplicationCommand{
AppCode: appcode.FromContext(request.Context()),
UserID: userID,
SalaryAssetType: pointWalletAssetType,
WithdrawAmount: formatPointUSD(netPoints, config.GetPointsPerUsd()),
WithdrawAmountMinor: grossPoints,
PointFeeAmount: feePoints,
PointNetAmount: netPoints,
PointsPerUSD: config.GetPointsPerUsd(),
PointFeeBPS: config.GetFeeBps(),
WithdrawAmount: formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
WithdrawAmountMinor: frozenGrossPoints,
PointFeeAmount: frozenFeePoints,
PointNetAmount: frozenNetPoints,
PointsPerUSD: frozenPointsPerUSD,
PointFeeBPS: frozenFeeBPS,
PointPolicyInstance: config.GetPolicyInstanceCode(),
WithdrawMethod: financewithdrawal.MethodUSDTTRC20,
WithdrawAddress: address,
@ -348,7 +403,14 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
CreatedAtMS: nowMS,
})
if err != nil {
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, grossPoints, feePoints, netPoints, config.GetPointsPerUsd(), config.GetFeeBps())
if financewithdrawal.IsApplicationOutcomeUnknown(err) {
// 申请可能已经落库,只是 INSERT 响应和事实重查同时失败;保留冻结后,同一客户端
// command_id 的重试会先命中钱包幂等回执,再由 writer 重查申请并完成状态收敛。
slog.Error("point_withdrawal_application_outcome_unknown", "user_id", userID, "app_code", appcode.FromContext(request.Context()), "freeze_command_id", freezeCommandID, "withdrawal_ref", withdrawalRef, "error", err)
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, frozenGrossPoints, frozenFeePoints, frozenNetPoints, frozenPointsPerUSD, frozenFeeBPS)
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
@ -363,16 +425,16 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
"status": application.Status,
"operations_status": application.OperationsStatus,
"asset_type": pointWalletAssetType,
"gross_point_amount": grossPoints,
"fee_point_amount": feePoints,
"net_point_amount": netPoints,
"points_per_usd": config.GetPointsPerUsd(),
"fee_bps": config.GetFeeBps(),
"gross_point_amount": frozenGrossPoints,
"fee_point_amount": frozenFeePoints,
"net_point_amount": frozenNetPoints,
"points_per_usd": frozenPointsPerUSD,
"fee_bps": frozenFeeBPS,
"policy_instance_code": config.GetPolicyInstanceCode(),
"gross_usd": formatPointUSD(grossPoints, config.GetPointsPerUsd()),
"fee_usd": formatPointUSD(feePoints, config.GetPointsPerUsd()),
"net_usd": formatPointUSD(netPoints, config.GetPointsPerUsd()),
"balance_after": pointBalanceFromProto(freezeResp.GetBalance(), config.GetPointsPerUsd()),
"gross_usd": formatPointUSD(frozenGrossPoints, frozenPointsPerUSD),
"fee_usd": formatPointUSD(frozenFeePoints, frozenPointsPerUSD),
"net_usd": formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
"balance_after": pointBalanceFromProto(freezeResp.GetBalance(), frozenPointsPerUSD),
"withdraw_method": application.WithdrawMethod,
"withdraw_address": application.WithdrawAddress,
"freeze_transaction_id": freezeResp.GetTransactionId(),
@ -411,25 +473,19 @@ func formatPointUSD(points int64, pointsPerUSD int64) string {
return formatUSDMinor(usdMinor)
}
func pointWithdrawalFreezeCommandID(submittedCommandID string, requestID string, nowMS int64) string {
if value := strings.TrimSpace(submittedCommandID); value != "" {
return value
}
requestID = strings.TrimSpace(requestID)
if requestID != "" {
return "point-withdrawal:" + requestID
}
return fmt.Sprintf("point-withdrawal:%d", nowMS)
func pointWithdrawalReference(freezeCommandID string) string {
// request_id 只用于链路追踪,每次 HTTP 重试都会变化。对客户端幂等键做固定摘要,既让
// freeze/rollback 命中同一 reservation又把引用稳定限制在 VARCHAR(128) 边界内。
digest := sha256.Sum256([]byte(strings.TrimSpace(freezeCommandID)))
return "point-withdrawal:" + hex.EncodeToString(digest[:])
}
func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
rollbackCommandID := strings.TrimSpace(freezeCommandID) + ":rollback"
if len(rollbackCommandID) > 128 {
rollbackCommandID = fmt.Sprintf("point-withdrawal-rollback:%s", httpkit.RequestIDFromContext(request.Context()))
}
rollbackCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(request.Context())), 5*time.Second)
defer cancel()
_, err := walletClient.ReleasePointWithdrawal(rollbackCtx, &walletv1.ReleasePointWithdrawalRequest{
withdrawalRef := pointWithdrawalReference(freezeCommandID)
// 回滚事务也必须跨 HTTP 重试保持同一个 command_id直接由固定长度的 reservation 引用派生,
// 不会因为原始 command_id 接近 128 字节而退化为不稳定的 request_id。
rollbackCommandID := withdrawalRef + ":rollback"
rollbackRequest := &walletv1.ReleasePointWithdrawalRequest{
CommandId: rollbackCommandID,
UserId: userID,
AssetType: pointWalletAssetType,
@ -440,10 +496,23 @@ func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWith
FeeBps: feeBPS,
Reason: "point withdrawal application create failed",
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
slog.Error("point_withdrawal_freeze_rollback_failed", "user_id", userID, "gross_point_amount", grossPoints, "freeze_command_id", freezeCommandID, "error", err)
WithdrawalRef: withdrawalRef,
}
baseCtx := appcode.WithContext(context.Background(), appcode.FromContext(request.Context()))
var rollbackErr error
for attempt := 1; attempt <= pointWithdrawalRollbackAttempts; attempt++ {
// 每次 RPC 使用独立短超时;前一次连接结果不确定时,后续尝试携带相同幂等键只会确认或返回同一回执。
rollbackCtx, cancel := context.WithTimeout(baseCtx, pointWithdrawalRollbackAttemptTimeout)
_, rollbackErr = walletClient.ReleasePointWithdrawal(rollbackCtx, rollbackRequest)
cancel()
if rollbackErr == nil {
return
}
if attempt < pointWithdrawalRollbackAttempts {
time.Sleep(time.Duration(attempt) * pointWithdrawalRollbackBackoffBaseDelay)
}
}
slog.Error("point_withdrawal_freeze_rollback_failed", "user_id", userID, "gross_point_amount", grossPoints, "freeze_command_id", freezeCommandID, "withdrawal_ref", withdrawalRef, "attempts", pointWithdrawalRollbackAttempts, "error", rollbackErr)
}
// transferPointWalletToCoinSeller 只接收目标和 POINT 金额wallet 事务会重新读取 active 白名单与比例。
@ -508,6 +577,7 @@ func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, re
CommandId: commandID, AppCode: appcode.FromContext(request.Context()), SourceUserId: userID,
SellerUserId: sellerUserID, PointAmount: pointAmount,
SourceCountryCode: pointWalletCountryCode(profileResp.GetUser()), Reason: "point wallet withdrawal to coin seller",
RegionId: profileResp.GetUser().GetRegionId(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)

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

@ -30,18 +30,22 @@ type HostSalarySettlementBatchResult struct {
type HostSalaryPolicy struct {
PolicyID uint64
// CycleKey 与 PolicyVersion 共同标识已发布的不可变周期快照;结算只能按账户 cycle_key 精确命中。
CycleKey string
PolicyVersion uint64
Name string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
ResidualDiamondToUSDRate string
EffectiveFromMs int64
EffectiveToMs int64
Levels []HostSalaryPolicyLevel
CycleKey string
PolicyVersion uint64
Name string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
EffectiveFromMs int64
EffectiveToMs int64
Levels []HostSalaryPolicyLevel
}
// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。

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)