2026-07-05 00:08:30 +08:00

446 lines
12 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 dashboard
import (
"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/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 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) 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 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 位以上业务 IDadmin 层统一改成字符串透给 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
}
}