fix: count Aslan paid coin-seller orders
This commit is contained in:
parent
e5f7e38f48
commit
08a0fec530
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user