数据打通 yumi aslan

This commit is contained in:
zhx 2026-07-03 18:49:44 +08:00
parent c53b710539
commit a2252a7115
25 changed files with 6138 additions and 3326 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1140,10 +1140,18 @@ message RechargeBillGooglePaidStats {
int64 est_net_usd_minor = 7;
}
// RechargeBillSalaryTransferStats
// USDT salary_transfer_to_coin_seller
message RechargeBillSalaryTransferStats {
int64 transfer_count = 1;
int64 transfer_usd_minor = 2;
}
message GetRechargeBillOverviewResponse {
repeated RechargeBillDailyBucket daily = 1;
repeated RechargeBillRegionBucket regions = 2;
RechargeBillGooglePaidStats google_paid = 3;
RechargeBillSalaryTransferStats salary_transfer = 4;
}
message RefreshGooglePaymentPricesRequest {

View File

@ -41,6 +41,7 @@ import (
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi"
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
@ -297,6 +298,16 @@ func main() {
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
)
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
dashboardService := dashboardmodule.NewService(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...))
databiService := databimodule.NewService(
store,
dashboardService,
appregistrymodule.NewService(userDB),
userclient.NewGRPC(userConn),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
)
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
@ -312,7 +323,8 @@ func main() {
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...)),
Dashboard: dashboardmodule.NewWithService(dashboardService),
Databi: databimodule.New(databiService, store, auditHandler),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
@ -508,6 +520,38 @@ func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, config
}
}
// databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录;
// 同一 App 配多个源(如 cdc MySQL + Mongo 补充)时只取一次。
func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimodule.LegacyAppDescriptor {
out := []databimodule.LegacyAppDescriptor{}
seen := map[string]struct{}{}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
continue
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
if appCode == "" {
continue
}
if _, ok := seen[appCode]; ok {
continue
}
seen[appCode] = struct{}{}
out = append(out, databimodule.LegacyAppDescriptor{AppCode: appCode, AppName: strings.TrimSpace(sourceConfig.AppName)})
}
return out
}
func databiLegacyRegionCatalogs(sources []paymentmodule.MoneyRegionSource) []databimodule.LegacyRegionCatalogSource {
out := []databimodule.LegacyRegionCatalogSource{}
for _, source := range sources {
if catalog, ok := source.(databimodule.LegacyRegionCatalogSource); ok && catalog != nil {
out = append(out, catalog)
}
}
return out
}
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
for _, source := range sources {

View File

@ -404,6 +404,24 @@ func (UserMoneyScope) TableName() string {
return "admin_user_money_scopes"
}
// DatabiKpiTarget 是社交 BI 里运营人员按 App×区域×月份 的充值 KPI 目标;
// 实际达成从统计链路实时聚合,目标只在 admin 库维护,不回写任何业务库。
type DatabiKpiTarget struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:uk_admin_databi_kpi_target,unique;not null" json:"userId"`
AppCode string `gorm:"size:32;index:uk_admin_databi_kpi_target,unique;not null" json:"appCode"`
RegionID int64 `gorm:"index:uk_admin_databi_kpi_target,unique;not null;default:0" json:"regionId"`
PeriodMonth string `gorm:"size:7;column:period_month;index:uk_admin_databi_kpi_target,unique;index:idx_admin_databi_kpi_targets_period;not null" json:"periodMonth"`
TargetUSDMinor int64 `gorm:"column:target_usd_minor;not null;default:0" json:"targetUsdMinor"`
DailyTargetUSDMinor int64 `gorm:"column:daily_target_usd_minor;not null;default:0" json:"dailyTargetUsdMinor"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (DatabiKpiTarget) TableName() string {
return "admin_databi_kpi_targets"
}
type TemporaryPaymentLinkOwner struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`

View File

@ -96,7 +96,11 @@ type externalDashboardMetric struct {
D7RetentionRate float64
D30RetentionRate float64
CoinTotal *int64
RefreshedAt sql.NullTime
// 以下指针字段只有 likei Mongo 源能给出事实cdc MySQL 源保持 nil前端展示 "--"。
CoinSellerTransferCoin *int64
ConsumedCoin *int64
OutputCoin *int64
RefreshedAt sql.NullTime
}
type externalMetricScanner interface {
@ -480,10 +484,10 @@ func (m externalDashboardMetric) toMap() map[string]any {
"arppu_usd_minor": m.ARPPUUSDMinor,
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
"coin_seller_stock_coin": nil,
"coin_seller_transfer_coin": nil,
"coin_seller_transfer_coin": nullableInt64(m.CoinSellerTransferCoin),
"coin_total": nullableInt64(m.CoinTotal),
"consumed_coin": nil,
"consume_output_ratio": nil,
"consumed_coin": nullableInt64(m.ConsumedCoin),
"consume_output_ratio": consumeOutputRatioValue(m.ConsumedCoin, m.OutputCoin),
"daily_active_user": m.ActiveUsers,
"d1_retention_base_users": m.D1RetentionBaseUsers,
"d1_retention_rate": m.D1RetentionRate,
@ -500,6 +504,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
"game_profit_rate": m.GameProfitRate,
"game_turnover": m.GameTurnover,
"gift_coin_spent": m.GiftCoinSpent,
"consume_output_delta": consumeOutputDeltaValue(m.ConsumedCoin, m.OutputCoin),
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
"lucky_gift_payers": m.LuckyGiftPayers,
@ -515,7 +520,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
"new_users": m.NewUsers,
"output_coin": nil,
"output_coin": nullableInt64(m.OutputCoin),
"paid_users": m.PaidUsers,
"paid_conversion_rate": m.PaidConversionRate,
"payer_rate": m.PayerRate,
@ -785,6 +790,20 @@ func nullableInt64(value *int64) any {
return *value
}
func consumeOutputRatioValue(consumed *int64, output *int64) any {
if consumed == nil || output == nil {
return nil
}
return ratioFloat64(*consumed, *output)
}
func consumeOutputDeltaValue(consumed *int64, output *int64) any {
if consumed == nil || output == nil {
return nil
}
return *consumed - *output
}
func firstExternalDashboardValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {

View File

@ -19,6 +19,11 @@ func New(store *repository.Store, cfg config.Config, user userclient.Client, opt
return &Handler{service: NewService(store, cfg, user, options...)}
}
// NewWithService 复用已构建的 DashboardService社交 BI 模块与大屏共享同一份外部源路由。
func NewWithService(service *DashboardService) *Handler {
return &Handler{service: service}
}
func (h *Handler) DashboardOverview(c *gin.Context) {
overview, err := h.service.Overview()
if err != nil {

View File

@ -0,0 +1,217 @@
package databi
import (
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
const (
kpiManagePermission = "databi-kpi:manage"
kpiViewAllPermission = "databi-kpi:view-all"
)
type Handler struct {
service *Service
store *repository.Store
audit shared.OperationLogger
}
func New(service *Service, store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{service: service, store: store, audit: audit}
}
func (h *Handler) Master(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, h.canViewAllKpi(c, access))
if err != nil {
response.ServerError(c, "获取 BI 主数据失败")
return
}
master.Permissions = map[string]bool{
"kpi_manage": middleware.HasPermission(c, kpiManagePermission),
"kpi_view_all": h.canViewAllKpi(c, access),
}
response.OK(c, master)
}
func (h *Handler) Overview(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Overview(c.Request.Context(), access, OverviewQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
})
if err != nil {
response.ServerError(c, "获取 BI 统计数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) Kpi(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Kpi(c.Request.Context(), access, KpiQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
PeriodMonth: c.Query("period_month"),
AppCodes: splitCSV(c.Query("app_codes")),
OperatorUserID: queryUint(c, "operator_user_id"),
ActorUserID: shared.ActorFromContext(c).UserID,
ViewAll: h.canViewAllKpi(c, access),
})
if err != nil {
if strings.Contains(err.Error(), "period_month") {
response.BadRequest(c, "KPI 月份格式不正确")
return
}
response.ServerError(c, "获取 KPI 数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) ListKpiTargets(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
userIDs := []uint{}
if userID := queryUint(c, "user_id"); userID > 0 {
userIDs = append(userIDs, userID)
}
if !middleware.HasPermission(c, kpiManagePermission) && !h.canViewAllKpi(c, access) {
// 没有管理/全员查看权限时只能查询自己的目标,保证"我的 KPI"可用又不泄露他人目标。
userIDs = []uint{shared.ActorFromContext(c).UserID}
}
months := splitCSV(c.Query("period_month"))
targets, err := h.store.ListDatabiKpiTargets(months, userIDs)
if err != nil {
response.ServerError(c, "获取 KPI 目标失败")
return
}
response.OK(c, gin.H{"items": targets, "total": len(targets)})
}
type kpiTargetItemRequest struct {
UserID uint `json:"userId"`
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
PeriodMonth string `json:"periodMonth"`
TargetUSDMinor int64 `json:"targetUsdMinor"`
DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"`
}
type kpiTargetReplaceRequest struct {
Items []kpiTargetItemRequest `json:"items"`
}
func (h *Handler) ReplaceKpiTargets(c *gin.Context) {
var request kpiTargetReplaceRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "KPI 目标参数不正确")
return
}
if len(request.Items) == 0 {
response.BadRequest(c, "KPI 目标不能为空")
return
}
targets := make([]model.DatabiKpiTarget, 0, len(request.Items))
for _, item := range request.Items {
targets = append(targets, model.DatabiKpiTarget{
UserID: item.UserID,
AppCode: item.AppCode,
RegionID: item.RegionID,
PeriodMonth: item.PeriodMonth,
TargetUSDMinor: item.TargetUSDMinor,
DailyTargetUSDMinor: item.DailyTargetUSDMinor,
})
}
if err := h.store.UpsertDatabiKpiTargets(targets); err != nil {
response.BadRequest(c, "保存 KPI 目标失败: "+err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "replace-databi-kpi-targets", "admin_databi_kpi_targets", "", "success", fmt.Sprintf("items=%d", len(request.Items)))
months := map[string]struct{}{}
userIDs := map[uint]struct{}{}
for _, item := range request.Items {
months[strings.TrimSpace(item.PeriodMonth)] = struct{}{}
userIDs[item.UserID] = struct{}{}
}
monthList := make([]string, 0, len(months))
for month := range months {
monthList = append(monthList, month)
}
userList := make([]uint, 0, len(userIDs))
for userID := range userIDs {
userList = append(userList, userID)
}
targetsAfter, err := h.store.ListDatabiKpiTargets(monthList, userList)
if err != nil {
response.ServerError(c, "获取 KPI 目标失败")
return
}
response.OK(c, gin.H{"items": targetsAfter, "total": len(targetsAfter)})
}
// canViewAllKpi显式授权 databi-kpi:view-all 或拥有全量数据范围的管理员可以看全员绩效。
func (h *Handler) canViewAllKpi(c *gin.Context, access repository.MoneyAccess) bool {
if middleware.HasPermission(c, kpiViewAllPermission) || middleware.HasPermission(c, kpiManagePermission) {
return true
}
return access.All
}
func (h *Handler) moneyAccess(c *gin.Context) (repository.MoneyAccess, bool) {
if h.store == nil {
return repository.MoneyAccess{All: true}, true
}
access, err := h.store.MoneyAccessForUser(shared.ActorFromContext(c).UserID)
if err != nil {
response.ServerError(c, "获取数据范围失败")
return repository.MoneyAccess{}, false
}
return access, true
}
func queryInt64(c *gin.Context, key string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(c.Query(key)), 10, 64)
return parsed
}
func queryUint(c *gin.Context, key string) uint {
parsed, _ := strconv.ParseUint(strings.TrimSpace(c.Query(key)), 10, 64)
return uint(parsed)
}
func splitCSV(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}

View File

@ -0,0 +1,270 @@
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
}

View File

@ -0,0 +1,112 @@
package databi
import (
"encoding/json"
"testing"
)
func TestRollupByRegionGroupsCountriesAndRecomputesDerived(t *testing.T) {
regions := []RegionInfo{
{AppCode: "aslan", RegionID: 11, RegionCode: "MENA", RegionName: "中东", Countries: []string{"SA", "AE"}},
{AppCode: "aslan", RegionID: 12, RegionCode: "SEA", RegionName: "东南亚", Countries: []string{"ID"}},
}
rows := []map[string]any{
{"country_code": "SA", "recharge_usd_minor": int64(10000), "active_users": int64(100), "paid_users": int64(10), "recharge_users": int64(10), "gift_coin_spent": nil},
{"country_code": "AE", "recharge_usd_minor": json.Number("5000"), "active_users": json.Number("50"), "paid_users": json.Number("5"), "recharge_users": json.Number("5"), "gift_coin_spent": nil},
{"country_code": "ID", "recharge_usd_minor": int64(2000), "active_users": int64(40), "paid_users": int64(2), "recharge_users": int64(2), "gift_coin_spent": int64(700)},
{"country_code": "XX", "recharge_usd_minor": int64(1), "active_users": int64(1), "paid_users": int64(0), "recharge_users": int64(0)},
}
out := rollupByRegion(rows, regionResolver(regions))
if len(out) != 3 {
t.Fatalf("expected 3 region rows (MENA/SEA/未划分), got %d", len(out))
}
mena := out[0]
if got, _ := rowInt64(mena, "region_id"); got != 11 {
t.Fatalf("expected first row to be region 11 by recharge desc, got %d", got)
}
if got, _ := rowInt64(mena, "recharge_usd_minor"); got != 15000 {
t.Fatalf("expected MENA recharge 15000, got %d", got)
}
if got, _ := rowInt64(mena, "active_users"); got != 150 {
t.Fatalf("expected MENA active users 150, got %d", got)
}
if got, ok := mena["arppu_usd_minor"].(int64); !ok || got != 1000 {
t.Fatalf("expected MENA arppu 1000, got %v", mena["arppu_usd_minor"])
}
if mena["gift_coin_spent"] != nil {
t.Fatalf("expected MENA gift_coin_spent nil when all rows nil, got %v", mena["gift_coin_spent"])
}
sea := out[1]
if got, _ := rowInt64(sea, "gift_coin_spent"); got != 700 {
t.Fatalf("expected SEA gift_coin_spent 700, got %d", got)
}
unassigned := out[2]
if got, _ := rowInt64(unassigned, "region_id"); got != 0 {
t.Fatalf("expected unassigned region id 0, got %d", got)
}
}
func TestRollupByRegionPrefersRowRegionID(t *testing.T) {
regions := []RegionInfo{{AppCode: "lalu", RegionID: 3, RegionCode: "LATAM", RegionName: "拉美", Countries: []string{"BR"}}}
rows := []map[string]any{
{"region_id": json.Number("3"), "country_id": int64(55), "recharge_usd_minor": int64(100)},
{"region_id": int64(3), "country_id": int64(56), "recharge_usd_minor": int64(200)},
}
out := rollupByRegion(rows, regionResolver(regions))
if len(out) != 1 {
t.Fatalf("expected 1 region row, got %d", len(out))
}
if got := out[0]["region_name"]; got != "拉美" {
t.Fatalf("expected region name 拉美, got %v", got)
}
if got, _ := rowInt64(out[0], "recharge_usd_minor"); got != 300 {
t.Fatalf("expected recharge 300, got %d", got)
}
}
func TestFilterRowsByRegion(t *testing.T) {
regions := []RegionInfo{
{AppCode: "yumi", RegionID: 21, Countries: []string{"SA"}},
{AppCode: "yumi", RegionID: 22, Countries: []string{"EG"}},
}
rows := []map[string]any{
{"country_code": "SA"},
{"country_code": "EG"},
{"country_code": "??"},
}
out := filterRowsByRegion(rows, regionResolver(regions), int64Set([]int64{21}))
if len(out) != 1 || out[0]["country_code"] != "SA" {
t.Fatalf("expected only SA row, got %v", out)
}
}
func TestAllowedRegions(t *testing.T) {
access := testAccess()
if all, _ := allowedRegions(access, "lalu"); !all {
t.Fatalf("expected whole-app access for lalu (region 0 scope)")
}
all, ids := allowedRegions(access, "aslan")
if all || len(ids) != 2 {
t.Fatalf("expected two region scopes for aslan, got all=%v ids=%v", all, ids)
}
if all, ids := allowedRegions(access, "yumi"); all || len(ids) != 0 {
t.Fatalf("expected no access for yumi, got all=%v ids=%v", all, ids)
}
}
func TestResolveKpiMonthClampsFuture(t *testing.T) {
location := mustLocation(t, "Asia/Shanghai")
month, start, end, err := resolveKpiMonth("2020-02", 0, location)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if month != "2020-02" {
t.Fatalf("expected month 2020-02, got %s", month)
}
if start.Format("2006-01-02") != "2020-02-01" || end.Format("2006-01-02") != "2020-03-01" {
t.Fatalf("unexpected month window %s ~ %s", start, end)
}
if _, _, _, err := resolveKpiMonth("2020/02", 0, location); err == nil {
t.Fatalf("expected format error for 2020/02")
}
}

View File

@ -0,0 +1,15 @@
package databi
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/databi/social/master", middleware.RequirePermission("overview:view"), h.Master)
protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview)
protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi)
protected.GET("/admin/databi/social/kpi-targets", middleware.RequirePermission("overview:view"), h.ListKpiTargets)
protected.PUT("/admin/databi/social/kpi-targets", middleware.RequirePermission(kpiManagePermission), h.ReplaceKpiTargets)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
package databi
import (
"testing"
"time"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
func testAccess() repository.MoneyAccess {
return repository.MoneyAccess{
UserID: 7,
Scopes: []model.UserMoneyScope{
{UserID: 7, AppCode: "lalu", RegionID: 0},
{UserID: 7, AppCode: "aslan", RegionID: 11},
{UserID: 7, AppCode: "aslan", RegionID: 12},
},
}
}
func mustLocation(t *testing.T, name string) *time.Location {
t.Helper()
location, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("load location %s: %v", name, err)
}
return location
}
func TestRegionDisplay(t *testing.T) {
regions := []RegionInfo{{AppCode: "aslan", RegionID: 11, RegionCode: "MENA", RegionName: "中东"}}
if _, name := regionDisplay(regions, 0); name != "全部区域" {
t.Fatalf("expected 全部区域 for region 0, got %s", name)
}
if code, name := regionDisplay(regions, 11); code != "MENA" || name != "中东" {
t.Fatalf("unexpected display for region 11: %s %s", code, name)
}
if _, name := regionDisplay(regions, 99); name != "区域 99" {
t.Fatalf("expected fallback name for unknown region, got %s", name)
}
}
func TestTopLevelMetricsStripsStructuralKeys(t *testing.T) {
out := topLevelMetrics(map[string]any{
"recharge_usd_minor": int64(5),
"daily_series": []any{"x"},
"country_breakdown": []any{"y"},
"daily_country_breakdown": []any{"z"},
"report_metric_sources": []any{},
})
if _, ok := out["daily_series"]; ok {
t.Fatalf("expected daily_series stripped")
}
if out["recharge_usd_minor"] != int64(5) {
t.Fatalf("expected metric kept, got %v", out["recharge_usd_minor"])
}
}

View File

@ -18,7 +18,6 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义legacy 源不适用。
@ -144,6 +143,8 @@ type legacyPurchaseDocument struct {
Products []legacyPurchaseProduct `bson:"products"`
CreateTime time.Time `bson:"createTime"`
PurchaseDateMS time.Time `bson:"purchaseDateMs"`
// LegacyBillCountry 由聚合阶段legacyCountryResolveStages解析账单国家码优先回退付款用户资料。
LegacyBillCountry string `bson:"legacyBillCountry"`
}
type legacyPurchaseFactory struct {
@ -186,10 +187,14 @@ func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter
if err != nil {
return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
}
cursor, err := s.collection.Find(ctx, filter, options.Find().
SetSort(bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}).
SetSkip(int64(page-1)*int64(pageSize)).
SetLimit(int64(pageSize)))
// 国家解析放在分页之后:每页最多 pageSize 次 $lookup用于把账单落到区域列展示。
pipeline := append(mongo.Pipeline{
bson.D{{Key: "$match", Value: filter}},
bson.D{{Key: "$sort", Value: bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}}},
bson.D{{Key: "$skip", Value: int64(page-1) * int64(pageSize)}},
bson.D{{Key: "$limit", Value: int64(pageSize)}},
}, legacyCountryResolveStages()...)
cursor, err := s.collection.Aggregate(ctx, pipeline)
if err != nil {
return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
}
@ -246,13 +251,21 @@ func (s *MongoRechargeBillSource) listBillsByRegionPipeline(ctx context.Context,
}
func (s *MongoRechargeBillSource) decodeBillCursor(ctx context.Context, cursor *mongo.Cursor, pageSize int) ([]rechargeBillDTO, error) {
countryToRegion := s.legacyCountryRegionIndex(ctx)
items := make([]rechargeBillDTO, 0, pageSize)
for cursor.Next(ctx) {
var document legacyPurchaseDocument
if err := cursor.Decode(&document); err != nil {
return nil, fmt.Errorf("decode legacy recharge bill for %s: %w", s.config.AppCode, err)
}
items = append(items, legacyRechargeBillDTO(s.config, document))
item := legacyRechargeBillDTO(s.config, document)
// likei 账单没有区域 ID用解析出的国家码映射区域目录供列表区域列与导出展示。
if country := strings.ToUpper(strings.TrimSpace(document.LegacyBillCountry)); country != "" {
if region, ok := countryToRegion[country]; ok {
item.TargetRegionID = region.RegionID
}
}
items = append(items, item)
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)

View File

@ -160,6 +160,40 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
return nil, false, nil
}
// RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态;
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
type RegionCatalogEntry struct {
AppCode string
AppName string
RegionID int64
RegionCode string
RegionName string
Countries []string
}
func (s *MongoMoneyRegionSource) RegionCatalog(ctx context.Context) ([]RegionCatalogEntry, error) {
if s == nil || s.collection == nil {
return nil, nil
}
_, regions, _, err := s.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return nil, err
}
appName := strings.TrimSpace(s.config.AppName)
entries := make([]RegionCatalogEntry, 0, len(regions))
for _, region := range regions {
entries = append(entries, RegionCatalogEntry{
AppCode: region.AppCode,
AppName: appName,
RegionID: region.RegionID,
RegionCode: region.RegionCode,
RegionName: region.Name,
Countries: region.Countries,
})
}
return entries, nil
}
func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfig, documents []legacyRegionDocument, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
appCode := appctx.Normalize(sourceConfig.AppCode)
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {

View File

@ -39,10 +39,20 @@ type rechargeBillGooglePaidStatsDTO struct {
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"`
}
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
@ -54,18 +64,21 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
// 财务系统统一按中国时区切日边界。
tzOffsetMinutes = 480
}
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
if source, ok := h.billSources[appCode]; ok {
overview, err := source.Overview(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
return
}
@ -76,8 +89,8 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
StartAtMs: startAtMS,
EndAtMs: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
})
if err != nil {
@ -130,5 +143,21 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
EstNetUsdMinor: stats.GetEstNetUsdMinor(),
}
}
if transfer := resp.GetSalaryTransfer(); transfer != nil {
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
}
// 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
}

