币商扣减

This commit is contained in:
zhx 2026-07-06 15:00:50 +08:00
parent 7515233227
commit 0d4ce59c10
5 changed files with 111 additions and 43 deletions

View File

@ -108,8 +108,7 @@ func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSel
goldArgs := []any{s.sysOrigin}
freightWhere := []string{
"fbrw.sys_origin = ?",
"fbrw.origin = 'PURCHASE'",
"fbrw.type = 0",
"((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
@ -132,7 +131,7 @@ func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSel
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
goldWhere = append(goldWhere, "(CAST(ugcr.id AS CHAR) = ? OR CONCAT('aslan-gold-coin-', ugcr.id) = ? OR CAST(ugcr.user_id AS CHAR) = ?)")
goldArgs = append(goldArgs, keyword, keyword, keyword)
freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-purchase-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)")
freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-', LOWER(fbrw.origin), '-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)")
freightArgs = append(freightArgs, keyword, keyword, keyword)
}
if countryFilter.Applied {
@ -170,11 +169,18 @@ FROM user_gold_coin_recharge ugcr
INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE ` + strings.Join(goldWhere, " AND ") + `
UNION ALL
SELECT CONCAT('aslan-freight-purchase-', fbrw.id) AS transaction_id,
'freight_purchase' AS source,
SELECT CONCAT('aslan-freight-', LOWER(fbrw.origin), '-', fbrw.id) AS transaction_id,
CONCAT('freight_', LOWER(fbrw.origin)) AS source,
fbrw.user_id,
UPPER(ubi.country_code) AS country_code,
CAST(CASE WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END AS CHAR) AS amount,
CAST(CASE
WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END)
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END AS CHAR) AS amount,
fbrw.create_time AS created_at
FROM ` + quoteMySQLIdentifier(walletDatabase) + `.user_freight_balance_running_water fbrw
INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id

View File

