From 07302f0aff14292d4c6e0060d97deecc23c8f41f Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 9 Jul 2026 19:57:39 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/finance_coin_seller_recharge.go | 681 ++++++++++++++++++ .../admin/internal/modules/payment/handler.go | 52 +- .../internal/modules/payment/handler_test.go | 37 + .../modules/payment/legacy_bill_source.go | 23 +- .../payment/legacy_bill_source_test.go | 36 +- .../modules/payment/recharge_bill_export.go | 53 +- .../modules/payment/recharge_bill_overview.go | 245 +++---- .../modules/payment/recharge_bill_stats.go | 194 ++--- .../coin_seller_recharge_order_repository.go | 27 +- 9 files changed, 1006 insertions(+), 342 deletions(-) create mode 100644 server/admin/internal/modules/payment/finance_coin_seller_recharge.go diff --git a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go new file mode 100644 index 00000000..93e403a0 --- /dev/null +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go @@ -0,0 +1,681 @@ +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 "" +} diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index 3ed26f7f..d077d440 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -77,10 +77,7 @@ func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *re func (h *Handler) ListRechargeBills(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) - if source, ok := h.billSources[appCode]; ok { - h.listLegacyRechargeBills(c, source) - return - } + rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")) userFilter := shared.UserIdentityFilterFromQuery(c, "") userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") if !ok { @@ -95,13 +92,58 @@ func (h *Handler) ListRechargeBills(c *gin.Context) { response.OK(c, response.Page{Items: []rechargeBillDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } + source, hasSource := h.billSources[appCode] + normalizedType := normalizedRechargeType(rechargeType) + if normalizedType == "" || normalizedType == "coin_seller" { + query := financeCoinSellerRechargeQuery{ + RechargeType: rechargeType, + Status: options.Status, + PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), + EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), + TargetUserID: sellerUserID, + HasUserOnlyFilter: userID > 0 && sellerUserID == 0, + Page: options.Page, + PageSize: options.PageSize, + RequestID: middleware.CurrentRequestID(c), + UserID: userID, + } + var items []rechargeBillDTO + var total int64 + var err error + if normalizedType == "coin_seller" { + items, total, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query) + } else { + var legacySource RechargeBillSource + if hasSource { + legacySource = source + } + items, total, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, legacySource, query) + } + if err != nil { + response.ServerError(c, "获取账单列表失败") + return + } + if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil { + response.ServerError(c, "获取账单用户资料失败") + return + } + response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) + return + } + if hasSource { + h.listLegacyRechargeBills(c, source) + return + } resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, UserId: userID, SellerUserId: sellerUserID, RegionId: queryInt64(c, "region_id", "regionId"), - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), + RechargeType: rechargeType, PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), Status: options.Status, Keyword: options.Keyword, diff --git a/server/admin/internal/modules/payment/handler_test.go b/server/admin/internal/modules/payment/handler_test.go index 601e5ce7..60fba2b5 100644 --- a/server/admin/internal/modules/payment/handler_test.go +++ b/server/admin/internal/modules/payment/handler_test.go @@ -87,6 +87,34 @@ func TestListRechargeBillsReturnsProviderAmountMinor(t *testing.T) { } } +func TestGetRechargeBillSummaryKeepsOrdinaryThirdParty(t *testing.T) { + wallet := &mockPaymentWallet{rechargeSummaryResp: &walletv1.GetRechargeBillSummaryResponse{ + Total: &walletv1.RechargeBillSummaryBucket{BillCount: 31, CoinAmount: 310000, UsdMinorAmount: 12345}, + ThirdParty: &walletv1.RechargeBillSummaryBucket{BillCount: 31, CoinAmount: 310000, UsdMinorAmount: 12345}, + }} + router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) + request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills/summary?recharge_type=third_party&status=succeeded", nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + var response adminPaymentTestResponse + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + thirdParty := response.Data["thirdParty"].(map[string]any) + total := response.Data["total"].(map[string]any) + if thirdParty["billCount"] != float64(31) || thirdParty["usdMinorAmount"] != float64(12345) { + t.Fatalf("ordinary third party summary should be preserved: %+v", response.Data) + } + if total["billCount"] != float64(31) || total["usdMinorAmount"] != float64(12345) { + t.Fatalf("total should be recomputed from ordinary third party bucket: %+v", response.Data) + } +} + func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) { wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{ Channels: []*walletv1.ThirdPartyPaymentChannel{{ @@ -609,6 +637,7 @@ func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine { c.Next() }) router.GET("/admin/payment/recharge-bills", handler.ListRechargeBills) + router.GET("/admin/payment/recharge-bills/summary", handler.GetRechargeBillSummary) router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels) router.GET("/admin/payment/temporary-links", handler.ListTemporaryPaymentLinks) router.POST("/admin/payment/temporary-links", handler.CreateTemporaryPaymentLink) @@ -755,6 +784,7 @@ type mockPaymentWallet struct { lastRechargeBills *walletv1.ListRechargeBillsRequest rechargeBillsResp *walletv1.ListRechargeBillsResponse + rechargeSummaryResp *walletv1.GetRechargeBillSummaryResponse lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse lastTemporaryOrders *walletv1.ListTemporaryRechargeOrdersRequest @@ -779,6 +809,13 @@ func (m *mockPaymentWallet) ListRechargeBills(_ context.Context, req *walletv1.L return &walletv1.ListRechargeBillsResponse{}, nil } +func (m *mockPaymentWallet) GetRechargeBillSummary(_ context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error) { + if m.rechargeSummaryResp != nil { + return m.rechargeSummaryResp, nil + } + return &walletv1.GetRechargeBillSummaryResponse{}, nil +} + func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) { m.lastThirdPartyChannels = req if m.thirdPartyChannelsResp != nil { diff --git a/server/admin/internal/modules/payment/legacy_bill_source.go b/server/admin/internal/modules/payment/legacy_bill_source.go index d6df89fc..cafbd35c 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source.go +++ b/server/admin/internal/modules/payment/legacy_bill_source.go @@ -248,7 +248,8 @@ func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query l return nil, 0, fmt.Errorf("legacy bill source is not configured") } if legacyQueryRechargeType(query) == "coin_seller" { - return s.listCoinSellerRechargeBills(ctx, query) + // 财务工作台币商充值已切到后台币商订单 + H5 币商身份订单,legacy dashboard 进货聚合不再作为账单来源。 + return []rechargeBillDTO{}, 0, nil } if s.collection == nil { return nil, 0, fmt.Errorf("legacy bill source is not configured") @@ -565,12 +566,7 @@ func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, qu return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured") } if legacyQueryRechargeType(query) == "coin_seller" { - summary := rechargeBillSummaryDTO{} - if err := s.mergeCoinSellerSummary(ctx, query, legacySummaryTZOffset(query), &summary); err != nil { - return rechargeBillSummaryDTO{}, err - } - summary.Total = summary.CoinSeller - return summary, nil + return rechargeBillSummaryDTO{}, nil } filter, regionCodes, matchable, err := s.billFilter(ctx, query) if err != nil { @@ -635,11 +631,6 @@ func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, qu if err := cursor.Err(); err != nil { return rechargeBillSummaryDTO{}, fmt.Errorf("iterate legacy recharge summary for %s: %w", s.config.AppCode, err) } - if legacyQueryRechargeType(query) == "" { - if err := s.mergeCoinSellerSummary(ctx, query, legacySummaryTZOffset(query), &summary); err != nil { - return rechargeBillSummaryDTO{}, err - } - } legacyRecomputeSummaryTotal(&summary) return summary, nil } @@ -907,9 +898,6 @@ func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRech return overview, err } if legacyQueryRechargeType(query) == "coin_seller" { - if err := s.mergeCoinSellerOverview(ctx, query, tzOffsetMinutes, &overview); err != nil { - return overview, err - } return overview, nil } filter, regionCodes, matchable, err := s.billFilter(ctx, query) @@ -930,11 +918,6 @@ func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRech if err := s.overviewGooglePaid(ctx, filter, query, &overview); err != nil { return overview, err } - if legacyQueryRechargeType(query) == "" { - if err := s.mergeCoinSellerOverview(ctx, query, tzOffsetMinutes, &overview); err != nil { - return overview, err - } - } return overview, nil } diff --git a/server/admin/internal/modules/payment/legacy_bill_source_test.go b/server/admin/internal/modules/payment/legacy_bill_source_test.go index 24a21a13..374cad77 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source_test.go +++ b/server/admin/internal/modules/payment/legacy_bill_source_test.go @@ -234,7 +234,7 @@ func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Cont return s.bills, s.billTotal, nil } -func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { +func TestLegacyCoinSellerListDoesNotUseDashboardBillSource(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "aslan", billTotal: 2, @@ -283,37 +283,11 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { if err != nil { t.Fatalf("ListRechargeBills: %v", err) } - if total != 2 || len(items) != 2 { - t.Fatalf("unexpected result total=%d items=%+v", total, items) + if total != 0 || len(items) != 0 { + t.Fatalf("legacy dashboard coin seller bills should be disabled: total=%d items=%+v", total, items) } - item := items[0] - if item.RechargeType != "coin_seller_stock_purchase" || item.ProviderCode != "coin_seller" { - t.Fatalf("unexpected coin seller mapping: %+v", item) - } - if item.TransactionID != "aslan-freight-purchase-99" || item.SellerUserID != 2069828597070528514 || item.USDMinorAmount != 132790 { - t.Fatalf("unexpected bill fields: %+v", item) - } - if item.SellerRegionID != 301 || item.TargetRegionID != 301 { - t.Fatalf("region mapping failed: %+v", item) - } - if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 { - t.Fatalf("paid facts mismatch: %+v", item) - } - deduction := items[1] - if deduction.RechargeType != "coin_seller_stock_deduction" || deduction.ProviderCode != "coin_seller" { - t.Fatalf("unexpected deduction mapping: %+v", deduction) - } - if deduction.TransactionID != "aslan-freight-deduction-100" || deduction.SellerUserID != 2069828597070528515 || deduction.USDMinorAmount != -100000 { - t.Fatalf("unexpected deduction fields: %+v", deduction) - } - if deduction.SellerRegionID != 302 || deduction.TargetRegionID != 302 { - t.Fatalf("deduction region mapping failed: %+v", deduction) - } - if deduction.UserPaidCurrencyCode != "USD" || deduction.UserPaidAmountMicro != -1_000_000_000 { - t.Fatalf("deduction paid facts mismatch: %+v", deduction) - } - if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" { - t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery) + if dashboardSource.billCalls != 0 { + t.Fatalf("dashboard bill source should not be called: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery) } } diff --git a/server/admin/internal/modules/payment/recharge_bill_export.go b/server/admin/internal/modules/payment/recharge_bill_export.go index 4eb82e20..23aefd6c 100644 --- a/server/admin/internal/modules/payment/recharge_bill_export.go +++ b/server/admin/internal/modules/payment/recharge_bill_export.go @@ -84,12 +84,61 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) // legacy 币商充值导出必须和页面列表使用同一个自然日解释方式。 tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes } + rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")) + normalizedType := normalizedRechargeType(rechargeType) + if normalizedType == "" || normalizedType == "coin_seller" { + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") + if !ok { + return nil, false + } + sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller") + sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") + if !ok { + return nil, false + } + if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) { + return []rechargeBillDTO{}, true + } + query := financeCoinSellerRechargeQuery{ + RechargeType: rechargeType, + Status: options.Status, + PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), + EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), + TargetUserID: sellerUserID, + HasUserOnlyFilter: userID > 0 && sellerUserID == 0, + Page: 1, + PageSize: rechargeBillExportMaxRows, + MaxPageSize: rechargeBillExportMaxRows, + RequestID: middleware.CurrentRequestID(c), + UserID: userID, + } + var items []rechargeBillDTO + var err error + if normalizedType == "coin_seller" { + items, _, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query) + } else { + items, _, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, h.billSources[appCode], query) + } + if err != nil { + response.ServerError(c, "导出账单失败") + return nil, false + } + if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil { + response.ServerError(c, "导出账单失败") + return nil, false + } + return capRechargeBillExportRows(items), true + } if source, ok := h.billSources[appCode]; ok { bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize) for page := 1; len(bills) < rechargeBillExportMaxRows; page++ { items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{ Keyword: options.Keyword, - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), + RechargeType: rechargeType, PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), RegionID: queryInt64(c, "region_id", "regionId"), StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), @@ -230,6 +279,8 @@ func rechargeSourceLabel(rechargeType string) string { switch rechargeType { case "google_play_recharge": return "谷歌充值" + case "coin_seller": + return "币商充值" case "coin_seller_stock_purchase": return "币商进货" case "coin_seller_stock_deduction": diff --git a/server/admin/internal/modules/payment/recharge_bill_overview.go b/server/admin/internal/modules/payment/recharge_bill_overview.go index cb5b8f9a..28e8027e 100644 --- a/server/admin/internal/modules/payment/recharge_bill_overview.go +++ b/server/admin/internal/modules/payment/recharge_bill_overview.go @@ -1,14 +1,11 @@ package payment import ( - "context" - "sort" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" - "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/response" walletv1 "hyapp.local/api/proto/wallet/v1" @@ -69,6 +66,20 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) { } startAtMS := queryInt64(c, "start_at_ms", "startAtMs") endAtMS := queryInt64(c, "end_at_ms", "endAtMs") + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") + if !ok { + return + } + sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller") + sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") + if !ok { + return + } + if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) { + response.OK(c, rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}) + return + } if source, ok := h.billSources[appCode]; ok { query := legacyRechargeBillQuery{ Keyword: options.Keyword, @@ -78,22 +89,26 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) { StartAtMS: startAtMS, EndAtMS: endAtMS, } - if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) { - return + overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}} + if userFilter.IsEmpty() && sellerFilter.IsEmpty() { + var err error + overview, err = source.Overview(c.Request.Context(), query, tzOffsetMinutes) + if err != nil { + response.ServerError(c, "获取财务概览失败") + return + } } - overview, err := source.Overview(c.Request.Context(), query, tzOffsetMinutes) - if err != nil { - response.ServerError(c, "获取财务概览失败") - return - } - if err := h.applyFinanceThirdPartyOverview(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{ - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), - Status: options.Status, - Keyword: options.Keyword, - RegionID: query.RegionID, - StartAtMS: startAtMS, - EndAtMS: endAtMS, - TzOffsetMinutes: tzOffsetMinutes, + if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{ + RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), + Status: options.Status, + PaidState: query.PaidState, + Keyword: options.Keyword, + RegionID: query.RegionID, + StartAtMS: startAtMS, + EndAtMS: endAtMS, + TzOffsetMinutes: tzOffsetMinutes, + TargetUserID: sellerUserID, + HasUserOnlyFilter: userID > 0 && sellerUserID == 0, }, &overview); err != nil { response.ServerError(c, "获取财务概览失败") return @@ -103,79 +118,76 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) { return } - resp, err := h.wallet.GetRechargeBillOverview(c.Request.Context(), &walletv1.GetRechargeBillOverviewRequest{ - RequestId: middleware.CurrentRequestID(c), - AppCode: appCode, - RegionId: queryInt64(c, "region_id", "regionId"), - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), - Status: options.Status, - StartAtMs: startAtMS, - EndAtMs: endAtMS, - TzOffsetMinutes: tzOffsetMinutes, - }) - if err != nil { - writeWalletError(c, err, "获取财务概览失败") - return - } - overview := rechargeBillOverviewDTO{ - Daily: make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily())), - Regions: make([]rechargeBillRegionBucketDTO, 0, len(resp.GetRegions())), + Daily: []rechargeBillDailyBucketDTO{}, + Regions: []rechargeBillRegionBucketDTO{}, } - for _, bucket := range resp.GetDaily() { - overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{ - Date: bucket.GetDate(), - GoogleUsdMinor: bucket.GetGoogleUsdMinor(), - ThirdPartyUsdMinor: bucket.GetThirdPartyUsdMinor(), - CoinSellerUsdMinor: bucket.GetCoinSellerUsdMinor(), - GoogleCoinAmount: bucket.GetGoogleCoinAmount(), - ThirdPartyCoinAmount: bucket.GetThirdPartyCoinAmount(), - CoinSellerCoinAmount: bucket.GetCoinSellerCoinAmount(), + financeQuery := financeCoinSellerRechargeQuery{ + RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), + Status: options.Status, + PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + StartAtMS: startAtMS, + EndAtMS: endAtMS, + TzOffsetMinutes: tzOffsetMinutes, + TargetUserID: sellerUserID, + HasUserOnlyFilter: userID > 0 && sellerUserID == 0, + RequestID: middleware.CurrentRequestID(c), + UserID: userID, + } + if financeWalletOverviewSupportsOrdinaryFilters(financeQuery) { + resp, err := h.wallet.GetRechargeBillOverview(c.Request.Context(), &walletv1.GetRechargeBillOverviewRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appCode, + RegionId: queryInt64(c, "region_id", "regionId"), + RechargeType: financeQuery.RechargeType, + Status: options.Status, + StartAtMs: startAtMS, + EndAtMs: endAtMS, + TzOffsetMinutes: tzOffsetMinutes, }) - } - // 区域名从后台区域目录补齐;目录缺失时回退区域 ID 文本,避免概览页出现空白区块。 - regionNames := map[int64]string{} - if regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode); err == nil { - for _, region := range regions { - name := region.Name - if name == "" { - name = region.RegionCode + if err != nil { + writeWalletError(c, err, "获取财务概览失败") + return + } + overview.Daily = make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily())) + for _, bucket := range resp.GetDaily() { + overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{ + Date: bucket.GetDate(), + GoogleUsdMinor: bucket.GetGoogleUsdMinor(), + ThirdPartyUsdMinor: bucket.GetThirdPartyUsdMinor(), + CoinSellerUsdMinor: bucket.GetCoinSellerUsdMinor(), + GoogleCoinAmount: bucket.GetGoogleCoinAmount(), + ThirdPartyCoinAmount: bucket.GetThirdPartyCoinAmount(), + CoinSellerCoinAmount: bucket.GetCoinSellerCoinAmount(), + }) + } + if stats := resp.GetGooglePaid(); stats != nil { + overview.GooglePaid = rechargeBillGooglePaidStatsDTO{ + GoogleBillCount: stats.GetGoogleBillCount(), + SyncedCount: stats.GetSyncedCount(), + UnsyncedCount: stats.GetUnsyncedCount(), + CoveredUsdMinor: stats.GetCoveredUsdMinor(), + EstFeeUsdMinor: stats.GetEstFeeUsdMinor(), + EstTaxUsdMinor: stats.GetEstTaxUsdMinor(), + EstNetUsdMinor: stats.GetEstNetUsdMinor(), } - regionNames[region.RegionID] = name + } + if transfer := resp.GetSalaryTransfer(); transfer != nil { + overview.Withdrawal.TransferCount = transfer.GetTransferCount() + overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor() } } - for _, bucket := range resp.GetRegions() { - overview.Regions = append(overview.Regions, rechargeBillRegionBucketDTO{ - RegionID: bucket.GetRegionId(), - Name: regionNames[bucket.GetRegionId()], - BillCount: bucket.GetBillCount(), - UsdMinorAmount: bucket.GetUsdMinorAmount(), - }) - } - if stats := resp.GetGooglePaid(); stats != nil { - overview.GooglePaid = rechargeBillGooglePaidStatsDTO{ - GoogleBillCount: stats.GetGoogleBillCount(), - SyncedCount: stats.GetSyncedCount(), - UnsyncedCount: stats.GetUnsyncedCount(), - CoveredUsdMinor: stats.GetCoveredUsdMinor(), - EstFeeUsdMinor: stats.GetEstFeeUsdMinor(), - EstTaxUsdMinor: stats.GetEstTaxUsdMinor(), - EstNetUsdMinor: stats.GetEstNetUsdMinor(), + if normalizedRechargeType(financeQuery.RechargeType) == "" { + ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(c.Request.Context(), appCode, financeQuery) + if err != nil { + writeWalletError(c, err, "获取财务概览失败") + return } + overview.Regions = h.fillFinanceCoinSellerRegionNames(c.Request.Context(), appCode, ordinaryRegions) } - if transfer := resp.GetSalaryTransfer(); transfer != nil { - overview.Withdrawal.TransferCount = transfer.GetTransferCount() - overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor() - } - if err := h.applyFinanceThirdPartyOverview(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{ - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), - Status: options.Status, - Keyword: options.Keyword, - RegionID: queryInt64(c, "region_id", "regionId"), - StartAtMS: startAtMS, - EndAtMS: endAtMS, - TzOffsetMinutes: tzOffsetMinutes, - }, &overview); err != nil { + if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeQuery, &overview); err != nil { response.ServerError(c, "获取财务概览失败") return } @@ -183,73 +195,6 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) { response.OK(c, overview) } -func (h *Handler) applyFinanceThirdPartyOverview(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, overview *rechargeBillOverviewDTO) error { - if overview == nil { - return nil - } - stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query) - if err != nil { - return err - } - rechargeType := normalizedRechargeType(query.RechargeType) - byDate := map[string]int{} - for i := range overview.Daily { - byDate[overview.Daily[i].Date] = i - if rechargeType == "" || rechargeType == "third_party" { - // 覆写而不是累加:ordinary 三方支付仍可存在于钱包账单,但不能进入 finance thirdParty 统计。 - overview.Daily[i].ThirdPartyUsdMinor = 0 - overview.Daily[i].ThirdPartyCoinAmount = 0 - } - } - if rechargeType == "" || rechargeType == "third_party" { - for date, bucket := range stats.VerifiedDaily { - 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].ThirdPartyUsdMinor = bucket.USDMinorAmount - overview.Daily[index].ThirdPartyCoinAmount = bucket.CoinAmount - } - } - sort.Slice(overview.Daily, func(i, j int) bool { - return overview.Daily[i].Date < overview.Daily[j].Date - }) - if rechargeType == "third_party" { - // thirdParty 区域分布来自工单 target_region_id/target_country_id 快照,不能复用普通支付账单区域。 - overview.Regions = financeThirdPartyRegionBuckets(stats.VerifiedRegions) - } - return nil -} - -func financeThirdPartyRegionBuckets(rows []repository.CoinSellerRechargeOrderRegionStatsBucket) []rechargeBillRegionBucketDTO { - byRegion := map[int64]*rechargeBillRegionBucketDTO{} - for _, row := range rows { - if row.RegionID <= 0 { - continue - } - bucket, ok := byRegion[row.RegionID] - if !ok { - bucket = &rechargeBillRegionBucketDTO{RegionID: row.RegionID} - byRegion[row.RegionID] = bucket - } - bucket.BillCount += row.Count - bucket.UsdMinorAmount += row.USDMinor - } - 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) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool { if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) { return true diff --git a/server/admin/internal/modules/payment/recharge_bill_stats.go b/server/admin/internal/modules/payment/recharge_bill_stats.go index 0bcd0271..8993ce35 100644 --- a/server/admin/internal/modules/payment/recharge_bill_stats.go +++ b/server/admin/internal/modules/payment/recharge_bill_stats.go @@ -49,26 +49,6 @@ type googleRechargePaidDTO struct { Error string `json:"error,omitempty"` } -type financeThirdPartyRechargeStats struct { - Verified rechargeBillSummaryBucketDTO - GrantedOverlap rechargeBillSummaryBucketDTO - VerifiedDaily map[string]rechargeBillSummaryBucketDTO - GrantedDaily map[string]rechargeBillSummaryBucketDTO - VerifiedRegions []repository.CoinSellerRechargeOrderRegionStatsBucket -} - -type financeThirdPartyRechargeQuery struct { - RechargeType string - Status string - Keyword string - RegionID int64 - StartAtMS int64 - EndAtMS int64 - TzOffsetMinutes int32 - TargetUserID int64 - HasUserOnlyFilter bool -} - // listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。 func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) { options := shared.ListOptions(c) @@ -105,6 +85,20 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) { tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes } if source, ok := h.billSources[appCode]; ok { + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") + if !ok { + return + } + sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller") + sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") + if !ok { + return + } + if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) { + response.OK(c, rechargeBillSummaryDTO{}) + return + } query := legacyRechargeBillQuery{ Keyword: options.Keyword, RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), @@ -114,22 +108,28 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) { EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), TzOffsetMinutes: tzOffsetMinutes, } - if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) { - return + summary := rechargeBillSummaryDTO{} + if !userFilter.IsEmpty() || !sellerFilter.IsEmpty() { + // legacy 普通充值汇总没有 user/seller 过滤能力;有用户筛选时只保留后续可精确计算的财务币商桶。 + } else { + var err error + summary, err = source.SummarizeRechargeBills(c.Request.Context(), query) + if err != nil { + response.ServerError(c, "获取充值汇总失败") + return + } } - summary, err := source.SummarizeRechargeBills(c.Request.Context(), query) - if err != nil { - response.ServerError(c, "获取充值汇总失败") - return - } - if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{ - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), - Status: options.Status, - Keyword: options.Keyword, - RegionID: query.RegionID, - StartAtMS: query.StartAtMS, - EndAtMS: query.EndAtMS, - TzOffsetMinutes: tzOffsetMinutes, + if err := h.applyFinanceCoinSellerSummary(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{ + RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), + Status: options.Status, + PaidState: query.PaidState, + Keyword: options.Keyword, + RegionID: query.RegionID, + StartAtMS: query.StartAtMS, + EndAtMS: query.EndAtMS, + TzOffsetMinutes: tzOffsetMinutes, + TargetUserID: sellerUserID, + HasUserOnlyFilter: userID > 0 && sellerUserID == 0, }, &summary); err != nil { response.ServerError(c, "获取充值汇总失败") return @@ -151,31 +151,36 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) { response.OK(c, rechargeBillSummaryDTO{}) return } - resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{ - RequestId: middleware.CurrentRequestID(c), - AppCode: appCode, - UserId: userID, - SellerUserId: sellerUserID, - RegionId: queryInt64(c, "region_id", "regionId"), - RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), - Status: options.Status, - Keyword: options.Keyword, - StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"), - EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"), - }) - if err != nil { - writeWalletError(c, err, "获取充值汇总失败") - return + paidState := strings.TrimSpace(firstQuery(c, "paid_state", "paidState")) + var summary rechargeBillSummaryDTO + if paidState == "" { + resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appCode, + UserId: userID, + SellerUserId: sellerUserID, + RegionId: queryInt64(c, "region_id", "regionId"), + RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), + Status: options.Status, + Keyword: options.Keyword, + StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"), + EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"), + }) + if err != nil { + writeWalletError(c, err, "获取充值汇总失败") + return + } + summary = rechargeBillSummaryDTO{ + Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()), + GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()), + ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()), + CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()), + } } - summary := rechargeBillSummaryDTO{ - Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()), - GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()), - ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()), - CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()), - } - if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{ + if err := h.applyFinanceCoinSellerSummary(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{ RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), Status: options.Status, + PaidState: paidState, Keyword: options.Keyword, RegionID: queryInt64(c, "region_id", "regionId"), StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), @@ -201,81 +206,6 @@ func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBuck } } -func (h *Handler) applyFinanceThirdPartySummary(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, summary *rechargeBillSummaryDTO) error { - if summary == nil { - return nil - } - stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query) - if err != nil { - return err - } - rechargeType := normalizedRechargeType(query.RechargeType) - if rechargeType == "" || rechargeType == "third_party" { - // 财务页 thirdParty 只表示后台工单校验通过的币商充值外部订单;普通 MifaPay/V5Pay/USDT H5 支付不能进入该桶。 - summary.ThirdParty = stats.Verified - } else { - summary.ThirdParty = rechargeBillSummaryBucketDTO{} - } - if rechargeType != "" && rechargeType != "coin_seller" { - summary.CoinSeller = rechargeBillSummaryBucketDTO{} - } - if rechargeType != "" && rechargeType != "google_play_recharge" { - summary.GooglePlay = rechargeBillSummaryBucketDTO{} - } - if rechargeType == "" { - // coinSeller 桶保持全量展示;total 扣掉已落账重叠部分,避免同一财务工单同时经 thirdParty 与 coinSeller 重复计入总额。 - summary.Total = nonNegativeRechargeBillBucket(rechargeBillSummaryBucketDTO{ - BillCount: summary.GooglePlay.BillCount + summary.CoinSeller.BillCount + summary.ThirdParty.BillCount - stats.GrantedOverlap.BillCount, - CoinAmount: summary.GooglePlay.CoinAmount + summary.CoinSeller.CoinAmount + summary.ThirdParty.CoinAmount - stats.GrantedOverlap.CoinAmount, - USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.CoinSeller.USDMinorAmount + summary.ThirdParty.USDMinorAmount - stats.GrantedOverlap.USDMinorAmount, - }) - return nil - } - legacyRecomputeSummaryTotal(summary) - return nil -} - -func (h *Handler) financeThirdPartyRechargeStats(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery) (financeThirdPartyRechargeStats, error) { - stats := financeThirdPartyRechargeStats{ - VerifiedDaily: map[string]rechargeBillSummaryBucketDTO{}, - GrantedDaily: map[string]rechargeBillSummaryBucketDTO{}, - } - rechargeType := normalizedRechargeType(query.RechargeType) - if rechargeType != "" && rechargeType != "third_party" && rechargeType != "coin_seller" { - return stats, nil - } - if h == nil || h.store == nil || query.HasUserOnlyFilter { - return stats, nil - } - repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{ - AppCode: appCode, - Status: query.Status, - 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.Verified = rechargeBillSummaryBucketDTO{BillCount: repoStats.VerifiedCount, CoinAmount: repoStats.VerifiedCoinAmount, USDMinorAmount: repoStats.VerifiedUSDMinor} - stats.GrantedOverlap = rechargeBillSummaryBucketDTO{BillCount: repoStats.GrantedCount, CoinAmount: repoStats.GrantedCoinAmount, USDMinorAmount: repoStats.GrantedUSDMinor} - stats.VerifiedDaily = financeThirdPartyDailyStats(repoStats.VerifiedDaily) - stats.GrantedDaily = financeThirdPartyDailyStats(repoStats.GrantedDaily) - stats.VerifiedRegions = repoStats.VerifiedRegionBuckets - return stats, nil -} - -func financeThirdPartyDailyStats(in map[string]repository.CoinSellerRechargeOrderStatsBucket) map[string]rechargeBillSummaryBucketDTO { - out := make(map[string]rechargeBillSummaryBucketDTO, len(in)) - for date, bucket := range in { - out[date] = rechargeBillSummaryBucketDTO{BillCount: bucket.Count, CoinAmount: bucket.CoinAmount, USDMinorAmount: bucket.USDMinor} - } - return out -} - func normalizedRechargeType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } diff --git a/server/admin/internal/repository/coin_seller_recharge_order_repository.go b/server/admin/internal/repository/coin_seller_recharge_order_repository.go index f5e76042..1499ccc0 100644 --- a/server/admin/internal/repository/coin_seller_recharge_order_repository.go +++ b/server/admin/internal/repository/coin_seller_recharge_order_repository.go @@ -19,6 +19,7 @@ var ( type CoinSellerRechargeOrderListOptions struct { Page int PageSize int + PageSizeLimit int AppCode string Status string VerifyStatus string @@ -26,6 +27,7 @@ type CoinSellerRechargeOrderListOptions struct { ProviderCode string Keyword string TargetUserID int64 + RegionID int64 OperatorID uint CreatedFromMS int64 CreatedToMS int64 @@ -111,7 +113,7 @@ func (s *Store) GetCoinSellerRechargeOrder(id uint) (*model.CoinSellerRechargeOr } func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderListOptions) ([]model.CoinSellerRechargeOrder, int64, error) { - page, pageSize := normalizePage(options.Page, options.PageSize) + page, pageSize := normalizeCoinSellerRechargeOrderPage(options.Page, options.PageSize, options.PageSizeLimit) query := applyCoinSellerRechargeOrderFilters(s.db.Model(&model.CoinSellerRechargeOrder{}), options) var total int64 if err := query.Count(&total).Error; err != nil { @@ -126,6 +128,22 @@ func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderList return orders, total, err } +func normalizeCoinSellerRechargeOrderPage(page int, pageSize int, pageSizeLimit int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSizeLimit <= 0 { + pageSizeLimit = 100 + } + if pageSize > pageSizeLimit { + pageSize = pageSizeLimit + } + return page, pageSize +} + func (s *Store) CoinSellerRechargeOrderStats(options CoinSellerRechargeOrderStatsOptions) (CoinSellerRechargeOrderStats, error) { stats := CoinSellerRechargeOrderStats{ VerifiedDaily: map[string]CoinSellerRechargeOrderStatsBucket{}, @@ -350,6 +368,9 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha if options.TargetUserID > 0 { query = query.Where("target_user_id = ?", options.TargetUserID) } + if options.RegionID > 0 { + query = query.Where("target_region_id = ?", options.RegionID) + } if options.OperatorID > 0 { query = query.Where("operator_user_id = ?", options.OperatorID) } @@ -361,7 +382,7 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" - query = query.Where("external_order_no LIKE ? OR target_display_user_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like) + query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like, like) } return query } @@ -392,7 +413,7 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" - query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like) + query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like, like) } return query }