package databi import ( "context" "sort" "strconv" "strings" "hyapp-admin-server/internal/modules/payment" ) const financeRechargeTZOffsetMinutes int32 = 480 type financeRechargeValues struct { GoogleUSDMinor int64 ThirdPartyUSDMinor int64 CoinSellerUSDMinor int64 } func (v financeRechargeValues) userUSDMinor() int64 { return v.GoogleUSDMinor + v.ThirdPartyUSDMinor } func (v financeRechargeValues) totalUSDMinor() int64 { return v.userUSDMinor() + v.CoinSellerUSDMinor } func (s *Service) applyFinanceRechargeOverview( ctx context.Context, out *AppOverview, query OverviewQuery, regions []RegionInfo, restricted bool, allowedRegionIDs []int64, ) error { if s == nil || s.financeRecharge == nil || out == nil { return nil } scopeRegionIDs := []int64(nil) if query.RegionID > 0 { scopeRegionIDs = []int64{normalizeRegionID(regions, query.RegionID)} } else if len(query.RegionIDs) > 0 { scopeRegionIDs = normalizeRegionIDs(regions, query.RegionIDs) } else if restricted { scopeRegionIDs = normalizeRegionIDs(regions, allowedRegionIDs) } current, err := s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{ AppCode: out.AppCode, StartAtMS: query.StartMS, EndAtMS: query.EndMS, ScopeRegionIDs: scopeRegionIDs, IncludeRegions: true, TZOffsetMinutes: financeRechargeTZOffsetMinutes, }) if err != nil { return err } var previous payment.FinanceRechargeOverview if query.StartMS > 0 && query.EndMS > query.StartMS { periodLength := query.EndMS - query.StartMS previous, err = s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{ AppCode: out.AppCode, StartAtMS: query.StartMS - periodLength, EndAtMS: query.StartMS, ScopeRegionIDs: scopeRegionIDs, IncludeRegions: false, TZOffsetMinutes: financeRechargeTZOffsetMinutes, }) if err != nil { return err } } currentTotal := financeRechargeOverviewTotal(current) previousTotal := financeRechargeOverviewTotal(previous) applyFinanceRechargeValues(out.Total, currentTotal) if query.StartMS > 0 && query.EndMS > query.StartMS { out.Total["recharge_delta_rate"] = financeRechargeDelta(currentTotal.totalUSDMinor(), previousTotal.totalUSDMinor()) out.Total["user_recharge_delta_rate"] = financeRechargeDelta(currentTotal.userUSDMinor(), previousTotal.userUSDMinor()) } // Finance 没有付费用户人数口径;金额切源后旧 ARPU/ARPPU 环比不可继续展示,否则分子来自旧 Dashboard。 out.Total["arpu_delta_rate"] = nil out.Total["arppu_delta_rate"] = nil out.DailySeries = overlayFinanceRechargeDaily(out.DailySeries, current.Daily) out.RegionBreakdown, out.DailyRegionBreakdown = overlayFinanceRechargeRegions( out.RegionBreakdown, out.DailyRegionBreakdown, current.Regions, regions, ) // Finance 页面没有国家维度,国家充值必须整体标为不可用;保留任何旧 Dashboard 金额都会导致 // App/区域合计与国家合计无法对账,并与下发的 Finance 指标来源说明矛盾。 markFinanceRechargeUnavailable(out.CountryBreakdown) markFinanceRechargeUnavailable(out.DailyCountryBreakdown) out.ReportMetricSources = appendFinanceRechargeMetricSources(out.ReportMetricSources) return nil } func (s *Service) applyFinanceRechargeKpiWindow( ctx context.Context, appCode string, startMS int64, endMS int64, total map[string]any, regionMetrics map[int64]map[string]any, ) error { financeOverview, err := s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{ AppCode: appCode, StartAtMS: startMS, EndAtMS: endMS, IncludeRegions: true, TZOffsetMinutes: financeRechargeTZOffsetMinutes, }) if err != nil { return err } applyFinanceRechargeValues(total, financeRechargeOverviewTotal(financeOverview)) for regionID, row := range regionMetrics { zeroFinanceRecharge([]map[string]any{row}) regionMetrics[regionID] = row } for _, financeRegion := range financeOverview.Regions { row := regionMetrics[financeRegion.RegionID] if row == nil { row = map[string]any{"region_id": financeRegion.RegionID} regionMetrics[financeRegion.RegionID] = row } // KPI 只消费区域总充值;Finance 区域分布没有渠道拆分,不能用 0 伪造各渠道。 applyFinanceRechargeTotalOnly(row, financeRegion.TotalUSDMinor) } return nil } func financeRechargeOverviewTotal(overview payment.FinanceRechargeOverview) financeRechargeValues { var total financeRechargeValues for _, row := range overview.Daily { total.GoogleUSDMinor += row.GoogleUSDMinor total.ThirdPartyUSDMinor += row.ThirdPartyUSDMinor total.CoinSellerUSDMinor += row.CoinSellerUSDMinor } return total } func financeRechargeValuesFromDaily(row payment.FinanceRechargeDailyBucket) financeRechargeValues { return financeRechargeValues{ GoogleUSDMinor: row.GoogleUSDMinor, ThirdPartyUSDMinor: row.ThirdPartyUSDMinor, CoinSellerUSDMinor: row.CoinSellerUSDMinor, } } func applyFinanceRechargeValues(row map[string]any, values financeRechargeValues) { if row == nil { return } row["google_recharge_usd_minor"] = values.GoogleUSDMinor // Databi 历史字段名是 mifapay,但 Finance 的 thirdParty 包含全部非 Google 三方渠道。 row["mifapay_recharge_usd_minor"] = values.ThirdPartyUSDMinor row["third_party_recharge_usd_minor"] = values.ThirdPartyUSDMinor row["coin_seller_recharge_usd_minor"] = values.CoinSellerUSDMinor row["user_recharge_usd_minor"] = values.userUSDMinor() row["recharge_usd_minor"] = values.totalUSDMinor() row["total_recharge_usd_minor"] = values.totalUSDMinor() row["new_user_recharge_usd_minor"] = nil markFinanceRechargeDerivedUnavailable(row) } func applyFinanceRechargeTotalOnly(row map[string]any, totalUSDMinor int64) { if row == nil { return } markFinanceRechargeUnavailable([]map[string]any{row}) row["recharge_usd_minor"] = totalUSDMinor row["total_recharge_usd_minor"] = totalUSDMinor } func markFinanceRechargeDerivedUnavailable(row map[string]any) { if row == nil { return } // Finance 的币商/H5 金额没有与旧 Dashboard paid_users 一致的去重人数, // 因此 ARPU/ARPPU 不做跨来源除法;前端通过该标记在跨 App 聚合时继续保持缺失。 row["finance_recharge_aligned"] = true row["arpu_usd_minor"] = nil row["arppu_usd_minor"] = nil } func overlayFinanceRechargeDaily(existing []map[string]any, finance []payment.FinanceRechargeDailyBucket) []map[string]any { byDay := make(map[string]payment.FinanceRechargeDailyBucket, len(finance)) for _, row := range finance { if row.Date != "" { byDay[row.Date] = row } } seen := map[string]struct{}{} for _, row := range existing { day := rowString(row, "stat_day", "label") bucket := byDay[day] applyFinanceRechargeValues(row, financeRechargeValuesFromDaily(bucket)) if day != "" { seen[day] = struct{}{} } } for day, bucket := range byDay { if _, ok := seen[day]; ok { continue } row := map[string]any{"label": day, "stat_day": day} applyFinanceRechargeValues(row, financeRechargeValuesFromDaily(bucket)) existing = append(existing, row) } sort.Slice(existing, func(i, j int) bool { return rowString(existing[i], "stat_day", "label") < rowString(existing[j], "stat_day", "label") }) return existing } func overlayFinanceRechargeRegions( regionRows []map[string]any, dailyRegionRows []map[string]any, financeRegions []payment.FinanceRechargeRegionOverview, regions []RegionInfo, ) ([]map[string]any, []map[string]any) { zeroFinanceRecharge(regionRows) dailyAvailable := false for _, financeRegion := range financeRegions { if financeRegion.DailyAvailable { dailyAvailable = true break } } if dailyAvailable { zeroFinanceRecharge(dailyRegionRows) } else { // 默认全量查询只复用 Finance 已有的区域总额;逐日渠道仅在用户显式选择区域时查询。 // 这里用 null 清掉旧 Dashboard 值,避免“未查询”被展示成 0 或旧口径。 markFinanceRechargeUnavailable(dailyRegionRows) } regionIndex := map[int64]map[string]any{} for _, row := range regionRows { if regionID, ok := rowInt64(row, "region_id", "regionId"); ok { regionIndex[normalizeRegionID(regions, regionID)] = row } } dailyIndex := map[string]map[string]any{} for _, row := range dailyRegionRows { regionID, ok := rowInt64(row, "region_id", "regionId") if !ok { continue } day := rowString(row, "stat_day", "label") dailyIndex[financeRegionDayKey(normalizeRegionID(regions, regionID), day)] = row } for _, financeRegion := range financeRegions { regionID := normalizeRegionID(regions, financeRegion.RegionID) row := regionIndex[regionID] if row == nil { code, name := regionDisplay(regions, regionID) row = map[string]any{"region_id": regionID, "region_code": code, "region_name": name} regionRows = append(regionRows, row) regionIndex[regionID] = row } var total financeRechargeValues for _, dayBucket := range financeRegion.Daily { values := financeRechargeValuesFromDaily(dayBucket) total.GoogleUSDMinor += values.GoogleUSDMinor total.ThirdPartyUSDMinor += values.ThirdPartyUSDMinor total.CoinSellerUSDMinor += values.CoinSellerUSDMinor key := financeRegionDayKey(regionID, dayBucket.Date) dailyRow := dailyIndex[key] if dailyRow == nil { code, name := regionDisplay(regions, regionID) dailyRow = map[string]any{ "label": dayBucket.Date, "stat_day": dayBucket.Date, "region_id": regionID, "region_code": code, "region_name": name, } dailyRegionRows = append(dailyRegionRows, dailyRow) dailyIndex[key] = dailyRow } applyFinanceRechargeValues(dailyRow, values) } if financeRegion.DailyAvailable { applyFinanceRechargeValues(row, total) } else { applyFinanceRechargeTotalOnly(row, financeRegion.TotalUSDMinor) } } sort.Slice(regionRows, func(i, j int) bool { left, _ := rowInt64(regionRows[i], "region_id", "regionId") right, _ := rowInt64(regionRows[j], "region_id", "regionId") return left < right }) sort.Slice(dailyRegionRows, func(i, j int) bool { leftDay := rowString(dailyRegionRows[i], "stat_day", "label") rightDay := rowString(dailyRegionRows[j], "stat_day", "label") if leftDay != rightDay { return leftDay < rightDay } left, _ := rowInt64(dailyRegionRows[i], "region_id", "regionId") right, _ := rowInt64(dailyRegionRows[j], "region_id", "regionId") return left < right }) return regionRows, dailyRegionRows } func zeroFinanceRecharge(rows []map[string]any) { for _, row := range rows { applyFinanceRechargeValues(row, financeRechargeValues{}) } } func markFinanceRechargeUnavailable(rows []map[string]any) { for _, row := range rows { row["google_recharge_usd_minor"] = nil row["mifapay_recharge_usd_minor"] = nil row["third_party_recharge_usd_minor"] = nil row["coin_seller_recharge_usd_minor"] = nil row["user_recharge_usd_minor"] = nil row["recharge_usd_minor"] = nil row["total_recharge_usd_minor"] = nil row["new_user_recharge_usd_minor"] = nil markFinanceRechargeDerivedUnavailable(row) } } func requirementsIncludesRevenue(section string) bool { switch strings.ToLower(strings.TrimSpace(section)) { case "", "all", "revenue": return true default: return false } } func requirementsHasCohortFilter(query RequirementsQuery) bool { for _, value := range []string{query.UserRole, query.PayerType, query.NewUserType} { normalized := strings.ToLower(strings.TrimSpace(value)) if normalized != "" && normalized != "all" { return true } } return false } func overlayFinanceRechargeRequirements(sections []map[string]any, overview payment.FinanceRechargeOverview) { byDay := make(map[string]financeRechargeValues, len(overview.Daily)) for _, bucket := range overview.Daily { if bucket.Date != "" { byDay[bucket.Date] = financeRechargeValuesFromDaily(bucket) } } total := financeRechargeOverviewTotal(overview) for _, section := range sections { if rowString(section, "key") != "revenue" { continue } section["recharge_source"] = "finance" section["finance_recharge_available"] = true updateFinanceRequirementMetadata(section, true) if row, ok := section["total"].(map[string]any); ok { applyFinanceRequirementAmounts(row, total, 1) } daily := anyRowSlice(section["daily_series"]) seen := map[string]struct{}{} for _, row := range daily { day := rowString(row, "stat_day", "label") applyFinanceRequirementAmounts(row, byDay[day], 1) if day != "" { seen[day] = struct{}{} } } for day, values := range byDay { if _, ok := seen[day]; ok { continue } row := map[string]any{"label": day, "stat_day": day} applyFinanceRequirementAmounts(row, values, 1) daily = append(daily, row) } sort.Slice(daily, func(i, j int) bool { return rowString(daily[i], "stat_day", "label") < rowString(daily[j], "stat_day", "label") }) section["daily_series"] = daily if row, ok := section["average"].(map[string]any); ok { applyFinanceRequirementAmounts(row, total, len(daily)) } } } func markFinanceRechargeRequirementsUnavailable(sections []map[string]any) { for _, section := range sections { if rowString(section, "key") != "revenue" { continue } section["recharge_source"] = "unavailable_for_cohort" section["finance_recharge_available"] = false updateFinanceRequirementMetadata(section, false) if row, ok := section["total"].(map[string]any); ok { markFinanceRequirementAmountsUnavailable(row) } if row, ok := section["average"].(map[string]any); ok { markFinanceRequirementAmountsUnavailable(row) } for _, row := range anyRowSlice(section["daily_series"]) { markFinanceRequirementAmountsUnavailable(row) } } } func applyFinanceRequirementAmounts(row map[string]any, values financeRechargeValues, dayCount int) { if row == nil { return } assign := func(key string, value int64) { if dayCount > 1 { row[key] = float64(value) / float64(dayCount) return } row[key] = value } assign("recharge_usd_minor", values.totalUSDMinor()) assign("google_recharge_usd_minor", values.GoogleUSDMinor) assign("third_party_recharge_usd_minor", values.ThirdPartyUSDMinor) assign("coin_seller_recharge_usd_minor", values.CoinSellerUSDMinor) // Finance 无首充/复购和去重付费人数维度,这些金额均值不能继续沿用宽表分子。 row["first_recharge_usd_minor"] = nil row["repeat_recharge_usd_minor"] = nil row["arpu_usd_minor"] = nil row["arppu_usd_minor"] = nil row["finance_recharge_aligned"] = true } func markFinanceRequirementAmountsUnavailable(row map[string]any) { if row == nil { return } for _, key := range []string{ "recharge_usd_minor", "google_recharge_usd_minor", "third_party_recharge_usd_minor", "coin_seller_recharge_usd_minor", "first_recharge_usd_minor", "repeat_recharge_usd_minor", "arpu_usd_minor", "arppu_usd_minor", } { row[key] = nil } row["finance_recharge_aligned"] = true } func updateFinanceRequirementMetadata(section map[string]any, available bool) { definitions := map[string]string{ "recharge_usd_minor": "Finance 成功充值总额 = Google + 非 Google 三方 + 币商充值。", "google_recharge_usd_minor": "Finance Google 成功充值。", "third_party_recharge_usd_minor": "Finance 非 Google 三方成功充值。", "coin_seller_recharge_usd_minor": "Finance 已核验币商订单 + H5 币商身份成功充值。", "first_recharge_usd_minor": "Finance 暂无首充金额拆分,本字段不可用。", "repeat_recharge_usd_minor": "Finance 暂无复购金额拆分,本字段不可用。", "arpu_usd_minor": "Finance 金额没有同口径活跃人数,本字段不可用。", "arppu_usd_minor": "Finance 币商/H5 金额没有同口径付费人数,本字段不可用。", } if !available { for _, key := range []string{"recharge_usd_minor", "google_recharge_usd_minor", "third_party_recharge_usd_minor", "coin_seller_recharge_usd_minor"} { definitions[key] = "Finance 不支持当前角色/付费身份/新老用户筛选,本字段不可用。" } } metricDefinitions := anyRowSlice(section["metric_definitions"]) definitionIndex := map[string]map[string]any{} for _, row := range metricDefinitions { definitionIndex[rowString(row, "metric", "field")] = row } for metric, definition := range definitions { row := definitionIndex[metric] if row == nil { row = map[string]any{"metric": metric} metricDefinitions = append(metricDefinitions, row) } row["definition"] = definition } section["metric_definitions"] = metricDefinitions columns := anyRowSlice(section["columns"]) for _, column := range columns { key := rowString(column, "key") if definition, ok := definitions[key]; ok { column["tooltip"] = definition } } section["columns"] = columns } func financeRegionDayKey(regionID int64, day string) string { return strconv.FormatInt(regionID, 10) + "|" + day } func financeRechargeDelta(current int64, previous int64) float64 { if previous == 0 { if current > 0 { return 1 } return 0 } denominator := previous if denominator < 0 { denominator = -denominator } return float64(current-previous) / float64(denominator) } func appendFinanceRechargeMetricSources(value any) []map[string]any { out := anyRowSlice(value) fields := []struct { field string definition string }{ {"recharge_usd_minor", "Finance 总充值 = Google + 三方 + 币商充值。"}, {"google_recharge_usd_minor", "Finance 谷歌成功充值账单。"}, {"mifapay_recharge_usd_minor", "Finance 非 Google 三方成功充值账单。"}, {"coin_seller_recharge_usd_minor", "Finance 已核验币商订单 + H5 币商身份成功充值。"}, } for _, item := range fields { replaced := false for index := range out { if rowString(out[index], "field") != item.field { continue } out[index] = map[string]any{ "available": true, "definition": item.definition, "field": item.field, "source": "Finance recharge-bills overview", } replaced = true break } if !replaced { out = append(out, map[string]any{ "available": true, "definition": item.definition, "field": item.field, "source": "Finance recharge-bills overview", }) } } return out }