diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index c35a0210..81553e84 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -94,6 +94,7 @@ var catalog = map[Code]Spec{ 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"), + PointWithdrawalDateNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, PointWithdrawalDateNotAllowed, "withdrawal date is not allowed"), 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 dcde6742..55a09219 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -103,6 +103,8 @@ const ( IdempotencyConflict Code = "IDEMPOTENCY_CONFLICT" // PointWithdrawalLimitReached 表示用户已达到月工资政策配置的 UTC 日/周/月提现次数上限。 PointWithdrawalLimitReached Code = "POINT_WITHDRAWAL_LIMIT_REACHED" + // PointWithdrawalDateNotAllowed 表示当前 UTC 日不在工资政策允许的平台提现日期内。 + PointWithdrawalDateNotAllowed Code = "POINT_WITHDRAWAL_DATE_NOT_ALLOWED" // 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 ea2d1f17..68495b20 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -143,6 +143,14 @@ func TestCatalogMappings(t *testing.T) { publicCode: string(PointWithdrawalLimitReached), publicMessage: "withdrawal limit reached", }, + { + name: "point withdrawal date gate keeps actionable conflict", + code: PointWithdrawalDateNotAllowed, + grpcCode: codes.FailedPrecondition, + httpStatus: httpStatusConflict, + publicCode: string(PointWithdrawalDateNotAllowed), + publicMessage: "withdrawal date is not allowed", + }, { 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 60d56fb3..017a5eb7 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -450,6 +450,8 @@ type HostAgencySalaryPolicy struct { 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"` + // PlatformWithdrawalAllowedDays 使用排序去重后的逗号串保存 UTC 月内日;空串兼容为每天可提现。 + PlatformWithdrawalAllowedDays string `gorm:"column:platform_withdrawal_allowed_days;size:96;not null;default:''" json:"platformWithdrawalAllowedDays"` // 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"` diff --git a/server/admin/internal/modules/hostagencypolicy/request.go b/server/admin/internal/modules/hostagencypolicy/request.go index d4aecd50..dd4a5eaa 100644 --- a/server/admin/internal/modules/hostagencypolicy/request.go +++ b/server/admin/internal/modules/hostagencypolicy/request.go @@ -14,6 +14,7 @@ type policyRequest struct { CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"` PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"` PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"` + PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"` Description string `json:"description"` Levels []levelRequest `json:"levels"` } diff --git a/server/admin/internal/modules/hostagencypolicy/response.go b/server/admin/internal/modules/hostagencypolicy/response.go index 6e44620e..967b73ac 100644 --- a/server/admin/internal/modules/hostagencypolicy/response.go +++ b/server/admin/internal/modules/hostagencypolicy/response.go @@ -23,6 +23,7 @@ type policyDTO struct { CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"` PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"` PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"` + PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"` Description string `json:"description"` CreatedByAdminID uint `json:"created_by_admin_id"` UpdatedByAdminID uint `json:"updated_by_admin_id"` @@ -76,6 +77,7 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO { CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount, PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount, + PlatformWithdrawalAllowedDays: parseWithdrawalDays(item.PlatformWithdrawalAllowedDays), Description: item.Description, CreatedByAdminID: item.CreatedByAdminID, UpdatedByAdminID: item.UpdatedByAdminID, diff --git a/server/admin/internal/modules/hostagencypolicy/service.go b/server/admin/internal/modules/hostagencypolicy/service.go index d1f6e5dc..a2905f00 100644 --- a/server/admin/internal/modules/hostagencypolicy/service.go +++ b/server/admin/internal/modules/hostagencypolicy/service.go @@ -219,6 +219,7 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u item.CoinSellerWithdrawalLimitCount = updated.CoinSellerWithdrawalLimitCount item.PlatformWithdrawalLimitPeriod = updated.PlatformWithdrawalLimitPeriod item.PlatformWithdrawalLimitCount = updated.PlatformWithdrawalLimitCount + item.PlatformWithdrawalAllowedDays = updated.PlatformWithdrawalAllowedDays item.Description = updated.Description item.UpdatedByAdminID = actorID // 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft。 @@ -382,6 +383,7 @@ type runtimeHostSalaryPolicySnapshot struct { CoinSellerWithdrawalLimitCount int64 PlatformWithdrawalLimitPeriod string PlatformWithdrawalLimitCount int64 + PlatformWithdrawalAllowedDays string } type runtimeHostSalaryLevelSnapshot struct { @@ -402,15 +404,16 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item 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, + platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) 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, + item.PlatformWithdrawalAllowedDays, nonZeroMS(item.CreatedAtMS, publishedAtMS), publishedAtMS, ); err != nil { return err @@ -421,7 +424,7 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item 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 + platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days FROM host_agency_salary_policies WHERE app_code = ? AND policy_id = ? FOR UPDATE`, item.AppCode, item.ID).Scan( @@ -430,6 +433,7 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item &existing.GiftCoinToDiamondRatio, &existing.ResidualDiamondToUSDRate, &existing.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount, &existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount, + &existing.PlatformWithdrawalAllowedDays, ) if err != nil { return err @@ -483,7 +487,8 @@ func runtimeHostSalaryPolicyMatches(existing runtimeHostSalaryPolicySnapshot, it existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) && existing.CoinSellerWithdrawalLimitCount == item.CoinSellerWithdrawalLimitCount && existing.PlatformWithdrawalLimitPeriod == firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) && - existing.PlatformWithdrawalLimitCount == item.PlatformWithdrawalLimitCount + existing.PlatformWithdrawalLimitCount == item.PlatformWithdrawalLimitCount && + existing.PlatformWithdrawalAllowedDays == item.PlatformWithdrawalAllowedDays } func runtimeHostSalaryLevelsFromModel(levels []model.HostAgencySalaryLevel) ([]runtimeHostSalaryLevelSnapshot, error) { @@ -645,6 +650,10 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo if err != nil { return model.HostAgencySalaryPolicy{}, err } + platformAllowedDays, err := normalizeWithdrawalDays(req.PlatformWithdrawalAllowedDays) + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } description := strings.TrimSpace(req.Description) if len([]rune(description)) > 255 { return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符") @@ -670,6 +679,7 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo CoinSellerWithdrawalLimitCount: coinSellerLimitCount, PlatformWithdrawalLimitPeriod: platformLimitPeriod, PlatformWithdrawalLimitCount: platformLimitCount, + PlatformWithdrawalAllowedDays: platformAllowedDays, Description: description, CreatedByAdminID: actorID, UpdatedByAdminID: actorID, @@ -692,6 +702,48 @@ func normalizeWithdrawalLimit(period string, count int64, field string) (string, return period, count, nil } +// normalizeWithdrawalDays 把后台多选日规整为不可变快照中的稳定逗号串;空集合表示不限制日期。 +func normalizeWithdrawalDays(days []int32) (string, error) { + if len(days) == 0 { + return "", nil + } + unique := make(map[int32]struct{}, len(days)) + for _, day := range days { + if day < 1 || day > 31 { + return "", errors.New("平台每月可提现日期必须在 1 到 31 号之间") + } + unique[day] = struct{}{} + } + normalized := make([]int, 0, len(unique)) + for day := range unique { + normalized = append(normalized, int(day)) + } + sort.Ints(normalized) + parts := make([]string, 0, len(normalized)) + for _, day := range normalized { + parts = append(parts, strconv.Itoa(day)) + } + return strings.Join(parts, ","), nil +} + +func parseWithdrawalDays(raw string) []int32 { + raw = strings.TrimSpace(raw) + if raw == "" { + return []int32{} + } + parts := strings.Split(raw, ",") + days := make([]int32, 0, len(parts)) + for _, part := range parts { + day, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || day < 1 || day > 31 { + // 数据库异常值不回传给表单,发布和 Wallet 运行侧仍会拒绝不合法快照。 + return []int32{} + } + days = append(days, int32(day)) + } + return days +} + func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) { if len(requests) == 0 { return nil, errors.New("至少需要配置一个等级") diff --git a/server/admin/internal/modules/hostagencypolicy/service_test.go b/server/admin/internal/modules/hostagencypolicy/service_test.go index 77670340..d1800283 100644 --- a/server/admin/internal/modules/hostagencypolicy/service_test.go +++ b/server/admin/internal/modules/hostagencypolicy/service_test.go @@ -26,6 +26,7 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) { CoinSellerWithdrawalLimitCount: 3, PlatformWithdrawalLimitPeriod: "day", PlatformWithdrawalLimitCount: 1, + PlatformWithdrawalAllowedDays: []int32{30, 15, 30}, 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"}, @@ -44,7 +45,7 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) { 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 { + if item.CoinSellerWithdrawalLimitPeriod != "week" || item.CoinSellerWithdrawalLimitCount != 3 || item.PlatformWithdrawalLimitPeriod != "day" || item.PlatformWithdrawalLimitCount != 1 || item.PlatformWithdrawalAllowedDays != "15,30" { t.Fatalf("withdrawal limit fields mismatch: %+v", item) } if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 { @@ -72,6 +73,14 @@ func TestPolicyModelFromRequestRejectsInvalidWithdrawalLimit(t *testing.T) { if err == nil { t.Fatal("negative withdrawal limit count must fail") } + _, err = policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid withdrawal day", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily, + PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalAllowedDays: []int32{15, 32}, + Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}}, + }) + if err == nil { + t.Fatal("platform withdrawal day outside 1..31 must fail") + } } func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) { @@ -201,13 +210,14 @@ func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t 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}}, + PlatformWithdrawalAllowedDays: "15,30", + 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)) + "gift_ratio", "residual_rate", "coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days", + }).AddRow("2026-08", 81, "August policy", 25, "active", "daily", "automatic", "1.000000", "0.000000000000", "week", 2, "day", 1, "15,30")) 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)) @@ -227,13 +237,15 @@ func TestRuntimeHostSalaryPolicySnapshotRejectsDifferentContent(t *testing.T) { SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic, GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0", CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month", + PlatformWithdrawalAllowedDays: "15,30", } 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, + PlatformWithdrawalLimitCount: 2, + PlatformWithdrawalAllowedDays: "15,30", } if runtimeHostSalaryPolicyMatches(existing, item, 81) { t.Fatal("different withdrawal limit content must never be accepted as an idempotent snapshot") 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 e0487d3b..f0c720d7 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 @@ -9,6 +9,7 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/repository" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" @@ -50,6 +51,15 @@ func (s *MongoRechargeBillSource) financePaidCoinSellerFilter(ctx context.Contex "orderId": bson.M{"$type": "string", "$ne": ""}, "factory.factoryCode": bson.M{"$type": "string", "$ne": ""}, } + // Legacy 支付厂商码带下划线;按已确认的两个历史写法等值匹配,避免正则扫描,也保证厂商筛选不会混入其他三方。 + if rechargeSource, ok := financeCoinSellerRechargeSource(query.RechargeType); ok { + switch rechargeSource { + case repository.CoinSellerRechargeSourceMiFaPay: + filter["factory.factoryCode"] = bson.M{"$in": bson.A{"MIFA_PAY", "MIFAPAY"}} + case repository.CoinSellerRechargeSourceV5Pay: + filter["factory.factoryCode"] = bson.M{"$in": bson.A{"V5_PAY", "V5PAY"}} + } + } conditions := bson.A{bson.M{"$or": legacyFreightCommodityMatchConditions()}} if query.TargetUserID > 0 { filter["acceptUserId"] = query.TargetUserID @@ -332,8 +342,8 @@ func legacyPaidCoinSellerRechargeBillDTO(sourceConfig config.FinanceBillSourceCo return rechargeBillDTO{ AppCode: sourceConfig.AppCode, TransactionID: legacyDocumentID(document.ID), - // Legacy H5 FREIGHT_GOLD 是支付渠道成功单,归入币商-三方;运营手工选择的 USDT 只来自 admin 订单台账。 - RechargeType: financeCoinSellerThirdPartyType, + // Legacy H5 以支付单 factoryCode 还原具体厂商;未知历史值保留“币商充值-三方”聚合标签。 + RechargeType: financeCoinSellerBillType(factoryCode), 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 2bf0fe0b..bcfdd745 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 != financeCoinSellerThirdPartyType || dto.Status != "succeeded" { + if dto.RechargeType != financeCoinSellerMiFaPayType || dto.Status != "succeeded" { t.Fatalf("coin seller bill identity mismatch: %+v", dto) } if dto.ExternalRef != document.OrderID || dto.SellerUserID != document.AcceptUserID { 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 58ef3f74..1bde22e0 100644 --- a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go @@ -41,6 +41,8 @@ const ( financeRechargeMergeMaxPrefetch = 5000 financeCoinSellerRechargeType = "coin_seller" financeCoinSellerThirdPartyType = "coin_seller_third_party" + financeCoinSellerMiFaPayType = "coin_seller_mifapay" + financeCoinSellerV5PayType = "coin_seller_v5pay" financeCoinSellerUSDTType = "coin_seller_usdt" // 2^53 以内的 int64 在 float64 里精确可表示,不会被浏览器 JSON 圆整; @@ -56,6 +58,10 @@ func financeCoinSellerRechargeSource(rechargeType string) (string, bool) { return "", true case financeCoinSellerThirdPartyType: return repository.CoinSellerRechargeSourceThirdParty, true + case financeCoinSellerMiFaPayType: + return repository.CoinSellerRechargeSourceMiFaPay, true + case financeCoinSellerV5PayType: + return repository.CoinSellerRechargeSourceV5Pay, true case financeCoinSellerUSDTType: return repository.CoinSellerRechargeSourceUSDT, true default: @@ -69,10 +75,19 @@ func isFinanceCoinSellerRechargeType(rechargeType string) bool { } func financeCoinSellerBillType(providerCode string) string { - if strings.EqualFold(strings.TrimSpace(providerCode), "usdt") { + // Legacy Mongo 使用 MIFA_PAY/V5_PAY,新台账和 wallet 使用 mifapay/v5pay;去掉分隔符后才能得到同一财务厂商口径。 + normalized := strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(providerCode))) + switch normalized { + case "mifapay": + return financeCoinSellerMiFaPayType + case "v5pay": + return financeCoinSellerV5PayType + case "usdt", "usdttrc20": return financeCoinSellerUSDTType + default: + // 未知历史三方不能被误标为任一厂商;保留聚合标签,仍可从 ProviderCode 审计原值。 + return financeCoinSellerThirdPartyType } - return financeCoinSellerThirdPartyType } // normalizeFinanceCoinSellerRegionID 把请求里可能被前端 float64 圆整过的 legacy 区域 ID @@ -619,8 +634,8 @@ func (h *Handler) listH5CoinSellerRechargeBills(ctx context.Context, appCode str return nil, 0, err } item.TransactionID = firstNonEmptyPaymentString(walletTransactionID, item.ExternalRef) - // H5 币商身份订单通过支付渠道完成,属于币商-三方;只有运营台账 provider_code=usdt 才进入币商-USDT。 - item.RechargeType = financeCoinSellerThirdPartyType + // H5 订单直接使用支付事实里的 provider_code,避免厂商筛选后明细仍退化成笼统“三方”。 + item.RechargeType = financeCoinSellerBillType(item.ProviderCode) item.Status = "succeeded" item.TargetRegionID = item.SellerRegionID item.PolicyID = productID @@ -682,6 +697,17 @@ func h5CoinSellerRechargeWhere(appCode string, query financeCoinSellerRechargeQu "wt.status = 'succeeded'", } args := []any{appCode} + // wallet 表已有 (app_code,status,provider_code,updated_at_ms) 索引;把厂商条件下推到 SQL 才能保证统计、总数和分页一致。 + if rechargeSource, ok := financeCoinSellerRechargeSource(query.RechargeType); ok { + switch rechargeSource { + case repository.CoinSellerRechargeSourceThirdParty: + where = append(where, "eo.provider_code IN (?, ?)") + args = append(args, repository.CoinSellerRechargeSourceMiFaPay, repository.CoinSellerRechargeSourceV5Pay) + case repository.CoinSellerRechargeSourceMiFaPay, repository.CoinSellerRechargeSourceV5Pay: + where = append(where, "eo.provider_code = ?") + args = append(args, rechargeSource) + } + } if query.TargetUserID > 0 { where = append(where, "eo.target_user_id = ?") args = append(args, query.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 6f7c528f..1ff11b58 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 @@ -2,6 +2,7 @@ package payment import ( "context" + "strings" "testing" "hyapp-admin-server/internal/model" @@ -26,8 +27,16 @@ func TestFinanceCoinSellerRechargeOrderBillUsesBusinessTime(t *testing.T) { 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) + if mifapayBill := financeCoinSellerRechargeOrderBill(order); mifapayBill.RechargeType != financeCoinSellerMiFaPayType { + t.Fatalf("MiFaPay admin order must expose the provider source: %+v", mifapayBill) + } + order.ProviderCode = "v5pay" + if v5payBill := financeCoinSellerRechargeOrderBill(order); v5payBill.RechargeType != financeCoinSellerV5PayType { + t.Fatalf("V5Pay admin order must expose the provider source: %+v", v5payBill) + } + order.ProviderCode = "unknown_legacy_provider" + if legacyBill := financeCoinSellerRechargeOrderBill(order); legacyBill.RechargeType != financeCoinSellerThirdPartyType { + t.Fatalf("unknown legacy provider must keep the aggregate third-party source: %+v", legacyBill) } } @@ -40,11 +49,28 @@ func TestFinanceCoinSellerRechargeSourceKeepsParentCompatibility(t *testing.T) { 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(financeCoinSellerMiFaPayType); !ok || source != "mifapay" { + t.Fatalf("MiFaPay source mismatch: source=%q ok=%v", source, ok) + } + if source, ok := financeCoinSellerRechargeSource(financeCoinSellerV5PayType); !ok || source != "v5pay" { + t.Fatalf("V5Pay 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 TestH5CoinSellerRechargeWhereScopesProviderBeforePagination(t *testing.T) { + where, args := h5CoinSellerRechargeWhere("aslan", financeCoinSellerRechargeQuery{RechargeType: financeCoinSellerV5PayType}) + if !strings.Contains(where, "eo.provider_code = ?") || len(args) != 2 || args[1] != "v5pay" { + t.Fatalf("V5Pay filter must be pushed into the indexed SQL query: where=%q args=%v", where, args) + } + where, args = h5CoinSellerRechargeWhere("aslan", financeCoinSellerRechargeQuery{RechargeType: financeCoinSellerThirdPartyType}) + if !strings.Contains(where, "eo.provider_code IN (?, ?)") || len(args) != 3 || args[1] != "mifapay" || args[2] != "v5pay" { + t.Fatalf("third-party aggregate must contain both providers: where=%q args=%v", where, args) + } +} + func TestNormalizeFinanceCoinSellerRegionIDRestoresRoundedLegacyID(t *testing.T) { handler := New(&mockPaymentWallet{}, nil, nil, nil, nil, WithMoneyRegionSources(&staticMoneyRegionSource{ regions: []moneyRegionDTO{{AppCode: "aslan", RegionID: 2049040141349486594, RegionCode: "IN", Name: "印度"}}, diff --git a/server/admin/internal/modules/payment/recharge_bill_export.go b/server/admin/internal/modules/payment/recharge_bill_export.go index 4d844d79..33261806 100644 --- a/server/admin/internal/modules/payment/recharge_bill_export.go +++ b/server/admin/internal/modules/payment/recharge_bill_export.go @@ -283,6 +283,10 @@ func rechargeSourceLabel(rechargeType string) string { return "币商充值" case financeCoinSellerThirdPartyType: return "币商充值-三方" + case financeCoinSellerMiFaPayType: + return "币商充值-MiFaPay" + case financeCoinSellerV5PayType: + return "币商充值-V5Pay" case financeCoinSellerUSDTType: return "币商充值-USDT" case "coin_seller_stock_purchase": 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 061763a0..12ea4439 100644 --- a/server/admin/internal/repository/coin_seller_recharge_order_repository.go +++ b/server/admin/internal/repository/coin_seller_recharge_order_repository.go @@ -19,6 +19,8 @@ var ( const ( // RechargeSource 只描述运营录入币商充值时的付款大类;provider_code 仍保留具体支付商,不能用展示分组覆盖审计事实。 CoinSellerRechargeSourceThirdParty = "third_party" + CoinSellerRechargeSourceMiFaPay = "mifapay" + CoinSellerRechargeSourceV5Pay = "v5pay" CoinSellerRechargeSourceUSDT = "usdt" ) @@ -432,11 +434,15 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller } // applyCoinSellerRechargeSourceFilter 把财务展示分组还原到台账的权威 provider_code。 -// 当前创建入口只允许 mifapay/v5pay/usdt,使用等值或 IN 条件可以继续利用 provider_code 索引,避免为了筛选扫描并在 Go 内丢弃整页数据。 +// 当前创建入口只允许 mifapay/v5pay/usdt;聚合三方使用小集合 IN,厂商和 USDT 使用等值条件,均可继续利用 provider_code 索引。 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 CoinSellerRechargeSourceMiFaPay: + return query.Where("provider_code = ?", "mifapay") + case CoinSellerRechargeSourceV5Pay: + return query.Where("provider_code = ?", "v5pay") case CoinSellerRechargeSourceUSDT: return query.Where("provider_code = ?", "usdt") default: diff --git a/server/admin/migrations/113_host_salary_policy_platform_withdrawal_days.sql b/server/admin/migrations/113_host_salary_policy_platform_withdrawal_days.sql new file mode 100644 index 00000000..7e93e1b4 --- /dev/null +++ b/server/admin/migrations/113_host_salary_policy_platform_withdrawal_days.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 工资政策是按 App/月份维护的小型配置表;日期使用最长 1,2,...,31 的规范逗号串,空串兼容为每天可提现。 +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_allowed_days') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN platform_withdrawal_allowed_days VARCHAR(96) NOT NULL DEFAULT '''' COMMENT ''用户找平台提现允许的 UTC 月内日,逗号分隔,空表示每天'' AFTER platform_withdrawal_limit_count, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; 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 d1d6129b..09a79f3c 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -302,6 +302,7 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies ( 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 表示不限', + platform_withdrawal_allowed_days VARCHAR(96) NOT NULL DEFAULT '' COMMENT '用户找平台提现允许的 UTC 月内日,逗号分隔,空表示每天', 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', diff --git a/services/wallet-service/deploy/mysql/migrations/009_host_salary_policy_platform_withdrawal_days.sql b/services/wallet-service/deploy/mysql/migrations/009_host_salary_policy_platform_withdrawal_days.sql new file mode 100644 index 00000000..8fc5a44b --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/009_host_salary_policy_platform_withdrawal_days.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 运行快照表是小型政策表;空值保持历史政策“每天可提现”,无需扫描或改写提现、交易和计数流水。 +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_allowed_days') = 0, + 'ALTER TABLE host_agency_salary_policies ADD COLUMN platform_withdrawal_allowed_days VARCHAR(96) NOT NULL DEFAULT '''' COMMENT ''用户找平台提现允许的 UTC 月内日,逗号分隔,空表示每天'' AFTER platform_withdrawal_limit_count, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/services/wallet-service/internal/domain/ledger/host_salary.go b/services/wallet-service/internal/domain/ledger/host_salary.go index 5df51e4f..48c7582b 100644 --- a/services/wallet-service/internal/domain/ledger/host_salary.go +++ b/services/wallet-service/internal/domain/ledger/host_salary.go @@ -43,9 +43,11 @@ type HostSalaryPolicy struct { CoinSellerWithdrawalLimitCount int64 PlatformWithdrawalLimitPeriod string PlatformWithdrawalLimitCount int64 - EffectiveFromMs int64 - EffectiveToMs int64 - Levels []HostSalaryPolicyLevel + // PlatformWithdrawalAllowedDays 是 UTC 月内日的规范逗号串;空串表示不限制提现日期。 + PlatformWithdrawalAllowedDays string + EffectiveFromMs int64 + EffectiveToMs int64 + Levels []HostSalaryPolicyLevel } // HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。 diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index b1d76c82..d0405dda 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2334,7 +2334,11 @@ func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) { } } -func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode string, policyID uint64, regionID int64, coinSellerPeriod string, coinSellerCount int64, platformPeriod string, platformCount int64) { +func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode string, policyID uint64, regionID int64, coinSellerPeriod string, coinSellerCount int64, platformPeriod string, platformCount int64, platformAllowedDays ...string) { + allowedDays := "" + if len(platformAllowedDays) > 0 { + allowedDays = platformAllowedDays[0] + } 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", @@ -2342,9 +2346,46 @@ func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode s GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0", CoinSellerWithdrawalLimitPeriod: coinSellerPeriod, CoinSellerWithdrawalLimitCount: coinSellerCount, PlatformWithdrawalLimitPeriod: platformPeriod, PlatformWithdrawalLimitCount: platformCount, + PlatformWithdrawalAllowedDays: allowedDays, }) } +func TestPointWithdrawalPlatformAllowedDaysUseExactUTCDate(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp("fami", 22008, ledger.AssetPoint, 2_000_000) + today := time.Now().UTC() + allowedDay := today.Format("2") + disallowedDay := "1" + if today.Day() == 1 { + disallowedDay = "2" + } + seedPointWithdrawalSalaryPolicy(repository, "fami", 228, 25, "month", 0, "month", 0, allowedDay) + svc := walletservice.New(repository) + if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "fami", CommandID: "point-day-allowed", UserID: 22008, AssetType: ledger.AssetPoint, + GrossPointAmount: 1_000_000, RegionID: 25, WithdrawalRef: "point-day-allowed-ref", + }); err != nil { + t.Fatalf("configured UTC withdrawal day must allow platform freeze: %v", err) + } + + repository.SetAssetBalanceForApp("fami", 22009, ledger.AssetPoint, 2_000_000) + seedPointWithdrawalSalaryPolicy(repository, "fami", 229, 26, "month", 0, "month", 0, disallowedDay) + _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "fami", CommandID: "point-day-blocked", UserID: 22009, AssetType: ledger.AssetPoint, + GrossPointAmount: 1_000_000, RegionID: 26, WithdrawalRef: "point-day-blocked-ref", + }) + if !xerr.IsCode(err, xerr.PointWithdrawalDateNotAllowed) { + t.Fatalf("date outside configured UTC days must be rejected, got %v", err) + } + metadata := xerr.MetadataOf(err) + if metadata["allowed_days"] != disallowedDay || metadata["current_day"] != allowedDay || metadata["timezone"] != "UTC" { + t.Fatalf("withdrawal date metadata mismatch: %+v", metadata) + } + if got := repository.CountRows("wallet_transactions", "app_code = ? AND command_id = ?", "fami", "point-day-blocked"); got != 0 { + t.Fatalf("rejected date must not create a wallet transaction, got %d", got) + } +} + func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) { repository := &fakePointGateRepository{} svc := walletservice.New(repository) 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 f3b9ce05..23a37c29 100644 --- a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go +++ b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go @@ -484,7 +484,7 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar 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.platform_withdrawal_limit_period, p.platform_withdrawal_limit_count, p.platform_withdrawal_allowed_days, p.effective_from_ms, p.effective_to_ms FROM host_salary_policy_cycle_bindings binding JOIN host_agency_salary_policies p @@ -504,6 +504,7 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount, &policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount, + &policy.PlatformWithdrawalAllowedDays, &policy.EffectiveFromMs, &policy.EffectiveToMs, ) if err != nil { diff --git a/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go b/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go index 8062e918..f03d2a49 100644 --- a/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go +++ b/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go @@ -27,6 +27,7 @@ type pointWithdrawalPolicyLimit struct { Period string Limit int64 PeriodKey string + AllowedDays string } // consumePointWithdrawalLimit 在真实账变事务内解析当前 UTC 月绑定政策并锁定一条计数主键。 @@ -43,6 +44,19 @@ func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx // 平台先冻结再建申请;没有稳定引用就无法在建单失败时精确撤销这一次 reservation。 return xerr.New(xerr.InvalidArgument, "withdrawal_ref is required for platform withdrawal") } + if channel == pointWithdrawalChannelPlatform { + allowed, err := platformWithdrawalAllowedOnUTCDate(limit.AllowedDays, time.UnixMilli(nowMS).UTC()) + if err != nil { + return err + } + if !allowed { + return xerr.NewWithMetadata(xerr.PointWithdrawalDateNotAllowed, "point withdrawal date is not allowed", map[string]string{ + "allowed_days": limit.AllowedDays, + "current_day": strconv.Itoa(time.UnixMilli(nowMS).UTC().Day()), + "timezone": "UTC", + }) + } + } appCode := appcode.FromContext(ctx) if limit.Limit > 0 { @@ -119,6 +133,7 @@ func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx * limit.Period, limit.Limit = policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount case pointWithdrawalChannelPlatform: limit.Period, limit.Limit = policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount + limit.AllowedDays = strings.TrimSpace(policy.PlatformWithdrawalAllowedDays) default: return pointWithdrawalPolicyLimit{}, xerr.New(xerr.InvalidArgument, "point withdrawal channel is invalid") } @@ -144,6 +159,32 @@ func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx * return limit, nil } +// platformWithdrawalAllowedOnUTCDate 精确匹配 UTC 日历日;例如 30 号在二月不存在时不会自动改成月底。 +// 空串兼容迁移前政策为每天可提现,异常快照则返回内部错误,不能静默放开限制。 +func platformWithdrawalAllowedOnUTCDate(raw string, now time.Time) (bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return true, nil + } + currentDay := now.UTC().Day() + seen := make(map[int]struct{}) + allowed := false + for _, part := range strings.Split(raw, ",") { + day, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || day < 1 || day > 31 { + return false, xerr.New(xerr.Internal, "point withdrawal allowed days policy is invalid") + } + if _, duplicate := seen[day]; duplicate { + return false, xerr.New(xerr.Internal, "point withdrawal allowed days policy is invalid") + } + seen[day] = struct{}{} + if day == currentDay { + allowed = true + } + } + return allowed, nil +} + // releasePointWithdrawalLimitReservation 只服务 gateway 建单失败补偿。 // application_id 非空的人工驳回不会调用这里,因此驳回申请仍占用原限制周期的一次次数。 func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, tx *sql.Tx, userID int64, withdrawalRef string, rollbackCommandID string, nowMS int64) error { diff --git a/services/wallet-service/internal/storage/mysql/schema.go b/services/wallet-service/internal/storage/mysql/schema.go index e3adc0e2..33cfd4ed 100644 --- a/services/wallet-service/internal/storage/mysql/schema.go +++ b/services/wallet-service/internal/storage/mysql/schema.go @@ -87,6 +87,11 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { 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, ` + ALTER TABLE host_agency_salary_policies + ADD COLUMN platform_withdrawal_allowed_days VARCHAR(96) NOT NULL DEFAULT '' COMMENT '用户找平台提现允许的 UTC 月内日,逗号分隔,空表示每天' AFTER platform_withdrawal_limit_count`); 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, diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 632aa8f7..ad19d1c0 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -967,8 +967,9 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos 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, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days, + 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), @@ -983,12 +984,13 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos 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), + platform_withdrawal_allowed_days = VALUES(platform_withdrawal_allowed_days), effective_from_ms = VALUES(effective_from_ms), effective_to_ms = VALUES(effective_to_ms), updated_at_ms = VALUES(updated_at_ms) `, 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.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount, policy.PlatformWithdrawalAllowedDays, policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs) if err != nil { r.t.Fatalf("seed host salary policy failed: %v", err)