diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 62115bf5..fea28b2e 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -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" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 43e7bc98..9ab3e548 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -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 { diff --git a/docs/flutter对接/Fami积分兑换金币Flutter对接.md b/docs/flutter对接/Fami积分兑换金币Flutter对接.md new file mode 100644 index 00000000..6e7af5ad --- /dev/null +++ b/docs/flutter对接/Fami积分兑换金币Flutter对接.md @@ -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 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 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 成功回执更新当前页面,其他页面进入时重新读取钱包余额。 diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index d039a6e6..c35a0210 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -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"), diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index 176e30cb..dcde6742 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -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 等级当前不可购买。 diff --git a/pkg/xerr/grpc_test.go b/pkg/xerr/grpc_test.go index f6ae3e4c..ea2d1f17 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -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, diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 81a18593..60d56fb3 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -430,10 +430,12 @@ type HostAgencySalaryPolicy struct { ID uint `gorm:"primaryKey" json:"id"` AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null;default:lalu" json:"appCode"` Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"` - // RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。 + // RegionID 只保存 RegionIDs 中最小的兼容值;真实适用边界由关联表按用户区域匹配。 RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"regionId"` + // RegionIDs 是这份政策实际覆盖的区域集合;RegionID 只保留首个值用于旧客户端和旧索引兼容。 + RegionIDs []int64 `gorm:"-" json:"regionIds"` // CycleKey 是工资归属月;PolicyVersion 使用政策主键形成稳定版本号,发布后两者均不可修改。 - CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"` + CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"` PolicyVersion uint64 `gorm:"column:policy_version;uniqueIndex:uk_admin_host_agency_salary_policy_version;not null;default:0" json:"policyVersion"` Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"` // SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。 @@ -443,17 +445,25 @@ type HostAgencySalaryPolicy struct { // 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。 GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"` ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"` - // 时间范围保留为运营展示元数据;工资结算只按 CycleKey 绑定版本,不能再用执行时间选政策。 + // 提现次数限制随月度政策形成不可变快照;count=0 表示不限,period 只允许 day/week/month。 + CoinSellerWithdrawalLimitPeriod string `gorm:"column:coin_seller_withdrawal_limit_period;size:16;not null;default:month" json:"coinSellerWithdrawalLimitPeriod"` + CoinSellerWithdrawalLimitCount int64 `gorm:"column:coin_seller_withdrawal_limit_count;not null;default:0" json:"coinSellerWithdrawalLimitCount"` + PlatformWithdrawalLimitPeriod string `gorm:"column:platform_withdrawal_limit_period;size:16;not null;default:month" json:"platformWithdrawalLimitPeriod"` + PlatformWithdrawalLimitCount int64 `gorm:"column:platform_withdrawal_limit_count;not null;default:0" json:"platformWithdrawalLimitCount"` + // Effective* 仅保留兼容既有表结构;后台不再配置或展示,工资结算只按 CycleKey 绑定版本。 EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"` EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"` Description string `gorm:"size:255;not null;default:''" json:"description"` CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"` UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"` // 发布字段只记录 admin 配置同步到 wallet 运行快照的结果;结算仍只读取 wallet-service 自己的运行表。 - PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"` - PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"` - PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"` - PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"` + PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"` + PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"` + PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"` + PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"` + // CopiedFrom* 记录系统补齐缺失月份时的来源;新月份拥有独立 policy_version,后续编辑不会改动上月口径。 + CopiedFromPolicyID uint `gorm:"column:copied_from_policy_id;not null;default:0" json:"copiedFromPolicyId"` + CopiedFromCycleKey string `gorm:"column:copied_from_cycle_key;size:7;not null;default:''" json:"copiedFromCycleKey"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` Levels []HostAgencySalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"` @@ -463,6 +473,33 @@ func (HostAgencySalaryPolicy) TableName() string { return "admin_host_agency_salary_policies" } +// HostAgencySalaryPolicyRegion 把一份政策关联到多个区域,避免复制多份可独立漂移的等级配置。 +type HostAgencySalaryPolicyRegion struct { + PolicyID uint `gorm:"column:policy_id;primaryKey"` + RegionID int64 `gorm:"column:region_id;primaryKey;index:idx_host_agency_policy_region,priority:1"` + CreatedAtMS int64 `gorm:"column:created_at_ms;not null"` +} + +func (HostAgencySalaryPolicyRegion) TableName() string { + return "admin_host_agency_salary_policy_regions" +} + +// HostAgencySalaryPolicyActiveScope 是启用政策的数据库级区域/月占位。 +// 普通区域映射允许一份政策覆盖多区,但无法表达“同一区域同月只能有一份 active 政策”; +// 该表用业务主键把并发校验下沉到 MySQL,policy_id 索引用于政策更新、停用和删除时整包释放。 +type HostAgencySalaryPolicyActiveScope struct { + AppCode string `gorm:"column:app_code;size:32;primaryKey"` + RegionID int64 `gorm:"column:region_id;primaryKey"` + CycleKey string `gorm:"column:cycle_key;size:7;primaryKey"` + PolicyID uint `gorm:"column:policy_id;index:idx_host_agency_salary_policy_active_scope_policy,priority:1;not null"` + CreatedAtMS int64 `gorm:"column:created_at_ms;not null"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null"` +} + +func (HostAgencySalaryPolicyActiveScope) TableName() string { + return "admin_host_agency_salary_policy_active_scopes" +} + type HostAgencySalaryLevel struct { ID uint `gorm:"primaryKey" json:"id"` PolicyID uint `gorm:"uniqueIndex:uk_admin_host_agency_salary_level;index:idx_admin_host_agency_salary_level_policy,not null" json:"policyId"` diff --git a/server/admin/internal/modules/hostagencypolicy/handler.go b/server/admin/internal/modules/hostagencypolicy/handler.go index 65cb6950..80b1a6f6 100644 --- a/server/admin/internal/modules/hostagencypolicy/handler.go +++ b/server/admin/internal/modules/hostagencypolicy/handler.go @@ -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) } diff --git a/server/admin/internal/modules/hostagencypolicy/request.go b/server/admin/internal/modules/hostagencypolicy/request.go index 40f6181d..d4aecd50 100644 --- a/server/admin/internal/modules/hostagencypolicy/request.go +++ b/server/admin/internal/modules/hostagencypolicy/request.go @@ -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 { diff --git a/server/admin/internal/modules/hostagencypolicy/response.go b/server/admin/internal/modules/hostagencypolicy/response.go index 9060dd63..6e44620e 100644 --- a/server/admin/internal/modules/hostagencypolicy/response.go +++ b/server/admin/internal/modules/hostagencypolicy/response.go @@ -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) diff --git a/server/admin/internal/modules/hostagencypolicy/service.go b/server/admin/internal/modules/hostagencypolicy/service.go index 4292ffb0..d1f6e5dc 100644 --- a/server/admin/internal/modules/hostagencypolicy/service.go +++ b/server/admin/internal/modules/hostagencypolicy/service.go @@ -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("请选择工资周期") } diff --git a/server/admin/internal/modules/hostagencypolicy/service_test.go b/server/admin/internal/modules/hostagencypolicy/service_test.go index 09e191b3..77670340 100644 --- a/server/admin/internal/modules/hostagencypolicy/service_test.go +++ b/server/admin/internal/modules/hostagencypolicy/service_test.go @@ -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) + } +} diff --git a/server/admin/internal/modules/hostsalarysettlement/service.go b/server/admin/internal/modules/hostsalarysettlement/service.go index 7b207f28..aa1eca18 100644 --- a/server/admin/internal/modules/hostsalarysettlement/service.go +++ b/server/admin/internal/modules/hostsalarysettlement/service.go @@ -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...) diff --git a/server/admin/internal/modules/payment/aslan_paid_coin_seller_source.go b/server/admin/internal/modules/payment/aslan_paid_coin_seller_source.go index 3193df27..e0487d3b 100644 --- a/server/admin/internal/modules/payment/aslan_paid_coin_seller_source.go +++ b/server/admin/internal/modules/payment/aslan_paid_coin_seller_source.go @@ -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, diff --git a/server/admin/internal/modules/payment/aslan_paid_coin_seller_source_test.go b/server/admin/internal/modules/payment/aslan_paid_coin_seller_source_test.go index bb0274c5..2bf0fe0b 100644 --- a/server/admin/internal/modules/payment/aslan_paid_coin_seller_source_test.go +++ b/server/admin/internal/modules/payment/aslan_paid_coin_seller_source_test.go @@ -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, } diff --git a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go index 7a52d287..58ef3f74 100644 --- a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go @@ -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, diff --git a/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go b/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go index d580a5df..6f7c528f 100644 --- a/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go @@ -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) { diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index d077d440..a8d13b68 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -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 diff --git a/server/admin/internal/modules/payment/recharge_bill_export.go b/server/admin/internal/modules/payment/recharge_bill_export.go index 23aefd6c..4d844d79 100644 --- a/server/admin/internal/modules/payment/recharge_bill_export.go +++ b/server/admin/internal/modules/payment/recharge_bill_export.go @@ -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": diff --git a/server/admin/internal/modules/payment/recharge_bill_overview.go b/server/admin/internal/modules/payment/recharge_bill_overview.go index 45952cb7..9cb010da 100644 --- a/server/admin/internal/modules/payment/recharge_bill_overview.go +++ b/server/admin/internal/modules/payment/recharge_bill_overview.go @@ -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 diff --git a/server/admin/internal/modules/payment/recharge_bill_stats.go b/server/admin/internal/modules/payment/recharge_bill_stats.go index 5a6ebfe7..269d8951 100644 --- a/server/admin/internal/modules/payment/recharge_bill_stats.go +++ b/server/admin/internal/modules/payment/recharge_bill_stats.go @@ -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, diff --git a/server/admin/internal/repository/coin_seller_recharge_order_repository.go b/server/admin/internal/repository/coin_seller_recharge_order_repository.go index e7c90935..061763a0 100644 --- a/server/admin/internal/repository/coin_seller_recharge_order_repository.go +++ b/server/admin/internal/repository/coin_seller_recharge_order_repository.go @@ -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 diff --git a/server/admin/internal/repository/coin_seller_recharge_order_repository_test.go b/server/admin/internal/repository/coin_seller_recharge_order_repository_test.go index 3886b52a..ad0debfe 100644 --- a/server/admin/internal/repository/coin_seller_recharge_order_repository_test.go +++ b/server/admin/internal/repository/coin_seller_recharge_order_repository_test.go @@ -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) diff --git a/server/admin/internal/repository/host_agency_salary_policy_repository.go b/server/admin/internal/repository/host_agency_salary_policy_repository.go index b46cf0e3..a662bce7 100644 --- a/server/admin/internal/repository/host_agency_salary_policy_repository.go +++ b/server/admin/internal/repository/host_agency_salary_policy_repository.go @@ -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 +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 84650eb2..7d6807a0 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -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{}, diff --git a/server/admin/migrations/110_host_agency_salary_policy_multi_region.sql b/server/admin/migrations/110_host_agency_salary_policy_multi_region.sql new file mode 100644 index 00000000..6889c838 --- /dev/null +++ b/server/admin/migrations/110_host_agency_salary_policy_multi_region.sql @@ -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 + ); diff --git a/server/admin/migrations/111_host_salary_policy_month_copy.sql b/server/admin/migrations/111_host_salary_policy_month_copy.sql new file mode 100644 index 00000000..d9f7af12 --- /dev/null +++ b/server/admin/migrations/111_host_salary_policy_month_copy.sql @@ -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; diff --git a/server/admin/migrations/112_host_salary_policy_withdrawal_limits.sql b/server/admin/migrations/112_host_salary_policy_withdrawal_limits.sql new file mode 100644 index 00000000..82f37e01 --- /dev/null +++ b/server/admin/migrations/112_host_salary_policy_withdrawal_limits.sql @@ -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; diff --git a/services/gateway-service/internal/financewithdrawal/writer.go b/services/gateway-service/internal/financewithdrawal/writer.go index 75fac609..94e2f83a 100644 --- a/services/gateway-service/internal/financewithdrawal/writer.go +++ b/services/gateway-service/internal/financewithdrawal/writer.go @@ -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)) diff --git a/services/gateway-service/internal/financewithdrawal/writer_test.go b/services/gateway-service/internal/financewithdrawal/writer_test.go index 36a0184a..656b4194 100644 --- a/services/gateway-service/internal/financewithdrawal/writer_test.go +++ b/services/gateway-service/internal/financewithdrawal/writer_test.go @@ -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", diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 0bec57a4..68d8beca 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index ba758987..d51d1080 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/walletapi/handler.go b/services/gateway-service/internal/transport/http/walletapi/handler.go index 1d6e7e57..5f9b031d 100644 --- a/services/gateway-service/internal/transport/http/walletapi/handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/handler.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go index 3bea9d2b..cfa5ada7 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go index 9c78d89a..22d29af2 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go @@ -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 } diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 8b469a90..d1d6129b 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -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' diff --git a/services/wallet-service/deploy/mysql/migrations/005_host_salary_policy_multi_region_inheritance.sql b/services/wallet-service/deploy/mysql/migrations/005_host_salary_policy_multi_region_inheritance.sql new file mode 100644 index 00000000..a9fcfed9 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/005_host_salary_policy_multi_region_inheritance.sql @@ -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; diff --git a/services/wallet-service/deploy/mysql/migrations/007_host_salary_policy_binding_source.sql b/services/wallet-service/deploy/mysql/migrations/007_host_salary_policy_binding_source.sql new file mode 100644 index 00000000..0da39d53 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/007_host_salary_policy_binding_source.sql @@ -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 = ''; diff --git a/services/wallet-service/deploy/mysql/migrations/008_host_salary_policy_withdrawal_limits.sql b/services/wallet-service/deploy/mysql/migrations/008_host_salary_policy_withdrawal_limits.sql new file mode 100644 index 00000000..124e5c44 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/008_host_salary_policy_withdrawal_limits.sql @@ -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='平台积分提现次数预留表'; diff --git a/services/wallet-service/internal/domain/ledger/host_salary.go b/services/wallet-service/internal/domain/ledger/host_salary.go index 5d3f9f4d..5df51e4f 100644 --- a/services/wallet-service/internal/domain/ledger/host_salary.go +++ b/services/wallet-service/internal/domain/ledger/host_salary.go @@ -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 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。 diff --git a/services/wallet-service/internal/domain/ledger/point_coin_seller.go b/services/wallet-service/internal/domain/ledger/point_coin_seller.go index ab112a33..c6716c03 100644 --- a/services/wallet-service/internal/domain/ledger/point_coin_seller.go +++ b/services/wallet-service/internal/domain/ledger/point_coin_seller.go @@ -21,6 +21,7 @@ type PointToCoinSellerCommand struct { SellerUserID int64 PointAmount int64 SourceCountryCode string + RegionID int64 Reason string } diff --git a/services/wallet-service/internal/domain/ledger/salary_withdrawal.go b/services/wallet-service/internal/domain/ledger/salary_withdrawal.go index abdedced..1470b709 100644 --- a/services/wallet-service/internal/domain/ledger/salary_withdrawal.go +++ b/services/wallet-service/internal/domain/ledger/salary_withdrawal.go @@ -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 钱包冻结/扣冻/释放后的余额和费用快照。 diff --git a/services/wallet-service/internal/service/wallet/point_coin_seller.go b/services/wallet-service/internal/service/wallet/point_coin_seller.go index 83cb3ec8..ea3188d8 100644 --- a/services/wallet-service/internal/service/wallet/point_coin_seller.go +++ b/services/wallet-service/internal/service/wallet/point_coin_seller.go @@ -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) diff --git a/services/wallet-service/internal/service/wallet/point_withdrawal.go b/services/wallet-service/internal/service/wallet/point_withdrawal.go index eadf97e3..c25a2c94 100644 --- a/services/wallet-service/internal/service/wallet/point_withdrawal.go +++ b/services/wallet-service/internal/service/wallet/point_withdrawal.go @@ -16,7 +16,7 @@ const ( // FreezePointWithdrawal 把已启用 App 的收益积分从 available 冻结到 frozen;gateway 必须先读取已发布政策快照。 func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { - if err := normalizePointWithdrawalCommand(&command, false); err != nil { + if err := normalizePointWithdrawalCommand(&command, false, true); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { @@ -36,7 +36,7 @@ func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.Poin // SettlePointWithdrawal 在财务审核通过时扣掉 frozen 积分;冻结时已扣 available,因此这里不能再次扣 available。 func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { - if err := normalizePointWithdrawalCommand(&command, true); err != nil { + if err := normalizePointWithdrawalCommand(&command, true, false); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { @@ -56,7 +56,7 @@ func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.Poin // ReleasePointWithdrawal 在审核拒绝或申请落库失败时把 frozen 积分返还 available,保证用户不会被永久冻结。 func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { - if err := normalizePointWithdrawalCommand(&command, false); err != nil { + if err := normalizePointWithdrawalCommand(&command, false, false); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { @@ -74,13 +74,16 @@ func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.Poi return pointWithdrawalReceiptFromSalary(receipt), nil } -func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool) error { +func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool, requireRegion bool) error { if command == nil || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.GrossPointAmount <= 0 { return xerr.New(xerr.InvalidArgument, "point withdrawal command is incomplete") } if requireOperator && command.OperatorUserID <= 0 { return xerr.New(xerr.InvalidArgument, "operator_user_id is required") } + if requireRegion && command.RegionID <= 0 { + return xerr.New(xerr.InvalidArgument, "region_id is required") + } command.CommandID = strings.TrimSpace(command.CommandID) if strings.TrimSpace(command.AppCode) == "" { return xerr.New(xerr.InvalidArgument, "app_code is required") @@ -141,6 +144,7 @@ func pointWithdrawalAsSalaryCommand(command ledger.PointWithdrawalCommand) ledge Reason: command.Reason, WithdrawalRef: command.WithdrawalRef, WithdrawalApplicationID: command.WithdrawalApplicationID, + RegionID: command.RegionID, } } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index ff539eb2..b1d76c82 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -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) } diff --git a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go index 8cbe86d3..f3b9ce05 100644 --- a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go +++ b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go @@ -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, ` diff --git a/services/wallet-service/internal/storage/mysql/metadata.go b/services/wallet-service/internal/storage/mysql/metadata.go index d010c9d2..f7c73725 100644 --- a/services/wallet-service/internal/storage/mysql/metadata.go +++ b/services/wallet-service/internal/storage/mysql/metadata.go @@ -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"` diff --git a/services/wallet-service/internal/storage/mysql/point_coin_seller.go b/services/wallet-service/internal/storage/mysql/point_coin_seller.go index 5dbbd143..79e0adfe 100644 --- a/services/wallet-service/internal/storage/mysql/point_coin_seller.go +++ b/services/wallet-service/internal/storage/mysql/point_coin_seller.go @@ -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, diff --git a/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go b/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go new file mode 100644 index 00000000..8062e918 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go @@ -0,0 +1,239 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "strconv" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" +) + +const ( + pointWithdrawalChannelCoinSeller = "coin_seller" + pointWithdrawalChannelPlatform = "platform" + + withdrawalLimitPeriodDay = "day" + withdrawalLimitPeriodWeek = "week" + withdrawalLimitPeriodMonth = "month" +) + +type pointWithdrawalPolicyLimit struct { + PolicyID uint64 + PolicyVersion uint64 + Period string + Limit int64 + PeriodKey string +} + +// consumePointWithdrawalLimit 在真实账变事务内解析当前 UTC 月绑定政策并锁定一条计数主键。 +// 调用方必须先完成 wallet transaction 幂等命中检查;这样重放直接返回旧回执,不会重复消耗次数。 +func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, channel string, commandID string, withdrawalRef string, nowMS int64) error { + if userID <= 0 || regionID <= 0 || (channel != pointWithdrawalChannelCoinSeller && channel != pointWithdrawalChannelPlatform) { + return xerr.New(xerr.InvalidArgument, "point withdrawal limit target is invalid") + } + limit, err := r.resolvePointWithdrawalPolicyLimit(ctx, tx, regionID, channel, nowMS) + if err != nil { + return err + } + if channel == pointWithdrawalChannelPlatform && strings.TrimSpace(withdrawalRef) == "" { + // 平台先冻结再建申请;没有稳定引用就无法在建单失败时精确撤销这一次 reservation。 + return xerr.New(xerr.InvalidArgument, "withdrawal_ref is required for platform withdrawal") + } + + appCode := appcode.FromContext(ctx) + if limit.Limit > 0 { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO point_withdrawal_policy_counters ( + app_code, user_id, channel, period, period_key, + withdrawal_count, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?) + ON DUPLICATE KEY UPDATE period_key = VALUES(period_key)`, + appCode, userID, channel, limit.Period, limit.PeriodKey, nowMS, nowMS, + ); err != nil { + return err + } + var current int64 + if err := tx.QueryRowContext(ctx, ` + SELECT withdrawal_count + FROM point_withdrawal_policy_counters + WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ? + FOR UPDATE`, appCode, userID, channel, limit.Period, limit.PeriodKey, + ).Scan(¤t); err != nil { + return err + } + if current >= limit.Limit { + return xerr.NewWithMetadata(xerr.PointWithdrawalLimitReached, "point withdrawal limit reached", map[string]string{ + "channel": channel, + "period": limit.Period, + "limit": strconv.FormatInt(limit.Limit, 10), + }) + } + if _, err := tx.ExecContext(ctx, ` + UPDATE point_withdrawal_policy_counters + SET withdrawal_count = withdrawal_count + 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?`, + nowMS, appCode, userID, channel, limit.Period, limit.PeriodKey, + ); err != nil { + return err + } + } + if channel != pointWithdrawalChannelPlatform { + return nil + } + // reservation 与冻结账变同事务提交;后续只允许“申请创建失败”按 withdrawal_ref 撤销,人工驳回仍计一次发起。 + _, err = tx.ExecContext(ctx, ` + INSERT INTO point_withdrawal_limit_reservations ( + app_code, withdrawal_ref, freeze_command_id, user_id, channel, + policy_id, policy_version, period, period_key, counted, status, created_at_ms, released_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 0)`, + appCode, strings.TrimSpace(withdrawalRef), strings.TrimSpace(commandID), userID, channel, + limit.PolicyID, limit.PolicyVersion, limit.Period, limit.PeriodKey, limit.Limit > 0, nowMS, + ) + if isMySQLDuplicateError(err) { + return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation already belongs to another command") + } + return err +} + +func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64) (pointWithdrawalPolicyLimit, error) { + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + now := time.UnixMilli(nowMS).UTC() + cycleKey := now.Format("2006-01") + // 复用月工资政策的不可变周期绑定解析;当前月缺失时沿用同一套“只复制紧邻上月”规则,限制与发薪不会选到不同版本。 + policy, found, err := r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, "", "") + if err != nil { + return pointWithdrawalPolicyLimit{}, err + } + if !found { + return pointWithdrawalPolicyLimit{}, xerr.New(xerr.PermissionDenied, "host salary policy is not configured for withdrawal region") + } + limit := pointWithdrawalPolicyLimit{PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion} + switch channel { + case pointWithdrawalChannelCoinSeller: + limit.Period, limit.Limit = policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount + case pointWithdrawalChannelPlatform: + limit.Period, limit.Limit = policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount + default: + return pointWithdrawalPolicyLimit{}, xerr.New(xerr.InvalidArgument, "point withdrawal channel is invalid") + } + limit.Period = strings.ToLower(strings.TrimSpace(limit.Period)) + if limit.Limit < 0 { + return pointWithdrawalPolicyLimit{}, xerr.New(xerr.Internal, "point withdrawal policy limit is invalid") + } + if limit.Period == "" { + limit.Period = withdrawalLimitPeriodMonth + } + switch limit.Period { + case withdrawalLimitPeriodDay: + limit.PeriodKey = now.Format("2006-01-02") + case withdrawalLimitPeriodWeek: + // 周期固定为 UTC 周一 00:00 起;保存周一日期避免跨年 ISO 周编号歧义。 + mondayOffset := (int(now.Weekday()) + 6) % 7 + limit.PeriodKey = now.AddDate(0, 0, -mondayOffset).Format("2006-01-02") + case withdrawalLimitPeriodMonth: + limit.PeriodKey = cycleKey + default: + return pointWithdrawalPolicyLimit{}, xerr.New(xerr.Internal, "point withdrawal policy period is invalid") + } + return limit, nil +} + +// releasePointWithdrawalLimitReservation 只服务 gateway 建单失败补偿。 +// application_id 非空的人工驳回不会调用这里,因此驳回申请仍占用原限制周期的一次次数。 +func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, tx *sql.Tx, userID int64, withdrawalRef string, rollbackCommandID string, nowMS int64) error { + withdrawalRef = strings.TrimSpace(withdrawalRef) + appCode := appcode.FromContext(ctx) + var reservedUserID int64 + var channel string + var period string + var periodKey string + var counted bool + var status string + queryByRef := withdrawalRef != "" + var err error + if queryByRef { + err = tx.QueryRowContext(ctx, ` + SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status + FROM point_withdrawal_limit_reservations + WHERE app_code = ? AND withdrawal_ref = ? + FOR UPDATE`, appCode, withdrawalRef, + ).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status) + } + if !queryByRef || errors.Is(err, sql.ErrNoRows) { + // HTTP 重试会生成新的 request_id,但 freeze command_id 是客户端稳定幂等键;按 :rollback 约定回退到唯一命令索引。 + freezeCommandID := strings.TrimSuffix(strings.TrimSpace(rollbackCommandID), ":rollback") + if freezeCommandID == strings.TrimSpace(rollbackCommandID) || freezeCommandID == "" { + if errors.Is(err, sql.ErrNoRows) || !queryByRef { + return nil + } + return err + } + err = tx.QueryRowContext(ctx, ` + SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status + FROM point_withdrawal_limit_reservations + WHERE app_code = ? AND freeze_command_id = ? + FOR UPDATE`, appCode, freezeCommandID, + ).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status) + } + if errors.Is(err, sql.ErrNoRows) { + // 旧冻结记录没有 reservation,释放余额仍按原兼容行为继续。 + return nil + } + if err != nil { + return err + } + if reservedUserID != userID || channel != pointWithdrawalChannelPlatform { + return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation does not match rollback") + } + if status == "released" { + return nil + } + if status != "active" { + return xerr.New(xerr.Internal, "point withdrawal reservation status is invalid") + } + if counted { + // policy_id/version 只保留为命中政策审计;自然周期计数跨区域和同月显式政策切换连续,释放也必须命中共享周期键。 + result, err := tx.ExecContext(ctx, ` + UPDATE point_withdrawal_policy_counters + SET withdrawal_count = withdrawal_count - 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ? + AND withdrawal_count > 0`, nowMS, appCode, userID, channel, period, periodKey) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return err + } + return xerr.New(xerr.Internal, "point withdrawal reservation counter is missing") + } + } + _, err = tx.ExecContext(ctx, ` + UPDATE point_withdrawal_limit_reservations + SET status = 'released', released_at_ms = ? + WHERE app_code = ? AND withdrawal_ref = ? AND status = 'active'`, nowMS, appCode, withdrawalRef) + return err +} + +// pointWithdrawalReservationWasReleased 防止已补偿冻结的 command_id 重放旧成功回执。 +// reservation 与冻结交易同事务落库,因此 active/不存在仍可按原幂等语义返回;released 必须要求客户端换新命令。 +func (r *Repository) pointWithdrawalReservationWasReleased(ctx context.Context, tx *sql.Tx, commandID string) (bool, error) { + var status string + err := tx.QueryRowContext(ctx, ` + SELECT status + FROM point_withdrawal_limit_reservations + WHERE app_code = ? AND freeze_command_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(commandID)).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return status == "released", nil +} diff --git a/services/wallet-service/internal/storage/mysql/salary_withdrawal.go b/services/wallet-service/internal/storage/mysql/salary_withdrawal.go index 96dc20ea..28a4f5bc 100644 --- a/services/wallet-service/internal/storage/mysql/salary_withdrawal.go +++ b/services/wallet-service/internal/storage/mysql/salary_withdrawal.go @@ -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 绝不能共存。 diff --git a/services/wallet-service/internal/storage/mysql/schema.go b/services/wallet-service/internal/storage/mysql/schema.go index 9377b6db..e3adc0e2 100644 --- a/services/wallet-service/internal/storage/mysql/schema.go +++ b/services/wallet-service/internal/storage/mysql/schema.go @@ -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' diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 17632876..632aa8f7 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -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": diff --git a/services/wallet-service/internal/transport/grpc/point_coin_seller.go b/services/wallet-service/internal/transport/grpc/point_coin_seller.go index ea901fca..7ff88cf5 100644 --- a/services/wallet-service/internal/transport/grpc/point_coin_seller.go +++ b/services/wallet-service/internal/transport/grpc/point_coin_seller.go @@ -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) diff --git a/services/wallet-service/internal/transport/grpc/point_withdrawal.go b/services/wallet-service/internal/transport/grpc/point_withdrawal.go index 324e688a..99b3ec78 100644 --- a/services/wallet-service/internal/transport/grpc/point_withdrawal.go +++ b/services/wallet-service/internal/transport/grpc/point_withdrawal.go @@ -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)