增加全表导出

This commit is contained in:
zhx 2026-07-20 19:08:59 +08:00
parent 2a6053da56
commit 4c980ddeca
23 changed files with 298 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("至少需要配置一个等级")

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "印度"}},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。

View File

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

View File

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

View File

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

View File

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

View File

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