2026-07-12 00:47:20 +08:00

557 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}