package payment import ( "context" "fmt" "sort" "strings" "sync" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/gin-gonic/gin" ) type rechargeBillDailyBucketDTO struct { Date string `json:"date"` GoogleUsdMinor int64 `json:"googleUsdMinor"` ThirdPartyUsdMinor int64 `json:"thirdPartyUsdMinor"` CoinSellerUsdMinor int64 `json:"coinSellerUsdMinor"` GoogleCoinAmount int64 `json:"googleCoinAmount"` ThirdPartyCoinAmount int64 `json:"thirdPartyCoinAmount"` CoinSellerCoinAmount int64 `json:"coinSellerCoinAmount"` } type rechargeBillRegionBucketDTO struct { RegionID int64 `json:"regionId"` Name string `json:"name"` BillCount int64 `json:"billCount"` UsdMinorAmount int64 `json:"usdMinorAmount"` } type rechargeBillGooglePaidStatsDTO struct { GoogleBillCount int64 `json:"googleBillCount"` SyncedCount int64 `json:"syncedCount"` UnsyncedCount int64 `json:"unsyncedCount"` CoveredUsdMinor int64 `json:"coveredUsdMinor"` EstFeeUsdMinor int64 `json:"estFeeUsdMinor"` EstTaxUsdMinor int64 `json:"estTaxUsdMinor"` EstNetUsdMinor int64 `json:"estNetUsdMinor"` } // rechargeBillWithdrawalStatsDTO 是“用户提现”合并口径:工资转币商(钱包账本)+ 审核通过的提现申请(admin 库)。 type rechargeBillWithdrawalStatsDTO struct { TransferCount int64 `json:"transferCount"` TransferUsdMinor int64 `json:"transferUsdMinor"` ApprovedCount int64 `json:"approvedCount"` ApprovedUsdMinor int64 `json:"approvedUsdMinor"` TotalUsdMinor int64 `json:"totalUsdMinor"` } type rechargeBillOverviewDTO struct { Daily []rechargeBillDailyBucketDTO `json:"daily"` Regions []rechargeBillRegionBucketDTO `json:"regions"` GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"` Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"` } // FinanceRechargeOverviewQuery 是供其他后台模块复用财务充值口径的只读查询。 // ScopeRegionIDs 非空时只汇总这些区域,避免数据范围受限的 BI 查询先读全量再裁剪。 type FinanceRechargeOverviewQuery struct { AppCode string StartAtMS int64 EndAtMS int64 ScopeRegionIDs []int64 IncludeRegions bool TZOffsetMinutes int32 } // FinanceRechargeDailyBucket 与 Finance 页面趋势使用同一组渠道字段和切日口径。 type FinanceRechargeDailyBucket struct { Date string GoogleUSDMinor int64 ThirdPartyUSDMinor int64 CoinSellerUSDMinor int64 } // FinanceRechargeRegionOverview 在全量查询时只返回 Finance 页面已有的区域总额; // 仅显式区域范围查询才补齐逐日渠道,避免默认概览对所有区域做 N+1 聚合。 type FinanceRechargeRegionOverview struct { RegionID int64 TotalUSDMinor int64 DailyAvailable bool Daily []FinanceRechargeDailyBucket } type FinanceRechargeOverview struct { Daily []FinanceRechargeDailyBucket Regions []FinanceRechargeRegionOverview } // FinanceRechargeOverviewSource 让 Social BI 依赖 Finance 的业务口径,而不是依赖 payment HTTP handler。 type FinanceRechargeOverviewSource interface { FinanceRechargeOverview(context.Context, FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error) } // GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。 func (h *Handler) GetRechargeBillOverview(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes")) if tzOffsetMinutes == 0 { // 财务系统统一按中国时区切日边界。 tzOffsetMinutes = 480 } 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 } 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, } // HTTP 页面和 Social BI 都走同一个装配函数;后续口径调整只允许在这里发生一次。 overview, err := h.loadFinanceRechargeOverview(c.Request.Context(), appCode, financeQuery, userFilter.IsEmpty() && sellerFilter.IsEmpty(), true) if err != nil { writeWalletError(c, err, "获取财务概览失败") return } h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS) response.OK(c, overview) } // FinanceRechargeOverview 暴露 Finance 页面自身使用的充值聚合,供 Social BI 复用。 // 默认只复用 Finance 已经返回的全局逐日与区域总额;显式区域筛选才按区域补逐日渠道。 func (h *Handler) FinanceRechargeOverview(ctx context.Context, query FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error) { appCode := appctx.Normalize(query.AppCode) if appCode == "" { return FinanceRechargeOverview{}, fmt.Errorf("app_code is required") } tzOffsetMinutes := query.TZOffsetMinutes if tzOffsetMinutes == 0 { tzOffsetMinutes = 480 } regionIDs := uniquePositiveInt64s(query.ScopeRegionIDs) baseQuery := financeCoinSellerRechargeQuery{ StartAtMS: query.StartAtMS, EndAtMS: query.EndAtMS, TzOffsetMinutes: tzOffsetMinutes, } if len(regionIDs) > 0 { regions, err := h.loadFinanceRechargeRegions(ctx, appCode, baseQuery, regionIDs) if err != nil { return FinanceRechargeOverview{}, err } return FinanceRechargeOverview{Daily: mergeFinanceRechargeRegionDaily(regions), Regions: regions}, nil } overview, err := h.loadFinanceRechargeOverview(ctx, appCode, baseQuery, true, query.IncludeRegions) if err != nil { return FinanceRechargeOverview{}, err } result := FinanceRechargeOverview{Daily: financeRechargeDailyBuckets(overview.Daily)} if !query.IncludeRegions { return result, nil } for _, region := range overview.Regions { if region.RegionID <= 0 { continue } result.Regions = append(result.Regions, FinanceRechargeRegionOverview{ RegionID: region.RegionID, TotalUSDMinor: region.UsdMinorAmount, }) } sort.Slice(result.Regions, func(i, j int) bool { return result.Regions[i].RegionID < result.Regions[j].RegionID }) return result, nil } func (h *Handler) loadFinanceRechargeRegions(ctx context.Context, appCode string, baseQuery financeCoinSellerRechargeQuery, regionIDs []int64) ([]FinanceRechargeRegionOverview, error) { type regionResult struct { region FinanceRechargeRegionOverview err error } results := make(chan regionResult, len(regionIDs)) semaphore := make(chan struct{}, 4) var wg sync.WaitGroup for _, regionID := range uniquePositiveInt64s(regionIDs) { regionID := regionID wg.Add(1) go func() { defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() regionQuery := baseQuery regionQuery.RegionID = regionID // 单区域默认趋势已经同时返回 Google/三方渠道;这里不再额外查询两次渠道区域分布。 overview, err := h.loadFinanceRechargeOverview(ctx, appCode, regionQuery, true, false) daily := financeRechargeDailyBuckets(overview.Daily) results <- regionResult{ region: FinanceRechargeRegionOverview{ RegionID: regionID, TotalUSDMinor: financeRechargeDailyTotal(daily), DailyAvailable: true, Daily: daily, }, err: err, } }() } wg.Wait() close(results) regions := make([]FinanceRechargeRegionOverview, 0, len(regionIDs)) for result := range results { if result.err != nil { return nil, result.err } regions = append(regions, result.region) } sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID }) return regions, nil } func financeRechargeDailyTotal(rows []FinanceRechargeDailyBucket) int64 { var total int64 for _, row := range rows { total += row.GoogleUSDMinor + row.ThirdPartyUSDMinor + row.CoinSellerUSDMinor } return total } // loadFinanceRechargeOverview 是 Finance HTTP 页面与模块内复用入口的唯一装配路径。 // ordinary 与币商统计仍分别由 wallet/legacy source 和 financeCoinSellerRechargeStats 提供,禁止回退旧库存币商口径。 func (h *Handler) loadFinanceRechargeOverview(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, includeOrdinary bool, includeRegions bool) (rechargeBillOverviewDTO, error) { overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}} if source, ok := h.billSources[appCode]; ok { if includeOrdinary { var err error overview, err = source.Overview(ctx, legacyRechargeBillQuery{ Keyword: query.Keyword, RechargeType: query.RechargeType, PaidState: query.PaidState, RegionID: query.RegionID, StartAtMS: query.StartAtMS, EndAtMS: query.EndAtMS, }, query.TzOffsetMinutes) if err != nil { return rechargeBillOverviewDTO{}, err } } if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil { return rechargeBillOverviewDTO{}, err } return overview, nil } if includeOrdinary && financeWalletOverviewSupportsOrdinaryFilters(query) { if h.wallet == nil { return rechargeBillOverviewDTO{}, fmt.Errorf("wallet recharge overview is not configured") } resp, err := h.wallet.GetRechargeBillOverview(ctx, &walletv1.GetRechargeBillOverviewRequest{ RequestId: query.RequestID, AppCode: appCode, RegionId: query.RegionID, RechargeType: query.RechargeType, Status: query.Status, StartAtMs: query.StartAtMS, EndAtMs: query.EndAtMS, TzOffsetMinutes: query.TzOffsetMinutes, }) if err != nil { return rechargeBillOverviewDTO{}, err } 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(), } } if transfer := resp.GetSalaryTransfer(); transfer != nil { overview.Withdrawal.TransferCount = transfer.GetTransferCount() overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor() } } if includeOrdinary && includeRegions && normalizedRechargeType(query.RechargeType) == "" { ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(ctx, appCode, query) if err != nil { return rechargeBillOverviewDTO{}, err } overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, ordinaryRegions) } if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil { return rechargeBillOverviewDTO{}, err } return overview, nil } func financeRechargeDailyBuckets(rows []rechargeBillDailyBucketDTO) []FinanceRechargeDailyBucket { out := make([]FinanceRechargeDailyBucket, 0, len(rows)) for _, row := range rows { if strings.TrimSpace(row.Date) == "" { continue } out = append(out, FinanceRechargeDailyBucket{ Date: row.Date, GoogleUSDMinor: row.GoogleUsdMinor, ThirdPartyUSDMinor: row.ThirdPartyUsdMinor, CoinSellerUSDMinor: row.CoinSellerUsdMinor, }) } sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date }) return out } func mergeFinanceRechargeRegionDaily(regions []FinanceRechargeRegionOverview) []FinanceRechargeDailyBucket { byDay := map[string]FinanceRechargeDailyBucket{} for _, region := range regions { for _, row := range region.Daily { current := byDay[row.Date] current.Date = row.Date current.GoogleUSDMinor += row.GoogleUSDMinor current.ThirdPartyUSDMinor += row.ThirdPartyUSDMinor current.CoinSellerUSDMinor += row.CoinSellerUSDMinor byDay[row.Date] = current } } out := make([]FinanceRechargeDailyBucket, 0, len(byDay)) for _, row := range byDay { out = append(out, row) } sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date }) return out } func uniquePositiveInt64s(values []int64) []int64 { seen := map[int64]struct{}{} out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool { if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) { return true } if _, _, _, err := legacyDashboardDayRange(query, tzOffsetMinutes); err != nil { response.BadRequest(c, "币商统计仅支持明确整日时间范围") return false } return true } // fillApprovedWithdrawalStats 把 admin 库的“审核通过提现申请”并入用户提现口径;查询失败只降级该卡片,不阻断概览。 func (h *Handler) fillApprovedWithdrawalStats(overview *rechargeBillOverviewDTO, appCode string, startAtMS int64, endAtMS int64) { if h.store != nil { if stats, err := h.store.ApprovedWithdrawalStats(appCode, startAtMS, endAtMS); err == nil { overview.Withdrawal.ApprovedCount = stats.ApprovedCount overview.Withdrawal.ApprovedUsdMinor = stats.ApprovedUSDMinor } } overview.Withdrawal.TotalUsdMinor = overview.Withdrawal.TransferUsdMinor + overview.Withdrawal.ApprovedUsdMinor }