package payment import ( "context" "fmt" "sort" "strconv" "strings" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" walletv1 "hyapp.local/api/proto/wallet/v1" ) type financeCoinSellerRechargeQuery struct { RechargeType string Status string PaidState string Keyword string RegionID int64 StartAtMS int64 EndAtMS int64 TzOffsetMinutes int32 TargetUserID int64 HasUserOnlyFilter bool Page int PageSize int MaxPageSize int RequestID string UserID int64 } type financeCoinSellerRechargeStats struct { Total rechargeBillSummaryBucketDTO Daily map[string]rechargeBillSummaryBucketDTO Regions []rechargeBillRegionBucketDTO } const ( financeCoinSellerRechargeMaxPageSize = 100 financeRechargeMergeMaxPrefetch = 5000 ) func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, summary *rechargeBillSummaryDTO) error { if summary == nil { return nil } rechargeType := normalizedRechargeType(query.RechargeType) if rechargeType != "" && rechargeType != "coin_seller" { summary.CoinSeller = rechargeBillSummaryBucketDTO{} legacyRecomputeSummaryTotal(summary) return nil } stats, err := h.financeCoinSellerRechargeStats(ctx, appCode, query) if err != nil { return err } summary.CoinSeller = stats.Total if rechargeType == "coin_seller" { summary.GooglePlay = rechargeBillSummaryBucketDTO{} summary.ThirdParty = rechargeBillSummaryBucketDTO{} } legacyRecomputeSummaryTotal(summary) return nil } func (h *Handler) applyFinanceCoinSellerOverview(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, overview *rechargeBillOverviewDTO) error { if overview == nil { return nil } rechargeType := normalizedRechargeType(query.RechargeType) if rechargeType != "" && rechargeType != "coin_seller" { for i := range overview.Daily { overview.Daily[i].CoinSellerUsdMinor = 0 overview.Daily[i].CoinSellerCoinAmount = 0 } return nil } stats, err := h.financeCoinSellerRechargeStats(ctx, appCode, query) if err != nil { return err } byDate := map[string]int{} for i := range overview.Daily { byDate[overview.Daily[i].Date] = i // 财务工作台的币商充值只认后台币商订单 + H5 币商身份充值;wallet 库存流水和 legacy dashboard 旧聚合先清掉再重填。 overview.Daily[i].CoinSellerUsdMinor = 0 overview.Daily[i].CoinSellerCoinAmount = 0 } for date, bucket := range stats.Daily { if date == "" { continue } index, ok := byDate[date] if !ok { overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{Date: date}) index = len(overview.Daily) - 1 byDate[date] = index } overview.Daily[index].CoinSellerUsdMinor = bucket.USDMinorAmount overview.Daily[index].CoinSellerCoinAmount = bucket.CoinAmount } sort.Slice(overview.Daily, func(i, j int) bool { return overview.Daily[i].Date < overview.Daily[j].Date }) if rechargeType == "coin_seller" { overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, stats.Regions) return nil } if rechargeType == "" { overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, mergeRechargeRegionBuckets(overview.Regions, stats.Regions)) } return nil } func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) { stats := financeCoinSellerRechargeStats{Daily: map[string]rechargeBillSummaryBucketDTO{}} rechargeType := normalizedRechargeType(query.RechargeType) if rechargeType != "" && rechargeType != "coin_seller" { return stats, nil } if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter { return stats, nil } if h != nil && h.store != nil { repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{ AppCode: appCode, RegionID: query.RegionID, TargetUserID: query.TargetUserID, Keyword: query.Keyword, CreatedFromMS: query.StartAtMS, CreatedToMS: query.EndAtMS, TzOffsetMinutes: query.TzOffsetMinutes, }) if err != nil { return stats, err } stats.addBucket(rechargeBillSummaryBucketDTO{ BillCount: repoStats.VerifiedCount, CoinAmount: repoStats.VerifiedCoinAmount, USDMinorAmount: repoStats.VerifiedUSDMinor, }) for date, bucket := range repoStats.VerifiedDaily { stats.addDaily(date, rechargeBillSummaryBucketDTO{BillCount: bucket.Count, CoinAmount: bucket.CoinAmount, USDMinorAmount: bucket.USDMinor}) } for _, row := range repoStats.VerifiedRegionBuckets { stats.addRegion(row.RegionID, row.Count, row.USDMinor) } } if h != nil && h.walletDB != nil { if err := h.addH5CoinSellerRechargeStats(ctx, appCode, query, &stats); err != nil { return stats, err } } sort.Slice(stats.Regions, func(i, j int) bool { return stats.Regions[i].UsdMinorAmount > stats.Regions[j].UsdMinorAmount }) return stats, nil } func (s *financeCoinSellerRechargeStats) addBucket(bucket rechargeBillSummaryBucketDTO) { if s == nil { return } s.Total.BillCount += bucket.BillCount s.Total.CoinAmount += bucket.CoinAmount s.Total.USDMinorAmount += bucket.USDMinorAmount } func (s *financeCoinSellerRechargeStats) addDaily(date string, bucket rechargeBillSummaryBucketDTO) { if s == nil || date == "" { return } if s.Daily == nil { s.Daily = map[string]rechargeBillSummaryBucketDTO{} } current := s.Daily[date] current.BillCount += bucket.BillCount current.CoinAmount += bucket.CoinAmount current.USDMinorAmount += bucket.USDMinorAmount s.Daily[date] = current } func (s *financeCoinSellerRechargeStats) addRegion(regionID int64, billCount int64, usdMinor int64) { if s == nil || regionID <= 0 { return } for i := range s.Regions { if s.Regions[i].RegionID == regionID { s.Regions[i].BillCount += billCount s.Regions[i].UsdMinorAmount += usdMinor return } } s.Regions = append(s.Regions, rechargeBillRegionBucketDTO{RegionID: regionID, BillCount: billCount, UsdMinorAmount: usdMinor}) } func (h *Handler) addH5CoinSellerRechargeStats(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, stats *financeCoinSellerRechargeStats) error { where, args := h5CoinSellerRechargeWhere(appCode, query) var total rechargeBillSummaryBucketDTO if err := h.walletDB.QueryRowContext(ctx, ` SELECT COUNT(*), COALESCE(SUM(eo.coin_amount), 0), COALESCE(SUM(eo.usd_minor_amount), 0) FROM external_recharge_orders eo JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id `+where, args...).Scan(&total.BillCount, &total.CoinAmount, &total.USDMinorAmount); err != nil { return err } stats.addBucket(total) tzOffsetMS := int64(query.TzOffsetMinutes) * 60_000 dailyArgs := append([]any{tzOffsetMS}, args...) rows, err := h.walletDB.QueryContext(ctx, ` SELECT DATE_FORMAT(FROM_UNIXTIME((eo.updated_at_ms + ?) / 1000), '%Y-%m-%d') AS date, COUNT(*), COALESCE(SUM(eo.coin_amount), 0), COALESCE(SUM(eo.usd_minor_amount), 0) FROM external_recharge_orders eo JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id `+where+` GROUP BY date ORDER BY date ASC`, dailyArgs...) if err != nil { return err } defer rows.Close() for rows.Next() { var date string var bucket rechargeBillSummaryBucketDTO if err := rows.Scan(&date, &bucket.BillCount, &bucket.CoinAmount, &bucket.USDMinorAmount); err != nil { return err } stats.addDaily(date, bucket) } if err := rows.Err(); err != nil { return err } regionRows, err := h.walletDB.QueryContext(ctx, ` SELECT eo.target_region_id, COUNT(*), COALESCE(SUM(eo.usd_minor_amount), 0) FROM external_recharge_orders eo JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id `+where+` GROUP BY eo.target_region_id ORDER BY 3 DESC`, args...) if err != nil { return err } defer regionRows.Close() for regionRows.Next() { var regionID, billCount, usdMinor int64 if err := regionRows.Scan(®ionID, &billCount, &usdMinor); err != nil { return err } stats.addRegion(regionID, billCount, usdMinor) } return regionRows.Err() } func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) { if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter { return []rechargeBillDTO{}, 0, nil } page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) items := make([]rechargeBillDTO, 0, prefetch*2) var total int64 if h != nil && h.store != nil { orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{ Page: 1, PageSize: prefetch, PageSizeLimit: prefetch, AppCode: appCode, VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified, Keyword: query.Keyword, TargetUserID: query.TargetUserID, RegionID: query.RegionID, CreatedFromMS: query.StartAtMS, CreatedToMS: query.EndAtMS, }) if err != nil { return nil, 0, err } total += orderTotal for _, order := range orders { if query.RegionID > 0 && order.TargetRegionID != query.RegionID { continue } items = append(items, financeCoinSellerRechargeOrderBill(order)) } } if h != nil && h.walletDB != nil { h5Items, h5Total, err := h.listH5CoinSellerRechargeBills(ctx, appCode, query, prefetch) if err != nil { return nil, 0, err } total += h5Total items = append(items, h5Items...) } sortRechargeBills(items) offset := (page - 1) * pageSize if offset >= len(items) { return []rechargeBillDTO{}, total, nil } end := offset + pageSize if end > len(items) { end = len(items) } return items[offset:end], total, nil } 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) items := make([]rechargeBillDTO, 0, prefetch*3) var total int64 if source != nil { if !query.HasUserOnlyFilter && query.TargetUserID == 0 { // legacy 普通充值源没有 user/seller 过滤能力;有用户筛选时宁可不展示普通源,避免 all 视图返回未过滤全量。 ordinary, ordinaryTotal, err := source.ListRechargeBills(ctx, legacyRechargeBillQuery{ Keyword: query.Keyword, RechargeType: "", PaidState: query.PaidState, RegionID: query.RegionID, StartAtMS: query.StartAtMS, EndAtMS: query.EndAtMS, TzOffsetMinutes: query.TzOffsetMinutes, Page: 1, PageSize: prefetch, }) if err != nil { return nil, 0, err } items = append(items, ordinary...) total += ordinaryTotal } } else if h != nil && h.wallet != nil { // wallet 的空 recharge_type 会并入 coin_seller_stock_records;all 视图必须拆成普通三方和 Google 两路,币商改走财务新口径。 for _, rechargeType := range []string{"third_party", "google_play_recharge"} { resp, err := h.wallet.ListRechargeBills(ctx, &walletv1.ListRechargeBillsRequest{ RequestId: query.RequestID, AppCode: appCode, UserId: query.UserID, SellerUserId: query.TargetUserID, RegionId: query.RegionID, RechargeType: rechargeType, PaidState: query.PaidState, Status: query.Status, Keyword: query.Keyword, StartAtMs: query.StartAtMS, EndAtMs: query.EndAtMS, Page: 1, PageSize: int32(prefetch), }) if err != nil { return nil, 0, err } total += resp.GetTotal() for _, item := range resp.GetBills() { items = append(items, rechargeBillFromProto(item)) } } } coinItems, coinTotal, err := h.listFinanceCoinSellerRechargeBills(ctx, appCode, financeCoinSellerRechargeQuery{ RechargeType: "coin_seller", Status: query.Status, PaidState: query.PaidState, Keyword: query.Keyword, RegionID: query.RegionID, StartAtMS: query.StartAtMS, EndAtMS: query.EndAtMS, TzOffsetMinutes: query.TzOffsetMinutes, TargetUserID: query.TargetUserID, HasUserOnlyFilter: query.HasUserOnlyFilter, Page: 1, PageSize: prefetch, MaxPageSize: prefetch, }) if err != nil { return nil, 0, err } items = append(items, coinItems...) total += coinTotal sortRechargeBills(items) offset := (page - 1) * pageSize if offset >= len(items) { return []rechargeBillDTO{}, total, nil } end := offset + pageSize if end > len(items) { end = len(items) } return items[offset:end], total, nil } func (h *Handler) walletOrdinaryRechargeOverviewRegions(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) ([]rechargeBillRegionBucketDTO, error) { if h == nil || h.wallet == nil { return []rechargeBillRegionBucketDTO{}, nil } if !financeWalletOverviewSupportsOrdinaryFilters(query) { // wallet overview proto 没有用户、币商、关键词和 paid_state 字段;过滤条件存在时返回空区域,避免展示未过滤的全量区域。 return []rechargeBillRegionBucketDTO{}, nil } var out []rechargeBillRegionBucketDTO for _, rechargeType := range []string{"third_party", "google_play_recharge"} { resp, err := h.wallet.GetRechargeBillOverview(ctx, &walletv1.GetRechargeBillOverviewRequest{ RequestId: query.RequestID, AppCode: appCode, RegionId: query.RegionID, RechargeType: rechargeType, Status: query.Status, StartAtMs: query.StartAtMS, EndAtMs: query.EndAtMS, TzOffsetMinutes: query.TzOffsetMinutes, }) if err != nil { return nil, err } for _, bucket := range resp.GetRegions() { out = append(out, rechargeBillRegionBucketDTO{ RegionID: bucket.GetRegionId(), BillCount: bucket.GetBillCount(), UsdMinorAmount: bucket.GetUsdMinorAmount(), }) } } return mergeRechargeRegionBuckets(out), nil } func financeWalletOverviewSupportsOrdinaryFilters(query financeCoinSellerRechargeQuery) bool { return query.UserID == 0 && query.TargetUserID == 0 && strings.TrimSpace(query.Keyword) == "" && strings.TrimSpace(query.PaidState) == "" } func (h *Handler) listH5CoinSellerRechargeBills(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, limit int) ([]rechargeBillDTO, int64, error) { where, args := h5CoinSellerRechargeWhere(appCode, query) var total int64 if err := h.walletDB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM external_recharge_orders eo JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id `+where, args...).Scan(&total); err != nil { return nil, 0, err } if limit < 1 { return []rechargeBillDTO{}, total, nil } rows, err := h.walletDB.QueryContext(ctx, ` SELECT eo.app_code, eo.order_id, wt.command_id, eo.target_user_id, eo.target_region_id, eo.product_id, eo.product_code, eo.product_name, eo.coin_amount, eo.usd_minor_amount, eo.provider_code, eo.currency_code, eo.provider_amount_minor, eo.provider_order_id, eo.wallet_transaction_id, eo.updated_at_ms, eo.created_at_ms FROM external_recharge_orders eo JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id `+where+` ORDER BY eo.updated_at_ms DESC, eo.order_id DESC LIMIT ?`, append(args, limit)...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]rechargeBillDTO, 0, limit) for rows.Next() { var item rechargeBillDTO var productID int64 var productCode, productName, providerOrderID, walletTransactionID string var createdAtMS int64 if err := rows.Scan( &item.AppCode, &item.ExternalRef, &item.CommandID, &item.SellerUserID, &item.SellerRegionID, &productID, &productCode, &productName, &item.CoinAmount, &item.USDMinorAmount, &item.ProviderCode, &item.UserPaidCurrencyCode, &item.ProviderAmountMinor, &providerOrderID, &walletTransactionID, &item.CreatedAtMS, &createdAtMS, ); err != nil { return nil, 0, err } item.TransactionID = firstNonEmptyPaymentString(walletTransactionID, item.ExternalRef) item.RechargeType = "coin_seller" item.Status = "succeeded" item.TargetRegionID = item.SellerRegionID item.PolicyID = productID item.PolicyVersion = productCode item.CurrencyCode = "USD" item.UserPaidAmountMicro = item.ProviderAmountMinor * 10_000 item.PaidSyncedAtMS = item.CreatedAtMS if item.UserPaidCurrencyCode == "" { item.UserPaidCurrencyCode = "USD" item.UserPaidAmountMicro = item.USDMinorAmount * 10_000 } if providerOrderID != "" { item.ExternalRef = providerOrderID } if item.CreatedAtMS == 0 { item.CreatedAtMS = createdAtMS } items = append(items, item) } return items, total, rows.Err() } func financeCoinSellerRechargeOrderBill(order model.CoinSellerRechargeOrder) rechargeBillDTO { currency := firstNonEmptyPaymentString(order.ProviderCurrencyCode, "USD") providerAmountMinor := order.ProviderAmountMinor userPaidAmountMicro := providerAmountMinor * 10_000 if providerAmountMinor == 0 { userPaidAmountMicro = order.USDMinorAmount * 10_000 } return rechargeBillDTO{ AppCode: order.AppCode, TransactionID: firstNonEmptyPaymentString(order.WalletTransactionID, fmt.Sprintf("coin-seller-order:%d", order.ID)), CommandID: order.WalletCommandID, RechargeType: "coin_seller", Status: "succeeded", ExternalRef: firstNonEmptyPaymentString(order.ProviderOrderID, order.ExternalOrderNo), SellerUserID: order.TargetUserID, SellerRegionID: order.TargetRegionID, TargetRegionID: order.TargetRegionID, CurrencyCode: "USD", CoinAmount: order.CoinAmount, USDMinorAmount: order.USDMinorAmount, ProviderAmountMinor: providerAmountMinor, CreatedAtMS: order.CreatedAtMS, ProviderCode: order.ProviderCode, UserPaidCurrencyCode: currency, UserPaidAmountMicro: userPaidAmountMicro, PaidSyncedAtMS: order.CreatedAtMS, } } func h5CoinSellerRechargeWhere(appCode string, query financeCoinSellerRechargeQuery) (string, []any) { where := []string{ "eo.app_code = ?", "eo.audience_type = 'coin_seller'", "eo.status = 'credited'", "eo.wallet_transaction_id <> ''", "wt.biz_type = 'coin_seller_recharge'", "wt.status = 'succeeded'", } args := []any{appCode} if query.TargetUserID > 0 { where = append(where, "eo.target_user_id = ?") args = append(args, query.TargetUserID) } if query.RegionID > 0 { where = append(where, "eo.target_region_id = ?") args = append(args, query.RegionID) } if query.StartAtMS > 0 { where = append(where, "eo.updated_at_ms >= ?") args = append(args, query.StartAtMS) } if query.EndAtMS > 0 { where = append(where, "eo.updated_at_ms < ?") args = append(args, query.EndAtMS) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" where = append(where, "(eo.order_id LIKE ? OR eo.provider_order_id LIKE ? OR eo.wallet_transaction_id LIKE ? OR eo.product_code LIKE ? OR eo.product_name LIKE ? OR eo.tx_hash LIKE ? OR CAST(eo.target_user_id AS CHAR) LIKE ?)") args = append(args, like, like, like, like, like, like, like) } return "WHERE " + strings.Join(where, " AND "), args } func financeCoinSellerSucceededStatus(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { case "", "all", "succeeded", "success": return true default: return false } } func financeCoinSellerPage(page int, pageSize int, maxPageSize int) (int, int) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 10 } if maxPageSize <= 0 { maxPageSize = financeCoinSellerRechargeMaxPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return page, pageSize } func financeRechargePrefetch(page int, pageSize int, maxPageSize int) int { prefetch := page * pageSize limit := financeRechargeMergeMaxPrefetch if maxPageSize > limit { limit = maxPageSize } if prefetch > limit { return limit } return prefetch } func sortRechargeBills(items []rechargeBillDTO) { sort.Slice(items, func(i, j int) bool { if items[i].CreatedAtMS == items[j].CreatedAtMS { return items[i].TransactionID > items[j].TransactionID } return items[i].CreatedAtMS > items[j].CreatedAtMS }) } func mergeRechargeRegionBuckets(groups ...[]rechargeBillRegionBucketDTO) []rechargeBillRegionBucketDTO { byRegion := map[int64]*rechargeBillRegionBucketDTO{} for _, group := range groups { for _, row := range group { if row.RegionID <= 0 { continue } bucket, ok := byRegion[row.RegionID] if !ok { copy := row byRegion[row.RegionID] = © continue } bucket.BillCount += row.BillCount bucket.UsdMinorAmount += row.UsdMinorAmount if bucket.Name == "" { bucket.Name = row.Name } } } out := make([]rechargeBillRegionBucketDTO, 0, len(byRegion)) for _, bucket := range byRegion { out = append(out, *bucket) } sort.Slice(out, func(i, j int) bool { return out[i].UsdMinorAmount > out[j].UsdMinorAmount }) return out } func (h *Handler) fillFinanceCoinSellerRegionNames(ctx context.Context, appCode string, buckets []rechargeBillRegionBucketDTO) []rechargeBillRegionBucketDTO { if len(buckets) == 0 { return buckets } names := map[int64]string{} if h != nil { if regions, err := h.listRechargeBillRegions(ctx, appCode); err == nil { for _, region := range regions { name := firstNonEmptyPaymentString(region.Name, region.RegionCode, strconv.FormatInt(region.RegionID, 10)) names[region.RegionID] = name } } } for i := range buckets { if name := names[buckets[i].RegionID]; name != "" { buckets[i].Name = name continue } buckets[i].Name = strconv.FormatInt(buckets[i].RegionID, 10) } return buckets } func firstNonEmptyPaymentString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }