fix: count Aslan paid coin-seller orders

This commit is contained in:
zhx 2026-07-15 14:07:23 +08:00
parent e5f7e38f48
commit 08a0fec530
4 changed files with 680 additions and 12 deletions

View File

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

View File

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

View File

@ -145,6 +145,21 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
return stats, nil return stats, nil
} }
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID) 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 { if h != nil && h.store != nil {
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{ repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode, AppCode: appCode,
@ -286,6 +301,15 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*2) items := make([]rechargeBillDTO, 0, prefetch*2)
var total int64 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 { if h != nil && h.store != nil {
orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{ orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{
Page: 1, Page: 1,
@ -318,6 +342,14 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
total += h5Total total += h5Total
items = append(items, h5Items...) items = append(items, h5Items...)
} }
var duplicateCount int
items, duplicateCount = deduplicateFinanceCoinSellerRechargeBills(items)
if duplicateCount > 0 {
total -= int64(duplicateCount)
if total < 0 {
total = 0
}
}
sortRechargeBills(items) sortRechargeBills(items)
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
if offset >= len(items) { if offset >= len(items) {
@ -330,6 +362,39 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
return items[offset:end], total, nil 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) { 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) page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)

View File

@ -316,9 +316,11 @@ type legacyPurchaseDocument struct {
Amount any `bson:"amount"` Amount any `bson:"amount"`
AmountUSD any `bson:"amountUsd"` AmountUSD any `bson:"amountUsd"`
Status string `bson:"status"` Status string `bson:"status"`
TrackCommodityType string `bson:"trackCommodityType"`
Factory legacyPurchaseFactory `bson:"factory"` Factory legacyPurchaseFactory `bson:"factory"`
Products []legacyPurchaseProduct `bson:"products"` Products []legacyPurchaseProduct `bson:"products"`
CreateTime time.Time `bson:"createTime"` CreateTime time.Time `bson:"createTime"`
UpdateTime time.Time `bson:"updateTime"`
PurchaseDateMS time.Time `bson:"purchaseDateMs"` PurchaseDateMS time.Time `bson:"purchaseDateMs"`
// LegacyBillCountry 由聚合阶段legacyCountryResolveStages解析账单国家码优先回退付款用户资料。 // LegacyBillCountry 由聚合阶段legacyCountryResolveStages解析账单国家码优先回退付款用户资料。
LegacyBillCountry string `bson:"legacyBillCountry"` LegacyBillCountry string `bson:"legacyBillCountry"`
@ -330,6 +332,7 @@ type legacyPurchaseFactory struct {
} }
type legacyPurchaseProduct struct { type legacyPurchaseProduct struct {
Code string `bson:"code"`
Name string `bson:"name"` Name string `bson:"name"`
Content string `bson:"content"` Content string `bson:"content"`
} }