package dashboard import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" ) type DashboardService struct { store *repository.Store cfg config.Config httpClient *http.Client user userclient.Client externalSources map[string]ExternalDashboardSource } type StatisticsQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 SeriesStartMS int64 SeriesEndMS int64 RegionID int64 CountryID int64 } type SelfGameStatisticsQuery struct { AppCode string StatTZ string RequestID string StartMS int64 EndMS int64 RegionID int64 CountryID int64 GameID string } type AppTrackingFunnelQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 RegionID int64 RegionIDs []int64 CountryID int64 } type SocialRequirementsQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 RegionID int64 RegionIDs []int64 CountryID int64 Section string UserRole string PayerType string } type PlatformGrantStatisticsQuery struct { AppCode string StatTZ string RequestID string StartMS int64 EndMS int64 StatDay string RegionID int64 CountryID int64 UserID int64 Source string Page int PageSize int } type ExternalDashboardSource interface { AppCode() string StatisticsOverview(context.Context, StatisticsQuery) (map[string]any, error) } type ExternalDashboardRegionResolver interface { CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error) } type Option func(*DashboardService) func WithExternalDashboardSources(sources ...ExternalDashboardSource) Option { return func(s *DashboardService) { for _, source := range sources { if source == nil { continue } appCode := strings.ToLower(strings.TrimSpace(source.AppCode())) if appCode == "" { continue } s.externalSources[appCode] = source } } } func NewService(store *repository.Store, cfg config.Config, user userclient.Client, options ...Option) *DashboardService { service := &DashboardService{ store: store, cfg: cfg, httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout}, user: user, externalSources: map[string]ExternalDashboardSource{}, } for _, option := range options { if option != nil { option(service) } } return service } func (s *DashboardService) Overview() (*repository.DashboardOverview, error) { return s.store.DashboardOverview() } func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) { if source := s.externalSources[strings.ToLower(strings.TrimSpace(query.AppCode))]; source != nil { // Yumi 这类外接 App 不属于 hyapp statistics-service 的事实域;命中外部源时直接读 dashboard-cdc-worker 的预聚合库。 return source.StatisticsOverview(ctx, query) } values := url.Values{} values.Set("app_code", query.AppCode) if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { values.Set("stat_tz", statTZ) } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } if query.EndMS > 0 { values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) } if query.SeriesStartMS > 0 { values.Set("series_start_ms", strconv.FormatInt(query.SeriesStartMS, 10)) } if query.SeriesEndMS > 0 { values.Set("series_end_ms", strconv.FormatInt(query.SeriesEndMS, 10)) } if query.RegionID > 0 { values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) } if query.CountryID > 0 { values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) } return s.statisticsGET(ctx, "/internal/v1/statistics/overview", values) } func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query SelfGameStatisticsQuery) (map[string]any, error) { values := url.Values{} values.Set("app_code", query.AppCode) if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { values.Set("stat_tz", statTZ) } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } if query.EndMS > 0 { values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) } if query.RegionID > 0 { values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) } if query.CountryID > 0 { values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) } if gameID := strings.TrimSpace(query.GameID); gameID != "" && gameID != "all" { values.Set("game_id", gameID) } // 数据大屏只通过 statistics-service 读取自研游戏聚合指标,避免 admin 服务绕过聚合层去扫业务明细表。 overview, err := s.statisticsGET(ctx, "/internal/v1/statistics/self-games/overview", values) if err != nil { return nil, err } // 高风险用户只对 statistics-service 已返回的 topN user_id 做批量资料补全;头像、昵称、短 ID 不进入统计库,避免聚合链路反查用户表。 _ = s.enrichSelfGameRiskUsers(ctx, query, overview) return overview, nil } func (s *DashboardService) AppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (map[string]any, error) { values := url.Values{} values.Set("app_code", query.AppCode) if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { values.Set("stat_tz", statTZ) } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } if query.EndMS > 0 { values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) } if query.RegionID > 0 { values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) } else if len(query.RegionIDs) > 0 { parts := make([]string, 0, len(query.RegionIDs)) for _, regionID := range query.RegionIDs { if regionID > 0 { parts = append(parts, strconv.FormatInt(regionID, 10)) } } if len(parts) > 0 { values.Set("region_ids", strings.Join(parts, ",")) } } if query.CountryID > 0 { values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) } return s.statisticsGET(ctx, "/internal/v1/statistics/app/funnel", values) } func (s *DashboardService) SocialRequirements(ctx context.Context, query SocialRequirementsQuery) (map[string]any, error) { values := url.Values{} values.Set("app_code", query.AppCode) if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { values.Set("stat_tz", statTZ) } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } if query.EndMS > 0 { values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) } if query.RegionID > 0 { values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) } else if len(query.RegionIDs) > 0 { parts := make([]string, 0, len(query.RegionIDs)) for _, regionID := range query.RegionIDs { if regionID > 0 { parts = append(parts, strconv.FormatInt(regionID, 10)) } } if len(parts) > 0 { values.Set("region_ids", strings.Join(parts, ",")) } } if query.CountryID > 0 { values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) } if section := strings.TrimSpace(query.Section); section != "" { values.Set("section", section) } if userRole := strings.TrimSpace(query.UserRole); userRole != "" { values.Set("user_role", userRole) } if payerType := strings.TrimSpace(query.PayerType); payerType != "" { values.Set("payer_type", payerType) } // Social BI 数据需求只读 statistics-service 聚合投影;admin 不直接拼 owner service 明细或跨库查询。 return s.statisticsGET(ctx, "/internal/v1/statistics/social/requirements", values) } func (s *DashboardService) RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error { if s == nil { return nil } // statistics-service 只接收已校验订单快照;admin 是财务工单事实 owner,不能让统计服务回查 admin 表。 payload := map[string]any{ "app_code": order.AppCode, "event_id": fmt.Sprintf("finance_coin_seller_recharge_order:%d", order.ID), "order_id": order.ID, "verify_status": order.VerifyStatus, "target_user_id": order.TargetUserID, "target_country_id": order.TargetCountryID, "target_region_id": order.TargetRegionID, "usd_minor": order.USDMinorAmount, "coin_amount": order.CoinAmount, "created_at_ms": order.CreatedAtMS, } return s.statisticsPOST(ctx, "/internal/v1/statistics/social/coin-seller-recharge-orders", payload) } func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) { page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false)) if err != nil { return nil, err } // 统计服务只返回用户 ID 和金币聚合;admin 层用 user-service 批量补展示资料,避免统计库复制用户主数据。 _ = s.enrichPlatformGrantUsers(ctx, query, page) return page, nil } func (s *DashboardService) PlatformGrantRecords(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) { if query.UserID <= 0 { return nil, fmt.Errorf("user_id is required") } page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/records", platformGrantValues(query, true)) if err != nil { return nil, err } stringifyPageUserIDs(page) return page, nil } func (s *DashboardService) statisticsGET(ctx context.Context, path string, values url.Values) (map[string]any, error) { baseURL := s.cfg.StatisticsService.BaseURL + path req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+values.Encode(), nil) if err != nil { return nil, err } resp, err := s.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("statistics service returned status %d", resp.StatusCode) } var out map[string]any decoder := json.NewDecoder(resp.Body) decoder.UseNumber() if err := decoder.Decode(&out); err != nil { return nil, err } return out, nil } func (s *DashboardService) statisticsPOST(ctx context.Context, path string, payload any) error { body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.StatisticsService.BaseURL+path, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var out map[string]any _ = json.NewDecoder(resp.Body).Decode(&out) return fmt.Errorf("statistics service returned status %d: %v", resp.StatusCode, out["error"]) } return nil } func platformGrantValues(query PlatformGrantStatisticsQuery, includeUser bool) url.Values { values := url.Values{} values.Set("app_code", query.AppCode) if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { values.Set("stat_tz", statTZ) } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } if query.EndMS > 0 { values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) } if statDay := strings.TrimSpace(query.StatDay); statDay != "" { values.Set("stat_day", statDay) } if query.RegionID > 0 { values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) } if query.CountryID > 0 { values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) } if includeUser && query.UserID > 0 { values.Set("user_id", strconv.FormatInt(query.UserID, 10)) } if source := strings.TrimSpace(query.Source); source != "" && source != "all" { values.Set("source", source) } if query.Page > 0 { values.Set("page", strconv.Itoa(query.Page)) } if query.PageSize > 0 { values.Set("page_size", strconv.Itoa(query.PageSize)) } return values } func (s *DashboardService) enrichPlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery, page map[string]any) error { if s.user == nil || page == nil { return nil } rows, ok := page["items"].([]any) if !ok || len(rows) == 0 { return nil } userIDs := make([]int64, 0, len(rows)) seen := map[int64]struct{}{} for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } userID := anyInt64(row["user_id"]) if userID <= 0 { continue } // user_id 可能超过 JS 安全整数,admin 统一转字符串输出,前端分页和后续来源查询都使用字符串透传。 row["user_id"] = strconv.FormatInt(userID, 10) if _, exists := seen[userID]; exists { continue } seen[userID] = struct{}{} userIDs = append(userIDs, userID) } if len(userIDs) == 0 { return nil } users, err := s.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{ RequestID: query.RequestID, Caller: "admin-server", UserIDs: userIDs, }) if err != nil { return err } for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } userID := anyInt64(row["user_id"]) user := users[userID] if user == nil { continue } userPayload := map[string]any{ "user_id": strconv.FormatInt(userID, 10), "avatar": user.Avatar, "username": user.Username, "display_user_id": user.DisplayUserID, } row["user"] = userPayload row["avatar"] = user.Avatar row["nickname"] = user.Username row["username"] = user.Username row["display_user_id"] = user.DisplayUserID row["short_id"] = user.DisplayUserID } return nil } func stringifyPageUserIDs(page map[string]any) { rows, ok := page["items"].([]any) if !ok { return } for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } if userID := anyInt64(row["user_id"]); userID > 0 { row["user_id"] = strconv.FormatInt(userID, 10) } } } func (s *DashboardService) enrichSelfGameRiskUsers(ctx context.Context, query SelfGameStatisticsQuery, overview map[string]any) error { if s.user == nil || overview == nil { return nil } rows, ok := overview["user_risk"].([]any) if !ok || len(rows) == 0 { return nil } userIDs := make([]int64, 0, len(rows)) seen := map[int64]struct{}{} for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } userID := anyInt64(row["user_id"]) if userID <= 0 { continue } // user_id 是 16 位以上业务 ID,admin 层统一改成字符串透给 H5,避免浏览器 JSON number 精度丢失。 row["user_id"] = strconv.FormatInt(userID, 10) if _, exists := seen[userID]; exists { continue } seen[userID] = struct{}{} userIDs = append(userIDs, userID) } if len(userIDs) == 0 { return nil } users, err := s.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{ RequestID: query.RequestID, Caller: "admin-server", UserIDs: userIDs, }) if err != nil { return err } for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } user := users[anyInt64(row["user_id"])] if user == nil { continue } row["avatar"] = user.Avatar row["nickname"] = user.Username row["username"] = user.Username row["display_user_id"] = user.DisplayUserID row["short_id"] = user.DisplayUserID } return nil } func anyInt64(value any) int64 { switch typed := value.(type) { case int64: return typed case int: return int64(typed) case float64: return int64(typed) case json.Number: parsed, _ := typed.Int64() return parsed case string: parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) return parsed default: return 0 } }