446 lines
12 KiB
Go
446 lines
12 KiB
Go
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 位以上业务 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
|
||
}
|
||
}
|