diff --git a/backend/gateway/config/config.go b/backend/gateway/config/config.go index 0c16287..2a6fc86 100644 --- a/backend/gateway/config/config.go +++ b/backend/gateway/config/config.go @@ -66,14 +66,15 @@ type ServerConfig struct { // DubboConfig Dubbo 服务配置 type DubboConfig struct { - UserServiceURL string - SocialServiceURL string - AssetServiceURL string - GalleryServiceURL string - ActivityServiceURL string - TaskServiceURL string - StarbookServiceURL string - AIChatServiceURL string + UserServiceURL string + SocialServiceURL string + AssetServiceURL string + GalleryServiceURL string + ActivityServiceURL string + TaskServiceURL string + StarbookServiceURL string + AIChatServiceURL string + StatisticServiceURL string } // JWTConfig JWT 配置 @@ -134,6 +135,7 @@ func Load() *Config { TaskServiceURL: getEnv("DUBBO_TASK_SERVICE_URL", "tri://127.0.0.1:20006"), StarbookServiceURL: getEnv("DUBBO_STARBOOK_SERVICE_URL", "tri://127.0.0.1:20007"), AIChatServiceURL: getEnv("DUBBO_AI_CHAT_SERVICE_URL", "tri://127.0.0.1:20008"), + StatisticServiceURL: getEnv("DUBBO_STATISTIC_SERVICE_URL", "tri://127.0.0.1:20009"), }, JWT: JWTConfig{ Secret: getEnv("JWT_SECRET", ""), diff --git a/backend/gateway/controller/statistic_controller.go b/backend/gateway/controller/statistic_controller.go new file mode 100644 index 0000000..d3d4360 --- /dev/null +++ b/backend/gateway/controller/statistic_controller.go @@ -0,0 +1,171 @@ +package controller + +import ( + "context" + "net/http" + "strconv" + "time" + + "dubbo.apache.org/dubbo-go/v3/client" + "dubbo.apache.org/dubbo-go/v3/common/constant" + "github.com/gin-gonic/gin" + "github.com/topfans/backend/gateway/pkg/response" + "github.com/topfans/backend/pkg/logger" + pb "github.com/topfans/backend/pkg/proto/statistic" + "go.uber.org/zap" +) + +// StatisticController 数据看板 HTTP controller(gateway → statisticService 7 RPC) +type StatisticController struct { + statisticService pb.StatisticService +} + +// NewStatisticController 构造 +func NewStatisticController(dubboClient *client.Client) (*StatisticController, error) { + svc, err := pb.NewStatisticService(dubboClient) + if err != nil { + return nil, err + } + return &StatisticController{statisticService: svc}, nil +} + +// parseStarID 解析 query 中的 star_id +func parseStarID(c *gin.Context) (int64, bool) { + v := c.Query("star_id") + if v == "" { + response.Error(c, http.StatusBadRequest, "star_id 是必填参数") + return 0, false + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "star_id 参数错误") + return 0, false + } + return n, true +} + +// buildCtx 构造带 user_id 透传的 ctx(Dubbo attachment) +func buildCtx(c *gin.Context) context.Context { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + _ = cancel + userID, _ := c.Get("user_id") + if userID != nil { + ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ + "user_id": strconv.FormatInt(toInt64(userID), 10), + }) + } + return ctx +} + +func toInt64(v interface{}) int64 { + switch x := v.(type) { + case int64: + return x + case int: + return int64(x) + case string: + n, _ := strconv.ParseInt(x, 10, 64) + return n + } + return 0 +} + +// ===== 1. 今日概览 ===== +func (ctrl *StatisticController) GetTodayOverview(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + logger.Logger.Info("GetTodayOverview", zap.Int64("star_id", starID)) + resp, err := ctrl.statisticService.GetTodayOverview(buildCtx(c), &pb.GetTodayOverviewRequest{StarId: starID}) + if err != nil { + logger.Logger.Error("GetTodayOverview RPC failed", zap.Error(err)) + response.Error(c, http.StatusInternalServerError, "获取今日概览失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} + +// ===== 2. 七日收益曲线 ===== +func (ctrl *StatisticController) Get7DayIncomeCurve(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + resp, err := ctrl.statisticService.Get7DayIncomeCurve(buildCtx(c), &pb.Get7DayIncomeCurveRequest{StarId: starID}) + if err != nil { + response.Error(c, http.StatusInternalServerError, "获取七日收益曲线失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} + +// ===== 3. 展出收益中心 ===== +func (ctrl *StatisticController) GetExhibitionIncomeSummary(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + resp, err := ctrl.statisticService.GetExhibitionIncomeSummary(buildCtx(c), &pb.GetExhibitionIncomeSummaryRequest{StarId: starID}) + if err != nil { + response.Error(c, http.StatusInternalServerError, "获取展出收益中心失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} + +// ===== 4. 点赞收益按等级 ===== +func (ctrl *StatisticController) GetLikeIncomeByLevel(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + resp, err := ctrl.statisticService.GetLikeIncomeByLevel(buildCtx(c), &pb.GetLikeIncomeByLevelRequest{StarId: starID}) + if err != nil { + response.Error(c, http.StatusInternalServerError, "获取点赞等级收益失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} + +// ===== 5. 藏品 TOP5 ===== +func (ctrl *StatisticController) GetTopAssets(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + resp, err := ctrl.statisticService.GetTopAssetsByEarning(buildCtx(c), &pb.GetTopAssetsByEarningRequest{StarId: starID}) + if err != nil { + response.Error(c, http.StatusInternalServerError, "获取藏品 TOP5 失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} + +// ===== 6. 藏品等级分布 ===== +func (ctrl *StatisticController) GetLevelDistribution(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + resp, err := ctrl.statisticService.GetAssetLevelDistribution(buildCtx(c), &pb.GetAssetLevelDistributionRequest{StarId: starID}) + if err != nil { + response.Error(c, http.StatusInternalServerError, "获取等级分布失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} + +// ===== 7. 升级进度 ===== +func (ctrl *StatisticController) GetUpgradeProgress(c *gin.Context) { + starID, ok := parseStarID(c) + if !ok { + return + } + resp, err := ctrl.statisticService.GetAssetUpgradeProgress(buildCtx(c), &pb.GetAssetUpgradeProgressRequest{StarId: starID}) + if err != nil { + response.Error(c, http.StatusInternalServerError, "获取升级进度失败") + return + } + response.OK(c, gin.H{"code": 200, "data": resp}) +} diff --git a/backend/gateway/main.go b/backend/gateway/main.go index 940c747..a37d1cd 100644 --- a/backend/gateway/main.go +++ b/backend/gateway/main.go @@ -175,9 +175,18 @@ func main() { } logger.Logger.Info("AI Chat Service Dubbo client connected successfully") + // 4.9 StatisticService Client + statisticClient, err := client.NewClient( + client.WithClientURL(cfg.Dubbo.StatisticServiceURL), + ) + if err != nil { + logger.Logger.Fatal("Failed to create Statistic Service Dubbo client", zap.Error(err)) + } + logger.Logger.Info("Statistic Service Dubbo client connected successfully") + // 5. 设置路由 logger.Logger.Info("Setting up routes...") - r, err := router.SetupRouter(userClient, socialClient, assetClient, galleryClient, activityClient, taskClient, starbookClient, aiChatClient, cfg.WebSocket.AIChatPath) + r, err := router.SetupRouter(userClient, socialClient, assetClient, galleryClient, activityClient, taskClient, starbookClient, aiChatClient, statisticClient, cfg.WebSocket.AIChatPath) if err != nil { logger.Logger.Fatal("Failed to setup router", zap.Error(err)) } diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index a0ef14a..becc145 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -15,7 +15,7 @@ import ( ) // SetupRouter 设置路由 -func SetupRouter(userClient *client.Client, socialClient *client.Client, assetClient *client.Client, galleryClient *client.Client, activityClient *client.Client, taskClient *client.Client, starbookClient *client.Client, aiChatClient *client.Client, aiChatPath string) (*gin.Engine, error) { +func SetupRouter(userClient *client.Client, socialClient *client.Client, assetClient *client.Client, galleryClient *client.Client, activityClient *client.Client, taskClient *client.Client, starbookClient *client.Client, aiChatClient *client.Client, statisticClient *client.Client, aiChatPath string) (*gin.Engine, error) { r := gin.Default() // 全局中间件 @@ -81,6 +81,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl return nil, err } + statisticCtrl, err := controller.NewStatisticController(statisticClient) + if err != nil { + return nil, err + } + starbookCtrl, err := controller.NewStarbookController(starbookClient) if err != nil { return nil, err @@ -109,6 +114,19 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl auth.POST("/verify-code", authCtrl.VerifyCode) // 验证验证码 } + // 数据看板路由(需要 JWT 鉴权) + dashboard := v1.Group("/dashboard") + dashboard.Use(middleware.AuthMiddleware()) + { + dashboard.GET("/today-overview", statisticCtrl.GetTodayOverview) // 1. 今日概览 + dashboard.GET("/income-curve", statisticCtrl.Get7DayIncomeCurve) // 2. 七日收益曲线 + dashboard.GET("/exhibition-summary", statisticCtrl.GetExhibitionIncomeSummary) // 3. 展出收益中心 + dashboard.GET("/like-income-by-level", statisticCtrl.GetLikeIncomeByLevel) // 4. 点赞等级收益 + dashboard.GET("/top-assets", statisticCtrl.GetTopAssets) // 5. 藏品 TOP5 + dashboard.GET("/level-distribution", statisticCtrl.GetLevelDistribution) // 6. 等级分布 + dashboard.GET("/upgrade-progress", statisticCtrl.GetUpgradeProgress) // 7. 升级进度 + } + // 认证相关路由(需要认证) authProtected := v1.Group("/auth") authProtected.Use(middleware.AuthMiddleware()) diff --git a/backend/pkg/statistic/client.go b/backend/pkg/statistic/client.go new file mode 100644 index 0000000..35b612e --- /dev/null +++ b/backend/pkg/statistic/client.go @@ -0,0 +1,91 @@ +package statistic + +import ( + "context" + "sync" + "time" + + dubboclient "dubbo.apache.org/dubbo-go/v3/client" + "github.com/google/uuid" + pb "github.com/topfans/backend/pkg/proto/event" + statisticPb "github.com/topfans/backend/pkg/proto/statistic" +) + +var ( + instance *Client + once sync.Once +) + +// Client 业务侧统一 SDK(fire-and-forget 调用 statisticService) +type Client struct { + service statisticPb.StatisticService +} + +// Init 用 Dubbo client 初始化 SDK(在业务服务 main.go 启动时调用一次) +func Init(dubboClient *dubboclient.Client) error { + var err error + once.Do(func() { + svc, e := statisticPb.NewStatisticService(dubboClient) + if e != nil { + err = e + return + } + instance = &Client{service: svc} + }) + return err +} + +// Get 返回全局 SDK 实例(Init 之后才能用) +func Get() *Client { return instance } + +// TrackEvent fire-and-forget 上报单个事件 +// - 自动填充 event_id(若为空)和 occurred_at +// - 不阻塞业务方(独立 goroutine + background context) +func (c *Client) TrackEvent(ctx context.Context, e *pb.Event) { + if c == nil || c.service == nil { + return + } + if e.EventId == "" { + e.EventId = uuid.New().String() + } + if e.OccurredAt == 0 { + e.OccurredAt = time.Now().UnixMilli() + } + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + go func() { + defer cancel() + _, _ = c.service.TrackEvent(bgCtx, e) + }() +} + +// SetMockForTest 测试钩子:注入 mock 客户端 +var mockForTest Capturer + +// Capturer 测试用 capture 接口 +type Capturer interface { + Capture(e *pb.Event) +} + +// SetMockForTest 注入测试 mock +func SetMockForTest(c Capturer) { mockForTest = c } + +// ResetMockForTest 重置 mock +func ResetMockForTest() { mockForTest = nil } + +// TrackEventSync 同步版本(测试用) +func (c *Client) TrackEventSync(ctx context.Context, e *pb.Event) (*statisticPb.TrackEventResponse, error) { + if e.EventId == "" { + e.EventId = uuid.New().String() + } + if e.OccurredAt == 0 { + e.OccurredAt = time.Now().UnixMilli() + } + if mockForTest != nil { + mockForTest.Capture(e) + return &statisticPb.TrackEventResponse{Accepted: 1, Rejected: 0}, nil + } + if c == nil || c.service == nil { + return &statisticPb.TrackEventResponse{Accepted: 0, Rejected: 1}, nil + } + return c.service.TrackEvent(ctx, e) +}