package databi import ( "encoding/json" "math" "strconv" "strings" ) // additiveMetricKeys 是各统计源(statistics-service / dashboard 外接源)国家行里可以线性相加的指标; // 国家按用户归属互斥,所以计数与金额按国家求和即为区域/整体口径,比例类字段一律丢弃后重算。 var additiveMetricKeys = []string{ "new_users", "registered_users", "registrations", "active_users", "paid_users", "new_paid_users", "recharge_users", "recharge_usd_minor", "total_recharge_usd_minor", "user_recharge_usd_minor", "new_user_recharge_usd_minor", "coin_seller_recharge_usd_minor", "coin_seller_stock_coin", "coin_seller_transfer_coin", "new_dealer_recharge_usd_minor", "mifapay_recharge_usd_minor", "google_recharge_usd_minor", "coin_total", "consumed_coin", "output_coin", "platform_grant_coin", "manual_grant_coin", "salary_usd_minor", "salary_exchange_usd_minor", "salary_transfer_usd_minor", "salary_transfer_coin", "mic_online_ms", "mic_online_users", "gift_coin_spent", "lucky_gift_turnover", "lucky_gift_payout", "lucky_gift_payers", "lucky_gift_anchor_share", "room_lucky_gift_turnover", "game_turnover", "game_payout", "game_refund", "game_players", "super_lucky_gift_turnover", "super_lucky_gift_payout", "d1_retention_users", "d1_retention_base_users", "d7_retention_users", "d7_retention_base_users", "d30_retention_users", "d30_retention_base_users", } type metricAccumulator struct { values map[string]int64 present map[string]bool } func newMetricAccumulator() *metricAccumulator { return &metricAccumulator{values: map[string]int64{}, present: map[string]bool{}} } func (acc *metricAccumulator) addRow(row map[string]any) { if row == nil { return } for _, key := range additiveMetricKeys { raw, ok := row[key] if !ok || raw == nil { continue } value, ok := metricInt64(raw) if !ok { continue } acc.values[key] += value acc.present[key] = true } } // toMap 输出与统计源同名的指标字段:可加字段直接给累加值,派生字段(比例/均值/利润)统一重算; // 某个字段在所有来源行里都缺失(legacy 口径未接入)时输出 nil,前端照旧显示 "--"。 func (acc *metricAccumulator) toMap() map[string]any { out := map[string]any{} for _, key := range additiveMetricKeys { if acc.present[key] { out[key] = acc.values[key] } else { out[key] = nil } } recharge, hasRecharge := acc.value("recharge_usd_minor") active, hasActive := acc.value("active_users") paid, hasPaid := acc.value("paid_users") rechargeUsers, hasRechargeUsers := acc.value("recharge_users") out["arpu_usd_minor"] = nilUnless(hasRecharge && hasActive, divideRound(recharge, active)) out["arppu_usd_minor"] = nilUnless(hasRecharge && hasPaid, divideRound(recharge, paid)) out["payer_rate"] = nilUnless(hasPaid && hasActive, safeRatio(paid, active)) out["paid_conversion_rate"] = nilUnless(hasPaid && hasActive, safeRatio(paid, active)) out["recharge_conversion_rate"] = nilUnless(hasRechargeUsers && hasActive, safeRatio(rechargeUsers, active)) gameTurnover, hasGameTurnover := acc.value("game_turnover") gamePayout, hasGamePayout := acc.value("game_payout") if hasGameTurnover && hasGamePayout { profit := gameTurnover - gamePayout out["game_profit"] = profit out["game_profit_rate"] = safeRatio(profit, gameTurnover) } else { out["game_profit"] = nil out["game_profit_rate"] = nil } luckyTurnover, hasLuckyTurnover := acc.value("lucky_gift_turnover") luckyPayout, hasLuckyPayout := acc.value("lucky_gift_payout") if hasLuckyTurnover && hasLuckyPayout { profit := luckyTurnover - luckyPayout out["lucky_gift_profit"] = profit out["lucky_gift_payout_rate"] = safeRatio(luckyPayout, luckyTurnover) out["lucky_gift_profit_rate"] = safeRatio(profit, luckyTurnover) } else { out["lucky_gift_profit"] = nil out["lucky_gift_payout_rate"] = nil out["lucky_gift_profit_rate"] = nil } superTurnover, hasSuperTurnover := acc.value("super_lucky_gift_turnover") superPayout, hasSuperPayout := acc.value("super_lucky_gift_payout") if hasSuperTurnover && hasSuperPayout { profit := superTurnover - superPayout out["super_lucky_gift_profit"] = profit out["super_lucky_gift_profit_rate"] = safeRatio(profit, superTurnover) } else { out["super_lucky_gift_profit"] = nil out["super_lucky_gift_profit_rate"] = nil } consumed, hasConsumed := acc.value("consumed_coin") output, hasOutput := acc.value("output_coin") out["consume_output_ratio"] = nilUnless(hasConsumed && hasOutput, safeRatio(consumed, output)) out["consume_output_delta"] = nilUnless(hasConsumed && hasOutput, consumed-output) micMS, hasMicMS := acc.value("mic_online_ms") micUsers, hasMicUsers := acc.value("mic_online_users") out["avg_mic_online_ms"] = nilUnless(hasMicMS && hasMicUsers, divideRound(micMS, micUsers)) for _, day := range []string{"d1", "d7", "d30"} { users, hasUsers := acc.value(day + "_retention_users") base, hasBase := acc.value(day + "_retention_base_users") out[day+"_retention_rate"] = nilUnless(hasUsers && hasBase, safeRatio(users, base)) } return out } func (acc *metricAccumulator) value(key string) (int64, bool) { return acc.values[key], acc.present[key] } func nilUnless(condition bool, value any) any { if !condition { return nil } return value } func divideRound(numerator int64, denominator int64) int64 { if denominator == 0 { return 0 } return int64(math.Round(float64(numerator) / float64(denominator))) } func safeRatio(numerator int64, denominator int64) float64 { if denominator == 0 { return 0 } return float64(numerator) / float64(denominator) } func metricInt64(value any) (int64, bool) { switch typed := value.(type) { case nil: return 0, false case int64: return typed, true case int32: return int64(typed), true case int: return int64(typed), true case uint: return int64(typed), true case uint64: return int64(typed), true case float64: return int64(math.Round(typed)), true case float32: return int64(math.Round(float64(typed))), true case json.Number: if parsed, err := typed.Int64(); err == nil { return parsed, true } if parsed, err := typed.Float64(); err == nil { return int64(math.Round(parsed)), true } return 0, false case string: trimmed := strings.TrimSpace(typed) if trimmed == "" { return 0, false } if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil { return parsed, true } if parsed, err := strconv.ParseFloat(trimmed, 64); err == nil { return int64(math.Round(parsed)), true } return 0, false default: return 0, false } } func anyRowSlice(value any) []map[string]any { switch typed := value.(type) { case []map[string]any: return typed case []any: out := make([]map[string]any, 0, len(typed)) for _, item := range typed { if row, ok := item.(map[string]any); ok { out = append(out, row) } } return out default: return nil } } func rowString(row map[string]any, keys ...string) string { for _, key := range keys { if raw, ok := row[key]; ok && raw != nil { if text, ok := raw.(string); ok { if trimmed := strings.TrimSpace(text); trimmed != "" { return trimmed } } } } return "" } func rowInt64(row map[string]any, keys ...string) (int64, bool) { for _, key := range keys { if raw, ok := row[key]; ok && raw != nil { if value, ok := metricInt64(raw); ok { return value, true } } } return 0, false }