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 new file mode 100644 index 00000000..3193df27 --- /dev/null +++ b/server/admin/internal/modules/payment/aslan_paid_coin_seller_source.go @@ -0,0 +1,391 @@ +package payment + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/config" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// financePaidCoinSellerRechargeSource 只暴露支付系统已经成功落单的币商充值事实。 +// WebConsole 手工填写的 USDT/金币和 legacy 钱包发货流水不实现这个接口,避免运营输入进入财务口径。 +type financePaidCoinSellerRechargeSource interface { + financePaidCoinSellerRechargeStats(context.Context, financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) + listFinancePaidCoinSellerRechargeBills(context.Context, financeCoinSellerRechargeQuery, int) ([]rechargeBillDTO, int64, error) +} + +func (h *Handler) financePaidCoinSellerRechargeSource(appCode string) financePaidCoinSellerRechargeSource { + if h == nil || appctx.Normalize(appCode) != "aslan" { + return nil + } + // 目前只有 Aslan legacy H5 的 FREIGHT_GOLD 真实支付单需要从 Mongo 补齐; + // 其他 App 继续使用自己的已校验后台订单或新 wallet H5 订单,避免无依据扩大旧平台口径。 + source, ok := h.billSources["aslan"] + if !ok || source == nil { + return nil + } + paidSource, _ := source.(financePaidCoinSellerRechargeSource) + return paidSource +} + +// financePaidCoinSellerFilter 只匹配有支付订单号、支付渠道、正数美元金额且已经成功的 FREIGHT_GOLD 单。 +// 这组字段来自 in_app_purchase_details 支付事实,不读取 user_freight_balance_running_water 的发货金额。 +func (s *MongoRechargeBillSource) financePaidCoinSellerFilter(ctx context.Context, query financeCoinSellerRechargeQuery) (bson.M, []string, bool, error) { + if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != "aslan" { + return nil, nil, false, nil + } + filter := bson.M{ + "sysOrigin": s.config.SysOrigin, + "status": "SUCCESS", + "trialPeriod": bson.M{"$ne": true}, + "amountUsd": bson.M{"$gt": 0}, + "orderId": bson.M{"$type": "string", "$ne": ""}, + "factory.factoryCode": bson.M{"$type": "string", "$ne": ""}, + } + conditions := bson.A{bson.M{"$or": legacyFreightCommodityMatchConditions()}} + if query.TargetUserID > 0 { + filter["acceptUserId"] = query.TargetUserID + } + if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + keywordConditions := bson.A{ + bson.M{"_id": keyword}, + bson.M{"orderId": keyword}, + } + if userID, err := strconv.ParseInt(keyword, 10, 64); err == nil && userID > 0 { + keywordConditions = append(keywordConditions, bson.M{"_id": userID}, bson.M{"acceptUserId": userID}) + } + conditions = append(conditions, bson.M{"$or": keywordConditions}) + } + paidAt := legacyPaidCoinSellerAtExpression() + timeConditions := bson.A{} + if query.StartAtMS > 0 { + timeConditions = append(timeConditions, bson.M{"$gte": bson.A{paidAt, time.UnixMilli(query.StartAtMS)}}) + } + if query.EndAtMS > 0 { + timeConditions = append(timeConditions, bson.M{"$lt": bson.A{paidAt, time.UnixMilli(query.EndAtMS)}}) + } + if len(timeConditions) == 1 { + conditions = append(conditions, bson.M{"$expr": timeConditions[0]}) + } else if len(timeConditions) > 1 { + conditions = append(conditions, bson.M{"$expr": bson.M{"$and": timeConditions}}) + } + filter["$and"] = conditions + + if query.RegionID <= 0 { + return filter, nil, true, nil + } + codes, handled, err := s.regionCountryCodes(ctx, query.RegionID) + if err != nil { + return nil, nil, false, err + } + if !handled || len(codes) == 0 { + return nil, nil, false, nil + } + return filter, codes, true, nil +} + +func legacyFreightCommodityMatchConditions() bson.A { + conditions := make(bson.A, 0, len(legacyFreightCommodityFields)) + for _, field := range legacyFreightCommodityFields { + conditions = append(conditions, bson.M{field: bson.M{"$in": legacyFreightCommodityTypes}}) + } + return conditions +} + +// updateTime 是支付回调把订单推进 SUCCESS 的时间;旧单缺失时才依次回退 purchaseDateMs/createTime。 +func legacyPaidCoinSellerAtExpression() bson.M { + return bson.M{"$ifNull": bson.A{ + "$updateTime", + bson.M{"$ifNull": bson.A{"$purchaseDateMs", "$createTime"}}, + }} +} + +func legacyFreightCoinAggregateExpression() bson.M { + typedTotal := bson.M{"$reduce": bson.M{ + "input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}}, + "initialValue": int64(0), + "in": bson.M{"$add": bson.A{ + "$$value", + bson.M{"$cond": bson.A{ + bson.M{"$or": bson.A{ + bson.M{"$in": bson.A{"$$this.code", legacyFreightCommodityTypes}}, + bson.M{"$in": bson.A{"$$this.name", legacyFreightCommodityTypes}}, + }}, + bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": int64(0), "onNull": int64(0)}}, + int64(0), + }}, + }}, + }} + allNumericTotal := bson.M{"$reduce": bson.M{ + "input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}}, + "initialValue": int64(0), + "in": bson.M{"$add": bson.A{ + "$$value", + bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": int64(0), "onNull": int64(0)}}, + }}, + }} + return bson.M{"$let": bson.M{ + "vars": bson.M{"typedTotal": typedTotal, "allNumericTotal": allNumericTotal}, + "in": bson.M{"$cond": bson.A{ + bson.M{"$gt": bson.A{"$$typedTotal", 0}}, + "$$typedTotal", + bson.M{"$cond": bson.A{ + bson.M{"$in": bson.A{"$trackCommodityType", legacyFreightCommodityTypes}}, + "$$allNumericTotal", + int64(0), + }}, + }}, + }} +} + +func (s *MongoRechargeBillSource) financePaidCoinSellerRechargeStats(ctx context.Context, query financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) { + stats := financeCoinSellerRechargeStats{Daily: map[string]rechargeBillSummaryBucketDTO{}} + filter, regionCodes, matchable, err := s.financePaidCoinSellerFilter(ctx, query) + if err != nil || !matchable { + return stats, err + } + base := mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}} + if len(regionCodes) > 0 { + base = append(base, legacyRegionLookupStages(regionCodes)...) + } else { + base = append(base, legacyCountryResolveStages()...) + } + tzOffsetMinutes := query.TzOffsetMinutes + if tzOffsetMinutes == 0 { + tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes + } + pipeline := append(base, + bson.D{{Key: "$addFields", Value: bson.M{ + "financePaidAt": legacyPaidCoinSellerAtExpression(), + "financeCoinAmount": legacyFreightCoinAggregateExpression(), + }}}, + bson.D{{Key: "$facet", Value: bson.M{ + "total": mongo.Pipeline{ + bson.D{{Key: "$group", Value: bson.M{ + "_id": nil, + "billCount": bson.M{"$sum": 1}, + "usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}}, + "coin": bson.M{"$sum": "$financeCoinAmount"}, + }}}, + }, + "daily": mongo.Pipeline{ + bson.D{{Key: "$group", Value: bson.M{ + "_id": bson.M{"$dateToString": bson.M{ + "format": "%Y-%m-%d", + "date": "$financePaidAt", + "timezone": legacyTimezoneOffset(tzOffsetMinutes), + }}, + "billCount": bson.M{"$sum": 1}, + "usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}}, + "coin": bson.M{"$sum": "$financeCoinAmount"}, + }}}, + }, + "regions": mongo.Pipeline{ + bson.D{{Key: "$group", Value: bson.M{ + "_id": "$legacyBillCountry", + "billCount": bson.M{"$sum": 1}, + "usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}}, + }}}, + }, + }}}, + ) + cursor, err := s.collection.Aggregate(ctx, pipeline) + if err != nil { + return stats, fmt.Errorf("aggregate Aslan paid coin seller bills: %w", err) + } + defer cursor.Close(ctx) + type bucketRow struct { + Date string `bson:"_id"` + BillCount int64 `bson:"billCount"` + USD any `bson:"usd"` + Coin int64 `bson:"coin"` + } + type regionRow struct { + Country string `bson:"_id"` + BillCount int64 `bson:"billCount"` + USD any `bson:"usd"` + } + var result struct { + Total []bucketRow `bson:"total"` + Daily []bucketRow `bson:"daily"` + Regions []regionRow `bson:"regions"` + } + if cursor.Next(ctx) { + if err := cursor.Decode(&result); err != nil { + return stats, fmt.Errorf("decode Aslan paid coin seller stats: %w", err) + } + } + if err := cursor.Err(); err != nil { + return stats, fmt.Errorf("iterate Aslan paid coin seller stats: %w", err) + } + if len(result.Total) > 0 { + stats.addBucket(rechargeBillSummaryBucketDTO{ + BillCount: result.Total[0].BillCount, + CoinAmount: result.Total[0].Coin, + USDMinorAmount: legacyDecimalToMinor(result.Total[0].USD), + }) + } + for _, row := range result.Daily { + stats.addDaily(row.Date, rechargeBillSummaryBucketDTO{ + BillCount: row.BillCount, + CoinAmount: row.Coin, + USDMinorAmount: legacyDecimalToMinor(row.USD), + }) + } + countryToRegion := s.legacyCountryRegionIndex(ctx) + for _, row := range result.Regions { + region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.Country))] + if !ok { + continue + } + stats.addRegion(region.RegionID, row.BillCount, legacyDecimalToMinor(row.USD)) + } + return stats, nil +} + +func (s *MongoRechargeBillSource) listFinancePaidCoinSellerRechargeBills(ctx context.Context, query financeCoinSellerRechargeQuery, limit int) ([]rechargeBillDTO, int64, error) { + filter, regionCodes, matchable, err := s.financePaidCoinSellerFilter(ctx, query) + if err != nil || !matchable { + return []rechargeBillDTO{}, 0, err + } + base := mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}} + var total int64 + if len(regionCodes) == 0 { + total, err = s.collection.CountDocuments(ctx, filter) + } else { + base = append(base, legacyRegionLookupStages(regionCodes)...) + countCursor, countErr := s.collection.Aggregate(ctx, append(clonePipeline(base), bson.D{{Key: "$count", Value: "n"}})) + if countErr != nil { + return nil, 0, fmt.Errorf("count Aslan paid coin seller bills by region: %w", countErr) + } + if countCursor.Next(ctx) { + var row struct { + N int64 `bson:"n"` + } + if decodeErr := countCursor.Decode(&row); decodeErr != nil { + countCursor.Close(ctx) + return nil, 0, fmt.Errorf("decode Aslan paid coin seller bill count: %w", decodeErr) + } + total = row.N + } + if countErr := countCursor.Err(); countErr != nil { + countCursor.Close(ctx) + return nil, 0, fmt.Errorf("iterate Aslan paid coin seller bill count: %w", countErr) + } + countCursor.Close(ctx) + } + if err != nil { + return nil, 0, fmt.Errorf("count Aslan paid coin seller bills: %w", err) + } + if total == 0 || limit < 1 { + return []rechargeBillDTO{}, total, nil + } + base = append(base, + bson.D{{Key: "$addFields", Value: bson.M{"financePaidAt": legacyPaidCoinSellerAtExpression()}}}, + bson.D{{Key: "$sort", Value: bson.D{{Key: "financePaidAt", Value: -1}, {Key: "_id", Value: -1}}}}, + bson.D{{Key: "$limit", Value: int64(limit)}}, + ) + if len(regionCodes) == 0 { + // 无区域筛选时先分页再查用户国家,避免为整段历史订单执行 $lookup。 + base = append(base, legacyCountryResolveStages()...) + } + cursor, err := s.collection.Aggregate(ctx, base, options.Aggregate().SetAllowDiskUse(false)) + if err != nil { + return nil, 0, fmt.Errorf("query Aslan paid coin seller bills: %w", err) + } + defer cursor.Close(ctx) + countryToRegion := s.legacyCountryRegionIndex(ctx) + items := make([]rechargeBillDTO, 0, limit) + for cursor.Next(ctx) { + var document legacyPurchaseDocument + if err := cursor.Decode(&document); err != nil { + return nil, 0, fmt.Errorf("decode Aslan paid coin seller bill: %w", err) + } + item := legacyPaidCoinSellerRechargeBillDTO(s.config, document) + if region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(document.LegacyBillCountry))]; ok { + item.SellerRegionID = region.RegionID + item.TargetRegionID = region.RegionID + } + items = append(items, item) + } + if err := cursor.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate Aslan paid coin seller bills: %w", err) + } + return items, total, nil +} + +func legacyPaidCoinSellerRechargeBillDTO(sourceConfig config.FinanceBillSourceConfig, document legacyPurchaseDocument) rechargeBillDTO { + paidAtMS := legacyPaidCoinSellerAtMS(document) + factoryCode := strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode)) + paidCurrency := strings.ToUpper(strings.TrimSpace(document.Currency)) + if paidCurrency == "" { + paidCurrency = "USD" + } + return rechargeBillDTO{ + AppCode: sourceConfig.AppCode, + TransactionID: legacyDocumentID(document.ID), + RechargeType: "coin_seller", + Status: "succeeded", + ExternalRef: strings.TrimSpace(document.OrderID), + SellerUserID: document.AcceptUserID, + CurrencyCode: "USD", + CoinAmount: legacyFreightCoinAmount(document), + USDMinorAmount: legacyDecimalToMinor(document.AmountUSD), + ProviderAmountMinor: legacyDecimalToMinor(document.Amount), + CreatedAtMS: paidAtMS, + ProviderCode: legacyProviderCode(factoryCode), + UserPaidCurrencyCode: paidCurrency, + UserPaidAmountMicro: legacyDecimalToMicro(document.Amount), + PaidSyncedAtMS: paidAtMS, + PolicyVersion: strings.ToUpper(strings.TrimSpace(document.TrackCommodityType)), + } +} + +func legacyPaidCoinSellerAtMS(document legacyPurchaseDocument) int64 { + for _, value := range []time.Time{document.UpdateTime, document.PurchaseDateMS, document.CreateTime} { + if !value.IsZero() { + return value.UnixMilli() + } + } + return 0 +} + +func legacyFreightCoinAmount(document legacyPurchaseDocument) int64 { + var typedTotal int64 + var allNumericTotal int64 + for _, product := range document.Products { + amount, err := strconv.ParseInt(strings.TrimSpace(product.Content), 10, 64) + if err != nil || amount <= 0 { + continue + } + allNumericTotal += amount + if legacyFreightCommodityType(product.Code) || legacyFreightCommodityType(product.Name) { + typedTotal += amount + } + } + if typedTotal > 0 { + return typedTotal + } + // 极老订单只在顶层 trackCommodityType 写类型;此时产品中的正数 content 才能作为该账单的发货金币。 + if legacyFreightCommodityType(document.TrackCommodityType) { + return allNumericTotal + } + return 0 +} + +func legacyFreightCommodityType(value string) bool { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "FREIGHT_GOLD", "FREIGHT_GOLD_SUPER": + return true + default: + return false + } +} 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 new file mode 100644 index 00000000..bb0274c5 --- /dev/null +++ b/server/admin/internal/modules/payment/aslan_paid_coin_seller_source_test.go @@ -0,0 +1,209 @@ +package payment + +import ( + "context" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func TestAslanPaidCoinSellerFilterUsesPaymentFacts(t *testing.T) { + source := &MongoRechargeBillSource{ + collection: &mongo.Collection{}, + config: aslanBillSourceConfig(), + regionResolvers: []legacyRegionCountryResolver{staticLegacyRegionResolver{ + codes: []string{"eg"}, handled: true, + }}, + } + filter, regionCodes, matchable, err := source.financePaidCoinSellerFilter(context.Background(), financeCoinSellerRechargeQuery{ + Keyword: "x5ctfib7800289mrkm5fco0A10001910", + RegionID: 1001, + StartAtMS: 1_784_000_000_000, + EndAtMS: 1_785_000_000_000, + TargetUserID: 2_070_458_148_788_715_521, + }) + if err != nil || !matchable { + t.Fatalf("financePaidCoinSellerFilter err=%v matchable=%v", err, matchable) + } + if filter["sysOrigin"] != "ATYOU" || filter["status"] != "SUCCESS" { + t.Fatalf("payment status boundary missing: %#v", filter) + } + if filter["acceptUserId"] != int64(2_070_458_148_788_715_521) { + t.Fatalf("seller filter missing: %#v", filter) + } + if len(regionCodes) != 1 || regionCodes[0] != "EG" { + t.Fatalf("region code mismatch: %#v", regionCodes) + } + conditions, ok := filter["$and"].(bson.A) + if !ok || len(conditions) != 3 { + t.Fatalf("freight/keyword/time conditions missing: %#v", filter) + } + if got := len(legacyFreightCommodityMatchConditions()); got != len(legacyFreightCommodityFields) { + t.Fatalf("freight compatibility fields=%d want=%d", got, len(legacyFreightCommodityFields)) + } + if _, exists := filter["recharge_type"]; exists { + t.Fatalf("wallet running-water recharge_type must not enter Mongo bill filter: %#v", filter) + } +} + +func TestLegacyPaidCoinSellerRechargeBillDTOUsesOriginalBillAmounts(t *testing.T) { + providerAmount, err := bson.ParseDecimal128("4726") + if err != nil { + t.Fatalf("parse provider amount: %v", err) + } + usdAmount, err := bson.ParseDecimal128("100.00") + if err != nil { + t.Fatalf("parse usd amount: %v", err) + } + createdAt := time.Date(2026, 7, 14, 12, 14, 26, 499_000_000, time.UTC) + paidAt := time.Date(2026, 7, 14, 12, 15, 14, 777_000_000, time.UTC) + document := legacyPurchaseDocument{ + ID: int64(2_077_003_764_199_862_273), + OrderID: "x5ctfib7800289mrkm5fco0A10001910", + AcceptUserID: 2_070_458_148_788_715_521, + CountryCode: "EG", + Currency: "EGP", + Amount: providerAmount, + AmountUSD: usdAmount, + Status: "SUCCESS", + TrackCommodityType: "FREIGHT_GOLD", + Factory: legacyPurchaseFactory{Platform: "H5", FactoryCode: "MIFA_PAY"}, + Products: []legacyPurchaseProduct{{Code: "FREIGHT_GOLD", Name: "FREIGHT_GOLD", Content: "8800000"}}, + CreateTime: createdAt, + UpdateTime: paidAt, + LegacyBillCountry: "EG", + } + dto := legacyPaidCoinSellerRechargeBillDTO(aslanBillSourceConfig(), document) + if dto.RechargeType != "coin_seller" || dto.Status != "succeeded" { + t.Fatalf("coin seller bill identity mismatch: %+v", dto) + } + if dto.ExternalRef != document.OrderID || dto.SellerUserID != document.AcceptUserID { + t.Fatalf("provider/seller identity mismatch: %+v", dto) + } + if dto.USDMinorAmount != 10_000 || dto.CoinAmount != 8_800_000 { + t.Fatalf("must use original bill USD/product amount: %+v", dto) + } + if dto.ProviderAmountMinor != 472_600 || dto.UserPaidCurrencyCode != "EGP" || dto.UserPaidAmountMicro != 4_726_000_000 { + t.Fatalf("provider paid amount mismatch: %+v", dto) + } + if dto.CreatedAtMS != paidAt.UnixMilli() || dto.PaidSyncedAtMS != paidAt.UnixMilli() { + t.Fatalf("successful payment time mismatch: %+v", dto) + } + if dto.PolicyVersion != "FREIGHT_GOLD" { + t.Fatalf("commodity type mismatch: %+v", dto) + } +} + +func TestLegacyFreightCoinAmountFallsBackToTopLevelType(t *testing.T) { + document := legacyPurchaseDocument{ + TrackCommodityType: "FREIGHT_GOLD", + Products: []legacyPurchaseProduct{ + {Name: "LEGACY_PRODUCT", Content: "8800000"}, + {Name: "PROPS", Content: "not-a-number"}, + }, + } + if got := legacyFreightCoinAmount(document); got != 8_800_000 { + t.Fatalf("legacy freight coin amount=%d", got) + } +} + +type fakeAslanPaidCoinSellerSource struct { + stats financeCoinSellerRechargeStats + items []rechargeBillDTO + total int64 + statsCalls int + listCalls int +} + +func (s *fakeAslanPaidCoinSellerSource) AppCode() string { return "aslan" } +func (s *fakeAslanPaidCoinSellerSource) AppName() string { return "Aslan" } +func (s *fakeAslanPaidCoinSellerSource) ListRechargeBills(context.Context, legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) { + return []rechargeBillDTO{}, 0, nil +} +func (s *fakeAslanPaidCoinSellerSource) SummarizeRechargeBills(context.Context, legacyRechargeBillQuery) (rechargeBillSummaryDTO, error) { + return rechargeBillSummaryDTO{}, nil +} +func (s *fakeAslanPaidCoinSellerSource) SupportsGooglePaidSync() bool { return false } +func (s *fakeAslanPaidCoinSellerSource) ListPendingGooglePaidTransactionIDs(context.Context, int) ([]string, error) { + return []string{}, nil +} +func (s *fakeAslanPaidCoinSellerSource) RefreshGooglePaidDetails(context.Context, []string) ([]googleRechargePaidDTO, error) { + return []googleRechargePaidDTO{}, nil +} +func (s *fakeAslanPaidCoinSellerSource) Overview(context.Context, legacyRechargeBillQuery, int32) (rechargeBillOverviewDTO, error) { + return rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}, nil +} +func (s *fakeAslanPaidCoinSellerSource) financePaidCoinSellerRechargeStats(context.Context, financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) { + s.statsCalls++ + return s.stats, nil +} +func (s *fakeAslanPaidCoinSellerSource) listFinancePaidCoinSellerRechargeBills(context.Context, financeCoinSellerRechargeQuery, int) ([]rechargeBillDTO, int64, error) { + s.listCalls++ + return append([]rechargeBillDTO(nil), s.items...), s.total, nil +} + +func TestFinanceCoinSellerIncludesAslanPaidBills(t *testing.T) { + source := &fakeAslanPaidCoinSellerSource{ + stats: financeCoinSellerRechargeStats{ + Total: rechargeBillSummaryBucketDTO{BillCount: 1, CoinAmount: 8_800_000, USDMinorAmount: 10_000}, + Daily: map[string]rechargeBillSummaryBucketDTO{ + "2026-07-14": {BillCount: 1, CoinAmount: 8_800_000, USDMinorAmount: 10_000}, + }, + Regions: []rechargeBillRegionBucketDTO{{RegionID: 9, BillCount: 1, UsdMinorAmount: 10_000}}, + }, + items: []rechargeBillDTO{{ + AppCode: "aslan", TransactionID: "2077003764199862273", ExternalRef: "x5ctfib7800289mrkm5fco0A10001910", + RechargeType: "coin_seller", ProviderCode: "mifa_pay", USDMinorAmount: 10_000, CoinAmount: 8_800_000, + }}, + total: 1, + } + handler := New(nil, nil, nil, nil, nil, WithRechargeBillSources(source)) + stats, err := handler.financeCoinSellerRechargeStats(context.Background(), "aslan", financeCoinSellerRechargeQuery{}) + if err != nil { + t.Fatalf("financeCoinSellerRechargeStats: %v", err) + } + if stats.Total.USDMinorAmount != 10_000 || stats.Total.CoinAmount != 8_800_000 || stats.Total.BillCount != 1 { + t.Fatalf("paid bill stats missing: %+v", stats.Total) + } + if stats.Daily["2026-07-14"].USDMinorAmount != 10_000 || len(stats.Regions) != 1 || stats.Regions[0].RegionID != 9 { + t.Fatalf("paid bill dimensions missing: %+v", stats) + } + items, total, err := handler.listFinanceCoinSellerRechargeBills(context.Background(), "aslan", financeCoinSellerRechargeQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("listFinanceCoinSellerRechargeBills: %v", err) + } + if total != 1 || len(items) != 1 || items[0].USDMinorAmount != 10_000 || items[0].ExternalRef != "x5ctfib7800289mrkm5fco0A10001910" { + t.Fatalf("paid bill list mismatch: total=%d items=%+v", total, items) + } + if source.statsCalls != 1 || source.listCalls != 1 { + t.Fatalf("paid source calls mismatch: stats=%d list=%d", source.statsCalls, source.listCalls) + } +} + +func TestFinanceCoinSellerBillDedupKeepsOriginalPaymentBill(t *testing.T) { + original := rechargeBillDTO{TransactionID: "mongo", ExternalRef: "ORDER-1", ProviderCode: "MIFA_PAY", USDMinorAmount: 10_000} + operatorBound := rechargeBillDTO{TransactionID: "admin", ExternalRef: "order-1", ProviderCode: "mifapay", USDMinorAmount: 11_000} + items, duplicateCount := deduplicateFinanceCoinSellerRechargeBills([]rechargeBillDTO{original, operatorBound}) + if duplicateCount != 1 || len(items) != 1 { + t.Fatalf("dedup mismatch: duplicates=%d items=%+v", duplicateCount, items) + } + if items[0].TransactionID != "mongo" || items[0].USDMinorAmount != 10_000 { + t.Fatalf("original payment bill must win: %+v", items[0]) + } +} + +func TestFinancePaidCoinSellerSourceIsAslanOnly(t *testing.T) { + source := &fakeAslanPaidCoinSellerSource{} + handler := New(nil, nil, nil, nil, nil, WithRechargeBillSources(source)) + if handler.financePaidCoinSellerRechargeSource("aslan") == nil { + t.Fatalf("Aslan paid source should be registered") + } + if handler.financePaidCoinSellerRechargeSource("yumi") != nil { + t.Fatalf("Aslan paid source must not expand to Yumi") + } +} + +var _ RechargeBillSource = (*fakeAslanPaidCoinSellerSource)(nil) +var _ financePaidCoinSellerRechargeSource = (*fakeAslanPaidCoinSellerSource)(nil) 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 dae7a98d..7a52d287 100644 --- a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go @@ -145,6 +145,21 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st return stats, nil } query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID) + if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil { + paidStats, err := paidSource.financePaidCoinSellerRechargeStats(ctx, query) + if err != nil { + return stats, err + } + // Aslan legacy H5 的 SUCCESS/FREIGHT_GOLD Mongo 订单是支付账单事实; + // 这里只合并该事实,不读取 WebConsole 手填金额或钱包发货流水。 + stats.addBucket(paidStats.Total) + for date, bucket := range paidStats.Daily { + stats.addDaily(date, bucket) + } + for _, region := range paidStats.Regions { + stats.addRegion(region.RegionID, region.BillCount, region.UsdMinorAmount) + } + } if h != nil && h.store != nil { repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{ AppCode: appCode, @@ -286,6 +301,15 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) items := make([]rechargeBillDTO, 0, prefetch*2) var total int64 + if paidSource := h.financePaidCoinSellerRechargeSource(appCode); paidSource != nil { + paidItems, paidTotal, err := paidSource.listFinancePaidCoinSellerRechargeBills(ctx, query, prefetch) + if err != nil { + return nil, 0, err + } + // 真实 H5 账单先进入结果;若同一个支付订单后来又被后台绑定,去重时保留原始账单金额和商品金币。 + items = append(items, paidItems...) + total += paidTotal + } if h != nil && h.store != nil { orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{ Page: 1, @@ -318,6 +342,14 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod total += h5Total items = append(items, h5Items...) } + var duplicateCount int + items, duplicateCount = deduplicateFinanceCoinSellerRechargeBills(items) + if duplicateCount > 0 { + total -= int64(duplicateCount) + if total < 0 { + total = 0 + } + } sortRechargeBills(items) offset := (page - 1) * pageSize if offset >= len(items) { @@ -330,6 +362,39 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod return items[offset:end], total, nil } +func deduplicateFinanceCoinSellerRechargeBills(items []rechargeBillDTO) ([]rechargeBillDTO, int) { + seen := make(map[string]struct{}, len(items)) + out := make([]rechargeBillDTO, 0, len(items)) + duplicates := 0 + for _, item := range items { + identity := financeCoinSellerRechargeBillIdentity(item) + if identity == "" { + out = append(out, item) + continue + } + if _, exists := seen[identity]; exists { + duplicates++ + continue + } + seen[identity] = struct{}{} + out = append(out, item) + } + return out, duplicates +} + +func financeCoinSellerRechargeBillIdentity(item rechargeBillDTO) string { + externalRef := strings.ToLower(strings.TrimSpace(item.ExternalRef)) + if externalRef != "" { + provider := strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(item.ProviderCode))) + return "provider:" + provider + ":" + externalRef + } + transactionID := strings.ToLower(strings.TrimSpace(item.TransactionID)) + if transactionID == "" { + return "" + } + return "transaction:" + transactionID +} + func (h *Handler) listFinanceAllRechargeBills(ctx context.Context, appCode string, source RechargeBillSource, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) { page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) diff --git a/server/admin/internal/modules/payment/legacy_bill_source.go b/server/admin/internal/modules/payment/legacy_bill_source.go index 41e2e979..4687f088 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source.go +++ b/server/admin/internal/modules/payment/legacy_bill_source.go @@ -308,18 +308,20 @@ func (s dashboardCoinSellerRechargeStatsSource) CoinSellerRechargeStats(ctx cont // legacyPurchaseDocument 对应 chatapp3-java 的 InAppPurchaseDetails Mongo 文档(只取展示需要的字段)。 type legacyPurchaseDocument struct { - ID any `bson:"_id"` - OrderID string `bson:"orderId"` - AcceptUserID int64 `bson:"acceptUserId"` - CountryCode string `bson:"countryCode"` - Currency string `bson:"currency"` - Amount any `bson:"amount"` - AmountUSD any `bson:"amountUsd"` - Status string `bson:"status"` - Factory legacyPurchaseFactory `bson:"factory"` - Products []legacyPurchaseProduct `bson:"products"` - CreateTime time.Time `bson:"createTime"` - PurchaseDateMS time.Time `bson:"purchaseDateMs"` + ID any `bson:"_id"` + OrderID string `bson:"orderId"` + AcceptUserID int64 `bson:"acceptUserId"` + CountryCode string `bson:"countryCode"` + Currency string `bson:"currency"` + Amount any `bson:"amount"` + AmountUSD any `bson:"amountUsd"` + Status string `bson:"status"` + TrackCommodityType string `bson:"trackCommodityType"` + Factory legacyPurchaseFactory `bson:"factory"` + Products []legacyPurchaseProduct `bson:"products"` + CreateTime time.Time `bson:"createTime"` + UpdateTime time.Time `bson:"updateTime"` + PurchaseDateMS time.Time `bson:"purchaseDateMs"` // LegacyBillCountry 由聚合阶段(legacyCountryResolveStages)解析:账单国家码优先,回退付款用户资料。 LegacyBillCountry string `bson:"legacyBillCountry"` } @@ -330,6 +332,7 @@ type legacyPurchaseFactory struct { } type legacyPurchaseProduct struct { + Code string `bson:"code"` Name string `bson:"name"` Content string `bson:"content"` }