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
|
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)
|
||||||
|
|||||||
@ -308,18 +308,20 @@ func (s dashboardCoinSellerRechargeStatsSource) CoinSellerRechargeStats(ctx cont
|
|||||||
|
|
||||||
// legacyPurchaseDocument 对应 chatapp3-java 的 InAppPurchaseDetails Mongo 文档(只取展示需要的字段)。
|
// legacyPurchaseDocument 对应 chatapp3-java 的 InAppPurchaseDetails Mongo 文档(只取展示需要的字段)。
|
||||||
type legacyPurchaseDocument struct {
|
type legacyPurchaseDocument struct {
|
||||||
ID any `bson:"_id"`
|
ID any `bson:"_id"`
|
||||||
OrderID string `bson:"orderId"`
|
OrderID string `bson:"orderId"`
|
||||||
AcceptUserID int64 `bson:"acceptUserId"`
|
AcceptUserID int64 `bson:"acceptUserId"`
|
||||||
CountryCode string `bson:"countryCode"`
|
CountryCode string `bson:"countryCode"`
|
||||||
Currency string `bson:"currency"`
|
Currency string `bson:"currency"`
|
||||||
Amount any `bson:"amount"`
|
Amount any `bson:"amount"`
|
||||||
AmountUSD any `bson:"amountUsd"`
|
AmountUSD any `bson:"amountUsd"`
|
||||||
Status string `bson:"status"`
|
Status string `bson:"status"`
|
||||||
Factory legacyPurchaseFactory `bson:"factory"`
|
TrackCommodityType string `bson:"trackCommodityType"`
|
||||||
Products []legacyPurchaseProduct `bson:"products"`
|
Factory legacyPurchaseFactory `bson:"factory"`
|
||||||
CreateTime time.Time `bson:"createTime"`
|
Products []legacyPurchaseProduct `bson:"products"`
|
||||||
PurchaseDateMS time.Time `bson:"purchaseDateMs"`
|
CreateTime time.Time `bson:"createTime"`
|
||||||
|
UpdateTime time.Time `bson:"updateTime"`
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user