402 lines
14 KiB
Go
402 lines
14 KiB
Go
package payment
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"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"
|
||
)
|
||
|
||
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义,legacy 源不适用。
|
||
type legacyRechargeBillQuery struct {
|
||
Keyword string
|
||
RegionID int64
|
||
StartAtMS int64
|
||
EndAtMS int64
|
||
Page int
|
||
PageSize int
|
||
}
|
||
|
||
// RechargeBillSource 是 legacy App(Yumi 等 likei 平台)充值账单的外部只读来源;
|
||
// 命中 app_code 后,财务充值明细和汇总直接走该来源,不再请求 wallet-service。
|
||
type RechargeBillSource interface {
|
||
AppCode() string
|
||
AppName() string
|
||
ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error)
|
||
SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error)
|
||
}
|
||
|
||
// legacyRegionCountryResolver 复用财务范围的区域→国家映射(MongoMoneyRegionSource 已实现)。
|
||
type legacyRegionCountryResolver interface {
|
||
CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error)
|
||
}
|
||
|
||
// MongoRechargeBillSource 读取 likei Mongo 的 in_app_purchase_details 集合作为充值账单事实。
|
||
type MongoRechargeBillSource struct {
|
||
collection *mongo.Collection
|
||
config config.FinanceBillSourceConfig
|
||
regionResolvers []legacyRegionCountryResolver
|
||
}
|
||
|
||
// NewMongoRechargeBillSource 创建 legacy 账单源;regionSources 用于把后台区域 ID 解析成国家码过滤条件。
|
||
func NewMongoRechargeBillSource(database *mongo.Database, sourceConfig config.FinanceBillSourceConfig, regionSources ...MoneyRegionSource) *MongoRechargeBillSource {
|
||
if database == nil || !sourceConfig.Enabled {
|
||
return nil
|
||
}
|
||
collectionName := strings.TrimSpace(sourceConfig.MongoCollection)
|
||
if collectionName == "" {
|
||
collectionName = "in_app_purchase_details"
|
||
}
|
||
sourceConfig.AppCode = appctx.Normalize(sourceConfig.AppCode)
|
||
sourceConfig.AppName = strings.TrimSpace(sourceConfig.AppName)
|
||
sourceConfig.SysOrigin = strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
|
||
source := &MongoRechargeBillSource{collection: database.Collection(collectionName), config: sourceConfig}
|
||
for _, regionSource := range regionSources {
|
||
if resolver, ok := regionSource.(legacyRegionCountryResolver); ok && resolver != nil {
|
||
source.regionResolvers = append(source.regionResolvers, resolver)
|
||
}
|
||
}
|
||
return source
|
||
}
|
||
|
||
func (s *MongoRechargeBillSource) AppCode() string {
|
||
return s.config.AppCode
|
||
}
|
||
|
||
func (s *MongoRechargeBillSource) AppName() string {
|
||
if s.config.AppName != "" {
|
||
return s.config.AppName
|
||
}
|
||
return s.config.AppCode
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
type legacyPurchaseFactory struct {
|
||
Platform string `bson:"platform"`
|
||
FactoryCode string `bson:"factoryCode"`
|
||
}
|
||
|
||
type legacyPurchaseProduct struct {
|
||
Name string `bson:"name"`
|
||
Content string `bson:"content"`
|
||
}
|
||
|
||
func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
|
||
if s == nil || s.collection == nil {
|
||
return nil, 0, fmt.Errorf("legacy bill source is not configured")
|
||
}
|
||
filter, matchable, err := s.billFilter(ctx, query)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if !matchable {
|
||
return []rechargeBillDTO{}, 0, nil
|
||
}
|
||
total, err := s.collection.CountDocuments(ctx, filter)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||
}
|
||
page := query.Page
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
pageSize := query.PageSize
|
||
if pageSize < 1 {
|
||
pageSize = 10
|
||
}
|
||
cursor, err := s.collection.Find(ctx, filter, options.Find().
|
||
SetSort(bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}).
|
||
SetSkip(int64(page-1)*int64(pageSize)).
|
||
SetLimit(int64(pageSize)))
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||
}
|
||
defer cursor.Close(ctx)
|
||
|
||
items := make([]rechargeBillDTO, 0, pageSize)
|
||
for cursor.Next(ctx) {
|
||
var document legacyPurchaseDocument
|
||
if err := cursor.Decode(&document); err != nil {
|
||
return nil, 0, fmt.Errorf("decode legacy recharge bill for %s: %w", s.config.AppCode, err)
|
||
}
|
||
items = append(items, legacyRechargeBillDTO(s.config, document))
|
||
}
|
||
if err := cursor.Err(); err != nil {
|
||
return nil, 0, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error) {
|
||
if s == nil || s.collection == nil {
|
||
return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured")
|
||
}
|
||
filter, matchable, err := s.billFilter(ctx, query)
|
||
if err != nil {
|
||
return rechargeBillSummaryDTO{}, err
|
||
}
|
||
if !matchable {
|
||
return rechargeBillSummaryDTO{}, nil
|
||
}
|
||
// 金币数只统计 GOLD/CANDY 商品内容,口径对齐 chatapp3 的 firstGoldProduct;content 非数值时按 0 计入。
|
||
coinExpr := bson.M{"$reduce": bson.M{
|
||
"input": bson.M{"$filter": bson.M{
|
||
"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
|
||
"cond": bson.M{"$in": bson.A{"$$this.name", bson.A{"GOLD", "CANDY"}}},
|
||
}},
|
||
"initialValue": 0,
|
||
"in": bson.M{"$add": bson.A{
|
||
"$$value",
|
||
bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": 0, "onNull": 0}},
|
||
}},
|
||
}}
|
||
cursor, err := s.collection.Aggregate(ctx, mongo.Pipeline{
|
||
{{Key: "$match", Value: filter}},
|
||
{{Key: "$group", Value: bson.M{
|
||
"_id": bson.M{"$cond": bson.A{
|
||
bson.M{"$eq": bson.A{"$factory.factoryCode", "GOOGLE"}},
|
||
"google",
|
||
"third_party",
|
||
}},
|
||
"billCount": bson.M{"$sum": 1},
|
||
"usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
|
||
"coin": bson.M{"$sum": coinExpr},
|
||
}}},
|
||
})
|
||
if err != nil {
|
||
return rechargeBillSummaryDTO{}, fmt.Errorf("summarize legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||
}
|
||
defer cursor.Close(ctx)
|
||
|
||
summary := rechargeBillSummaryDTO{}
|
||
for cursor.Next(ctx) {
|
||
var row struct {
|
||
ID string `bson:"_id"`
|
||
BillCount int64 `bson:"billCount"`
|
||
USD any `bson:"usd"`
|
||
Coin int64 `bson:"coin"`
|
||
}
|
||
if err := cursor.Decode(&row); err != nil {
|
||
return rechargeBillSummaryDTO{}, fmt.Errorf("decode legacy recharge summary for %s: %w", s.config.AppCode, err)
|
||
}
|
||
bucket := rechargeBillSummaryBucketDTO{
|
||
BillCount: row.BillCount,
|
||
CoinAmount: row.Coin,
|
||
USDMinorAmount: legacyDecimalToMinor(row.USD),
|
||
}
|
||
if row.ID == "google" {
|
||
summary.GooglePlay = bucket
|
||
} else {
|
||
summary.ThirdParty.BillCount += bucket.BillCount
|
||
summary.ThirdParty.CoinAmount += bucket.CoinAmount
|
||
summary.ThirdParty.USDMinorAmount += bucket.USDMinorAmount
|
||
}
|
||
}
|
||
if err := cursor.Err(); err != nil {
|
||
return rechargeBillSummaryDTO{}, fmt.Errorf("iterate legacy recharge summary for %s: %w", s.config.AppCode, err)
|
||
}
|
||
summary.Total = rechargeBillSummaryBucketDTO{
|
||
BillCount: summary.GooglePlay.BillCount + summary.ThirdParty.BillCount,
|
||
CoinAmount: summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount,
|
||
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount,
|
||
}
|
||
return summary, nil
|
||
}
|
||
|
||
// billFilter 构造与 hyapp 账单列表同口径的 Mongo 过滤条件;返回 matchable=false 表示筛选注定无结果。
|
||
func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRechargeBillQuery) (bson.M, bool, error) {
|
||
filter := bson.M{
|
||
"sysOrigin": s.config.SysOrigin,
|
||
"status": "SUCCESS",
|
||
// 订阅免费试用不产生真实付款,与 Aslan 大屏充值口径保持一致。
|
||
"trialPeriod": bson.M{"$ne": true},
|
||
}
|
||
timeRange := bson.M{}
|
||
if query.StartAtMS > 0 {
|
||
timeRange["$gte"] = time.UnixMilli(query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
timeRange["$lt"] = time.UnixMilli(query.EndAtMS)
|
||
}
|
||
if len(timeRange) > 0 {
|
||
filter["createTime"] = timeRange
|
||
}
|
||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||
// legacy 集合没有 hyapp 交易号;关键词只精确匹配内部订单 ID 和三方订单号,避免大集合正则扫描。
|
||
filter["$or"] = bson.A{
|
||
bson.M{"_id": keyword},
|
||
bson.M{"orderId": keyword},
|
||
}
|
||
}
|
||
if query.RegionID > 0 {
|
||
codes, handled, err := s.regionCountryCodes(ctx, query.RegionID)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
if !handled || len(codes) == 0 {
|
||
return nil, false, nil
|
||
}
|
||
filter["countryCode"] = bson.M{"$in": codes}
|
||
}
|
||
return filter, true, nil
|
||
}
|
||
|
||
func (s *MongoRechargeBillSource) regionCountryCodes(ctx context.Context, regionID int64) ([]string, bool, error) {
|
||
for _, resolver := range s.regionResolvers {
|
||
codes, handled, err := resolver.CountryCodesForRegion(ctx, s.config.AppCode, regionID)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
if handled {
|
||
normalized := make([]string, 0, len(codes))
|
||
for _, code := range codes {
|
||
if code = strings.ToUpper(strings.TrimSpace(code)); code != "" {
|
||
normalized = append(normalized, code)
|
||
}
|
||
}
|
||
return normalized, true, nil
|
||
}
|
||
}
|
||
return nil, false, nil
|
||
}
|
||
|
||
// legacyRechargeBillDTO 把 likei 内购明细收敛成财务充值账单 DTO;金额统一按实付币种微单位、美金最小单位表达。
|
||
func legacyRechargeBillDTO(sourceConfig config.FinanceBillSourceConfig, document legacyPurchaseDocument) rechargeBillDTO {
|
||
createdAtMS := int64(0)
|
||
if !document.CreateTime.IsZero() {
|
||
createdAtMS = document.CreateTime.UnixMilli()
|
||
} else if !document.PurchaseDateMS.IsZero() {
|
||
createdAtMS = document.PurchaseDateMS.UnixMilli()
|
||
}
|
||
factoryCode := strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode))
|
||
dto := rechargeBillDTO{
|
||
AppCode: sourceConfig.AppCode,
|
||
TransactionID: legacyDocumentID(document.ID),
|
||
RechargeType: legacyRechargeType(factoryCode),
|
||
Status: "succeeded",
|
||
ExternalRef: strings.TrimSpace(document.OrderID),
|
||
UserID: document.AcceptUserID,
|
||
CurrencyCode: strings.ToUpper(strings.TrimSpace(document.Currency)),
|
||
CoinAmount: legacyGoldCoinAmount(document.Products),
|
||
USDMinorAmount: legacyDecimalToMinor(document.AmountUSD),
|
||
CreatedAtMS: createdAtMS,
|
||
ProviderCode: legacyProviderCode(factoryCode),
|
||
UserPaidCurrencyCode: strings.ToUpper(strings.TrimSpace(document.Currency)),
|
||
UserPaidAmountMicro: legacyDecimalToMicro(document.Amount),
|
||
// likei 平台没有可反推的三方扣款数据;标记已同步避免前端把 legacy 谷歌账单当成待查询。
|
||
PaidSyncedAtMS: createdAtMS,
|
||
}
|
||
return dto
|
||
}
|
||
|
||
func legacyDocumentID(value any) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
if text, ok := value.(string); ok {
|
||
return text
|
||
}
|
||
if objectID, ok := value.(bson.ObjectID); ok {
|
||
return objectID.Hex()
|
||
}
|
||
return strings.TrimSpace(fmt.Sprint(value))
|
||
}
|
||
|
||
func legacyRechargeType(factoryCode string) string {
|
||
switch factoryCode {
|
||
case "GOOGLE":
|
||
return "google_play_recharge"
|
||
case "APPLE":
|
||
return "apple_recharge"
|
||
case "HUAWEI":
|
||
return "huawei_recharge"
|
||
case "TELEGRAM":
|
||
return "telegram_recharge"
|
||
default:
|
||
return "web_recharge"
|
||
}
|
||
}
|
||
|
||
func legacyProviderCode(factoryCode string) string {
|
||
if factoryCode == "" {
|
||
return "legacy"
|
||
}
|
||
return strings.ToLower(factoryCode)
|
||
}
|
||
|
||
// legacyGoldCoinAmount 只统计 GOLD/CANDY 商品内容,口径对齐 chatapp3 的 firstGoldProduct。
|
||
func legacyGoldCoinAmount(products []legacyPurchaseProduct) int64 {
|
||
var total int64
|
||
for _, product := range products {
|
||
if product.Name != "GOLD" && product.Name != "CANDY" {
|
||
continue
|
||
}
|
||
if amount, err := strconv.ParseInt(strings.TrimSpace(product.Content), 10, 64); err == nil {
|
||
total += amount
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
func legacyDecimalToMicro(value any) int64 {
|
||
return legacyDecimalScaled(value, 1_000_000)
|
||
}
|
||
|
||
func legacyDecimalToMinor(value any) int64 {
|
||
return legacyDecimalScaled(value, 100)
|
||
}
|
||
|
||
// legacyDecimalScaled 把 Mongo 里 Decimal128/数值/字符串形式的十进制金额换算成目标最小单位;非法值按 0 处理。
|
||
func legacyDecimalScaled(value any, scale float64) int64 {
|
||
var text string
|
||
switch typed := value.(type) {
|
||
case nil:
|
||
return 0
|
||
case bson.Decimal128:
|
||
text = typed.String()
|
||
case string:
|
||
text = typed
|
||
case int32:
|
||
return int64(typed) * int64(scale)
|
||
case int64:
|
||
return typed * int64(scale)
|
||
case int:
|
||
return int64(typed) * int64(scale)
|
||
case float64:
|
||
text = strconv.FormatFloat(typed, 'f', -1, 64)
|
||
case float32:
|
||
text = strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
||
default:
|
||
text = fmt.Sprint(value)
|
||
}
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(text), 64)
|
||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||
return 0
|
||
}
|
||
return int64(math.Round(parsed * scale))
|
||
}
|