@ -836,7 +836,7 @@ func (s *AslanMongoDashboardSource) loadLegacyCoinSellerRechargeStats(ctx contex
if err := s.loadLegacyGoldCoinRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
if err := s.loadLegacyFreightPurchaseRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
if err := s.loadLegacyFreightStockRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
return nil
@ -912,17 +912,17 @@ GROUP BY DATE_FORMAT(ugcr.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), ugc
return nil
}
func (s *AslanMongoDashboardSource) loadLegacyFreightPurchaseRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
func (s *AslanMongoDashboardSource) loadLegacyFreightStockRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
walletDatabase := strings.TrimSpace(s.legacyWalletDatabase)
if walletDatabase == "" {
return nil
}
// 后台“金币代理 > 发货”实际是给代理进货origin=PURCHASE/type=0表单金额落 amount
// “扣减”是库存/USDT 冲减origin=DEDUCTION/type=1amount 在源系统仍是正数,统计时必须转成负数。
// App/经销商路径可能只落 usd_quantity因此金额优先取 amount空值再回退 usd_quantity。
where := []string{
"fbrw.sys_origin = ?",
"fbrw.origin = 'PURCHASE'",
"fbrw.type = 0",
"((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))",
"fbrw.create_time >= ?",
"fbrw.create_time < ?",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
@ -956,6 +956,10 @@ SELECT DATE_FORMAT(fbrw.create_time, '%Y-%m-%d') AS stat_day,
UPPER(ubi.country_code) AS country_code,
fbrw.user_id,
CAST(COALESCE(SUM(CASE
WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END)
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END), 0) AS CHAR) AS amount
@ -964,7 +968,7 @@ FROM `+quoteMySQLIdentifier(walletDatabase)+`.user_freight_balance_running_water
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbrw.user_id`, args...)
if err != nil {
return fmt.Errorf("query legacy freight purchase recharge: %w", err)
return fmt.Errorf("query legacy freight stock recharge: %w", err)
}
defer rows.Close()
@ -974,7 +978,7 @@ GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbr
var userID int64
var amountText string
if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
return fmt.Errorf("scan legacy freight purchase recharge: %w", err)
return fmt.Errorf("scan legacy freight stock recharge: %w", err)
}
day = strings.TrimSpace(day)
countryCode = aslanMongoCountryCode(countryCode)
@ -983,24 +987,27 @@ GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbr
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return fmt.Errorf("parse legacy freight purchase recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
return fmt.Errorf("parse legacy freight stock recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
}
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate legacy freight purchase recharge: %w", err)
return fmt.Errorf("iterate legacy freight stock recharge: %w", err)
}
return nil
}
func addLegacyCoinSellerRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
if amount <= 0 {
if amount == 0 {
return
}
metric := grid.at(day, countryCode)
metric.RechargeUSDMinor += amount
metric.CoinSellerRechargeUSDMinor += amount
addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
// 扣减流水按负数冲减金额,但不是一次新的付费行为,不能增加付费用户或新用户充值。
if amount > 0 {
addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
}
}
func addLegacyStandardRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
@ -1748,7 +1755,7 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
}
}
legacySources := []map[string]any{
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity"},
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"},
}
rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId排除 FREIGHT_GOLD"
rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd排除 FREIGHT_GOLD"
@ -1760,7 +1767,7 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
}
} else {
rechargeUsersSource += " + legacy MySQL order_other_recharge/user_gold_coin_recharge/user_freight_balance_running_water distinct user_id"
rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity"
rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"
mifaPaySource += " + legacy MySQL order_other_recharge amount"
newUserRechargeSource += "/legacy 其他充值/legacy 币商充值"
}

View File

@ -169,7 +169,7 @@ func TestAslanMongoLegacyOtherRechargeFeedsThirdPartyChannel(t *testing.T) {
}
}
func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testing.T) {
func TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
@ -196,11 +196,12 @@ func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testin
mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}))
mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.type = 0").
mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.origin = 'DEDUCTION'").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
AddRow("2026-07-05", "PH", int64(2069828597070528514), "2000.00").
AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47"))
AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47").
AddRow("2026-07-05", "SA", int64(2071589123112779778), "-1000.00"))
if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err)
@ -218,6 +219,12 @@ func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testin
assertInt64(t, pkRow["coin_seller_recharge_usd_minor"], 2547)
assertInt64(t, pkRow["recharge_usd_minor"], 2547)
saRow := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap()
assertInt64(t, saRow["coin_seller_recharge_usd_minor"], -100000)
assertInt64(t, saRow["recharge_usd_minor"], -100000)
assertInt64(t, saRow["user_recharge_usd_minor"], 0)
assertInt64(t, saRow["paid_users"], 0)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
@ -244,10 +251,11 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw"
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2)))
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(3)))
mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}).
AddRow("aslan-freight-deduction-100", "freight_deduction", int64(2069828597070528516), "ae", "-1000.00", createdAt.Add(time.Minute)).
AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt).
AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour)))
@ -260,18 +268,21 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
if err != nil {
t.Fatalf("ListCoinSellerRechargeBills: %v", err)
}
if total != 2 || len(items) != 2 {
if total != 3 || len(items) != 3 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" {
if items[0].TransactionID != "aslan-freight-deduction-100" || items[0].Source != "freight_deduction" || items[0].CountryCode != "AE" {
t.Fatalf("unexpected first item identity: %+v", items[0])
}
if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() {
if items[0].USDMinorAmount != -100000 || items[0].CreatedAtMS != createdAt.Add(time.Minute).UnixMilli() {
t.Fatalf("unexpected first item amount/time: %+v", items[0])
}
if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 {
if items[1].TransactionID != "aslan-freight-purchase-99" || items[1].USDMinorAmount != 132790 {
t.Fatalf("unexpected second item: %+v", items[1])
}
if items[2].TransactionID != "aslan-gold-coin-7" || items[2].USDMinorAmount != 4200 {
t.Fatalf("unexpected third item: %+v", items[2])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
@ -300,24 +311,31 @@ func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) {
}
}
func TestAslanMongoLegacyRechargeSkipsNonPositiveAmount(t *testing.T) {
func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(t *testing.T) {
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1))
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
perUser := map[string]map[int64]int64{}
addLegacyStandardRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 101, 0)
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100)
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, 0)
if row := grid.metrics["2026-07-05"]["SA"]; row != nil {
t.Fatalf("zero legacy recharge should not create metric row: %#v", row)
}
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100)
row := grid.metrics["2026-07-05"]["SA"]
if row != nil {
t.Fatalf("non-positive legacy recharge should not create metric row: %#v", row)
if row == nil {
t.Fatalf("negative coin seller deduction should create metric row")
}
if row.RechargeUSDMinor != -100 || row.CoinSellerRechargeUSDMinor != -100 {
t.Fatalf("deduction should reduce recharge metrics: %#v", row)
}
if len(distinctUsers) != 0 {
t.Fatalf("non-positive legacy recharge should not create paid users: %#v", distinctUsers)
t.Fatalf("deduction should not create paid users: %#v", distinctUsers)
}
if len(perUser) != 0 {
t.Fatalf("non-positive legacy recharge should not create per-user recharge: %#v", perUser)
t.Fatalf("deduction should not create per-user recharge: %#v", perUser)
}
}

View File

@ -296,7 +296,7 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex
item := rechargeBillDTO{
AppCode: s.config.AppCode,
TransactionID: row.TransactionID,
RechargeType: "coin_seller_stock_purchase",
RechargeType: legacyCoinSellerBillRechargeType(row.Source),
Status: "succeeded",
ExternalRef: row.Source,
SellerUserID: row.UserID,
@ -318,6 +318,15 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex
return items, total, nil
}
func legacyCoinSellerBillRechargeType(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "freight_deduction", "coin_seller_stock_deduction":
return "coin_seller_stock_deduction"
default:
return "coin_seller_stock_purchase"
}
}
func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter bson.M, page int, pageSize int) ([]rechargeBillDTO, int64, error) {
total, err := s.collection.CountDocuments(ctx, filter)
if err != nil {

View File

@ -237,15 +237,25 @@ func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Cont
func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "aslan",
billTotal: 1,
bills: []dashboard.CoinSellerRechargeBill{{
TransactionID: "aslan-freight-purchase-99",
Source: "freight_purchase",
UserID: 2069828597070528514,
CountryCode: "PH",
USDMinorAmount: 132790,
CreatedAtMS: 1_783_530_000_000,
}},
billTotal: 2,
bills: []dashboard.CoinSellerRechargeBill{
{
TransactionID: "aslan-freight-purchase-99",
Source: "freight_purchase",
UserID: 2069828597070528514,
CountryCode: "PH",
USDMinorAmount: 132790,
CreatedAtMS: 1_783_530_000_000,
},
{
TransactionID: "aslan-freight-deduction-100",
Source: "freight_deduction",
UserID: 2069828597070528515,
CountryCode: "AE",
USDMinorAmount: -100000,
CreatedAtMS: 1_783_530_060_000,
},
},
}
source := &MongoRechargeBillSource{
config: aslanBillSourceConfig(),
@ -254,6 +264,11 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
RegionID: 301,
Name: "Philippines",
Countries: []string{"PH"},
}, {
AppCode: "aslan",
RegionID: 302,
Name: "United Arab Emirates",
Countries: []string{"AE"},
}}}},
}
source.SetDashboardCoinSellerSource(dashboardSource)
@ -268,7 +283,7 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
if err != nil {
t.Fatalf("ListRechargeBills: %v", err)
}
if total != 1 || len(items) != 1 {
if total != 2 || len(items) != 2 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
item := items[0]
@ -284,6 +299,19 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
t.Fatalf("paid facts mismatch: %+v", item)
}
deduction := items[1]
if deduction.RechargeType != "coin_seller_stock_deduction" || deduction.ProviderCode != "coin_seller" {
t.Fatalf("unexpected deduction mapping: %+v", deduction)
}
if deduction.TransactionID != "aslan-freight-deduction-100" || deduction.SellerUserID != 2069828597070528515 || deduction.USDMinorAmount != -100000 {
t.Fatalf("unexpected deduction fields: %+v", deduction)
}
if deduction.SellerRegionID != 302 || deduction.TargetRegionID != 302 {
t.Fatalf("deduction region mapping failed: %+v", deduction)
}
if deduction.UserPaidCurrencyCode != "USD" || deduction.UserPaidAmountMicro != -1_000_000_000 {
t.Fatalf("deduction paid facts mismatch: %+v", deduction)
}
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" {
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
}