View File

@ -75,10 +75,12 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
summary, err := source.SummarizeRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
})
if err != nil {
response.ServerError(c, "获取充值汇总失败")

View File

@ -0,0 +1,132 @@
package repository
import (
"errors"
"regexp"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var databiPeriodMonthPattern = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`)
// ListMoneyScopeOperators 返回所有配置过财务/数据范围的后台用户及其范围;
// 社交 BI 的"运营人员"就是这批人,不再依赖固定 team_id 约定。
func (s *Store) ListMoneyScopeOperators() ([]model.User, map[uint][]model.UserMoneyScope, error) {
var scopes []model.UserMoneyScope
if err := s.db.Order("user_id ASC, app_code ASC, region_id ASC").Find(&scopes).Error; err != nil {
return nil, nil, err
}
scopesByUser := map[uint][]model.UserMoneyScope{}
userIDs := make([]uint, 0, len(scopes))
for _, scope := range scopes {
if _, ok := scopesByUser[scope.UserID]; !ok {
userIDs = append(userIDs, scope.UserID)
}
scopesByUser[scope.UserID] = append(scopesByUser[scope.UserID], scope)
}
if len(userIDs) == 0 {
return []model.User{}, scopesByUser, nil
}
var users []model.User
if err := s.db.Where("id IN ?", userIDs).Order("id ASC").Find(&users).Error; err != nil {
return nil, nil, err
}
return users, scopesByUser, nil
}
func (s *Store) ListDatabiKpiTargets(periodMonths []string, userIDs []uint) ([]model.DatabiKpiTarget, error) {
query := s.db.Model(&model.DatabiKpiTarget{})
months := normalizeDatabiPeriodMonths(periodMonths)
if len(months) > 0 {
query = query.Where("period_month IN ?", months)
}
if len(userIDs) > 0 {
query = query.Where("user_id IN ?", userIDs)
}
var targets []model.DatabiKpiTarget
err := query.Order("user_id ASC, app_code ASC, region_id ASC, period_month ASC").Find(&targets).Error
return targets, err
}
// UpsertDatabiKpiTargets 逐条 upsert 目标;月目标和日目标同时为 0 视为清除该条目标。
func (s *Store) UpsertDatabiKpiTargets(targets []model.DatabiKpiTarget) error {
normalized, removals, err := normalizeDatabiKpiTargets(targets)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
for _, target := range removals {
if err := tx.Where(
"user_id = ? AND app_code = ? AND region_id = ? AND period_month = ?",
target.UserID, target.AppCode, target.RegionID, target.PeriodMonth,
).Delete(&model.DatabiKpiTarget{}).Error; err != nil {
return err
}
}
if len(normalized) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "app_code"}, {Name: "region_id"}, {Name: "period_month"}},
DoUpdates: clause.AssignmentColumns([]string{
"target_usd_minor",
"daily_target_usd_minor",
"updated_at_ms",
}),
}).Create(&normalized).Error
})
}
func normalizeDatabiKpiTargets(targets []model.DatabiKpiTarget) ([]model.DatabiKpiTarget, []model.DatabiKpiTarget, error) {
seen := map[string]struct{}{}
upserts := make([]model.DatabiKpiTarget, 0, len(targets))
removals := make([]model.DatabiKpiTarget, 0)
for _, target := range targets {
target.AppCode = appctx.Normalize(target.AppCode)
target.PeriodMonth = strings.TrimSpace(target.PeriodMonth)
if target.UserID == 0 || target.AppCode == "" || target.RegionID < 0 {
return nil, nil, errors.New("kpi target user, app code and region id are required")
}
if !databiPeriodMonthPattern.MatchString(target.PeriodMonth) {
return nil, nil, errors.New("kpi target period month must be formatted as YYYY-MM")
}
if target.TargetUSDMinor < 0 || target.DailyTargetUSDMinor < 0 {
return nil, nil, errors.New("kpi target amounts must not be negative")
}
key := strconv.FormatUint(uint64(target.UserID), 10) + ":" + target.AppCode + ":" + strconv.FormatInt(target.RegionID, 10) + ":" + target.PeriodMonth
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if target.TargetUSDMinor == 0 && target.DailyTargetUSDMinor == 0 {
removals = append(removals, target)
continue
}
target.ID = 0
upserts = append(upserts, target)
}
return upserts, removals, nil
}
func normalizeDatabiPeriodMonths(periodMonths []string) []string {
out := make([]string, 0, len(periodMonths))
seen := map[string]struct{}{}
for _, month := range periodMonths {
month = strings.TrimSpace(month)
if month == "" || !databiPeriodMonthPattern.MatchString(month) {
continue
}
if _, ok := seen[month]; ok {
continue
}
seen[month] = struct{}{}
out = append(out, month)
}
return out
}

View File

@ -80,6 +80,7 @@ func (s *Store) AutoMigrate() error {
&model.OperationLog{},
&model.DataScope{},
&model.UserMoneyScope{},
&model.DatabiKpiTarget{},
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},
&model.FinanceApplication{},

View File

@ -92,6 +92,32 @@ func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationA
return s.GetWithdrawalApplication(id)
}
// ApprovedWithdrawalStats 按审批通过时间聚合用户提现申请的 USDT 金额withdraw_amount_minor美分口径
type ApprovedWithdrawalStats struct {
ApprovedCount int64
ApprovedUSDMinor int64
}
func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS int64) (ApprovedWithdrawalStats, error) {
query := s.db.Model(&model.UserWithdrawalApplication{}).
Where("status = ?", model.WithdrawalApplicationStatusApproved)
if appCode = strings.TrimSpace(appCode); appCode != "" {
query = query.Where("app_code = ?", appCode)
}
if startAtMS > 0 {
query = query.Where("approved_at_ms >= ?", startAtMS)
}
if endAtMS > 0 {
query = query.Where("approved_at_ms < ?", endAtMS)
}
var stats ApprovedWithdrawalStats
row := query.Select("COUNT(*), COALESCE(SUM(withdraw_amount_minor), 0)").Row()
if err := row.Scan(&stats.ApprovedCount, &stats.ApprovedUSDMinor); err != nil {
return ApprovedWithdrawalStats{}, err
}
return stats, nil
}
func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB {
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
query = query.Where("app_code = ?", appCode)

View File

@ -18,6 +18,7 @@ import (
"hyapp-admin-server/internal/modules/cumulativerechargereward"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/databi"
"hyapp-admin-server/internal/modules/financeapplication"
"hyapp-admin-server/internal/modules/financewithdrawal"
"hyapp-admin-server/internal/modules/firstrechargereward"
@ -78,6 +79,7 @@ type Handlers struct {
CumulativeRecharge *cumulativerechargereward.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
Databi *databi.Handler
FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceWithdrawal *financewithdrawal.Handler
@ -160,6 +162,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
roomrocket.RegisterRoutes(protected, h.RoomRocket)
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
dashboard.RegisterRoutes(protected, h.Dashboard)
databi.RegisterRoutes(protected, h.Databi)
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg)

View File

@ -0,0 +1,34 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_databi_kpi_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID运营人员',
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID0 表示整个 app',
period_month CHAR(7) NOT NULL COMMENT '目标月份,格式 YYYY-MM',
target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当月充值目标USD 分',
daily_target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当日充值目标USD 分0 表示按月目标/自然天数折算',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_databi_kpi_target (user_id, app_code, region_id, period_month),
KEY idx_admin_databi_kpi_targets_period (period_month, app_code, region_id),
CONSTRAINT fk_admin_databi_kpi_targets_user FOREIGN KEY (user_id) REFERENCES admin_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='社交 BI 运营人员充值 KPI 月度目标';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('BI KPI 目标配置', 'databi-kpi:manage', 'button', '允许配置社交 BI 运营人员的充值 KPI 目标', @now_ms, @now_ms),
('BI 全员绩效查看', 'databi-kpi:view-all', 'button', '允许查看所有运营人员的 KPI 绩效;无此权限时只能查看自己', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('databi-kpi:manage', 'databi-kpi:view-all');

View File

@ -99,11 +99,18 @@ type RechargeBillGooglePaidStats struct {
EstNetUSDMinor int64
}
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布与谷歌扣费口径。
// RechargeBillSalaryTransferStats 是用户提现口径中的钱包账本部分:用户把工资 USDT 转给币商。
type RechargeBillSalaryTransferStats struct {
TransferCount int64
TransferUSDMinor int64
}
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布、谷歌扣费与提现口径。
type RechargeBillOverview struct {
Daily []RechargeBillDailyBucket
Regions []RechargeBillRegionBucket
GooglePaid RechargeBillGooglePaidStats
Daily []RechargeBillDailyBucket
Regions []RechargeBillRegionBucket
GooglePaid RechargeBillGooglePaidStats
SalaryTransfer RechargeBillSalaryTransferStats
}
// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。

View File

@ -50,6 +50,12 @@ func (r *Repository) GetRechargeBillOverview(ctx context.Context, query ledger.L
}
overview.GooglePaid = stats
}
// 用户提现(转币商)与充值渠道筛选无关,始终按 App/时间/区域口径统计。
salaryTransfer, err := r.overviewSalaryTransferStats(ctx, query)
if err != nil {
return ledger.RechargeBillOverview{}, err
}
overview.SalaryTransfer = salaryTransfer
dayIndexes := make([]int64, 0, len(dailyBuckets))
for dayIndex := range dailyBuckets {
@ -202,6 +208,40 @@ func scanOverviewRegionRows(rows overviewRegionRows, buckets map[int64]*ledger.R
return rows.Err()
}
// overviewSalaryTransferStats 汇总用户工资转币商的 USDT金额在交易 metadata 的 salary_usd_minor
// 该业务量级小人工触发JSON 提取聚合不构成性能压力。
func (r *Repository) overviewSalaryTransferStats(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSalaryTransferStats, error) {
where := `WHERE app_code = ? AND biz_type = ?`
args := []any{query.AppCode, bizTypeSalaryTransferToCoinSeller}
if query.Status != "" {
where += ` AND status = ?`
args = append(args, query.Status)
}
if query.RegionID > 0 {
where += ` AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.region_id')) AS SIGNED), 0) = ?`
args = append(args, query.RegionID)
}
if query.StartAtMS > 0 {
where += ` AND created_at_ms >= ?`
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += ` AND created_at_ms < ?`
args = append(args, query.EndAtMS)
}
var stats ledger.RechargeBillSalaryTransferStats
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.salary_usd_minor')) AS SIGNED), 0)), 0)
FROM wallet_transactions
`+where,
args...,
).Scan(&stats.TransferCount, &stats.TransferUSDMinor)
if err != nil {
return ledger.RechargeBillSalaryTransferStats{}, err
}
return stats, nil
}
// overviewGooglePaidStats 统计谷歌实付同步覆盖度,并把已同步账单的税/费/净收按实付占比折算到 USD 口径。
// 估算只覆盖“已同步且净收入有效”的账单;负差额按 0 处理,避免退款单把手续费冲成负数。
func (r *Repository) overviewGooglePaidStats(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillGooglePaidStats, error) {

View File

@ -88,6 +88,10 @@ func (s *Server) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetR
EstTaxUsdMinor: overview.GooglePaid.EstTaxUSDMinor,
EstNetUsdMinor: overview.GooglePaid.EstNetUSDMinor,
},
SalaryTransfer: &walletv1.RechargeBillSalaryTransferStats{
TransferCount: overview.SalaryTransfer.TransferCount,
TransferUsdMinor: overview.SalaryTransfer.TransferUSDMinor,
},
}
for _, bucket := range overview.Daily {
resp.Daily = append(resp.Daily, &walletv1.RechargeBillDailyBucket{