feat(statistic): T13-T16 Gateway routes + 5-service business integration (7/7 event types)
Gateway:
- 7 routes /api/v1/dashboard/* with JWT auth (middleware.AuthMiddleware)
- statistic_controller.go: 7 methods, response format {code:200, data:resp}
- gateway/main.go: StatisticServiceClient wired
- gateway/config: StatisticServiceURL (DUBBO_STATISTIC_SERVICE_URL, default tri://127.0.0.1:20009)
pkg/statistic SDK:
- fire-and-forget TrackEvent + BatchTrackEvent
- Init(client) + Get() global singleton pattern
Business-side integration (7/7 event types):
- socialService.LikeAsset → asset.like
- galleryService.PlaceAsset → exhibition.start
- galleryService.RemoveFromSlot → exhibition.end (with duration_ms)
- taskService.OnExhibitionCompleted → exhibition.revenue
- assetService.CreateMintOrder → asset.mint
- assetService.logLevelChange → asset.level_up
- userService.UpdateCrystalBalance → crystal.change (wrapper fn added)
Cache warmup:
- main.go: 7 RPCs x 5 sample starIDs at startup (15s delay)
- prevents cold-start DB thundering herd
Existing service modifications:
- galleryService/exhibition_service.go
- taskService/revenue_service.go
- assetService/{mint_service,asset_level_service}.go
- userService/user_service.go
- socialService/asset_like_service.go
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dd9952ccc9
commit
3d3f2260f3
@ -66,14 +66,15 @@ type ServerConfig struct {
|
|||||||
|
|
||||||
// DubboConfig Dubbo 服务配置
|
// DubboConfig Dubbo 服务配置
|
||||||
type DubboConfig struct {
|
type DubboConfig struct {
|
||||||
UserServiceURL string
|
UserServiceURL string
|
||||||
SocialServiceURL string
|
SocialServiceURL string
|
||||||
AssetServiceURL string
|
AssetServiceURL string
|
||||||
GalleryServiceURL string
|
GalleryServiceURL string
|
||||||
ActivityServiceURL string
|
ActivityServiceURL string
|
||||||
TaskServiceURL string
|
TaskServiceURL string
|
||||||
StarbookServiceURL string
|
StarbookServiceURL string
|
||||||
AIChatServiceURL string
|
AIChatServiceURL string
|
||||||
|
StatisticServiceURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTConfig JWT 配置
|
// JWTConfig JWT 配置
|
||||||
@ -134,6 +135,7 @@ func Load() *Config {
|
|||||||
TaskServiceURL: getEnv("DUBBO_TASK_SERVICE_URL", "tri://127.0.0.1:20006"),
|
TaskServiceURL: getEnv("DUBBO_TASK_SERVICE_URL", "tri://127.0.0.1:20006"),
|
||||||
StarbookServiceURL: getEnv("DUBBO_STARBOOK_SERVICE_URL", "tri://127.0.0.1:20007"),
|
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"),
|
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{
|
JWT: JWTConfig{
|
||||||
Secret: getEnv("JWT_SECRET", ""),
|
Secret: getEnv("JWT_SECRET", ""),
|
||||||
|
|||||||
171
backend/gateway/controller/statistic_controller.go
Normal file
171
backend/gateway/controller/statistic_controller.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
@ -175,9 +175,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger.Logger.Info("AI Chat Service Dubbo client connected successfully")
|
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. 设置路由
|
// 5. 设置路由
|
||||||
logger.Logger.Info("Setting up routes...")
|
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 {
|
if err != nil {
|
||||||
logger.Logger.Fatal("Failed to setup router", zap.Error(err))
|
logger.Logger.Fatal("Failed to setup router", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SetupRouter 设置路由
|
// 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()
|
r := gin.Default()
|
||||||
|
|
||||||
// 全局中间件
|
// 全局中间件
|
||||||
@ -81,6 +81,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statisticCtrl, err := controller.NewStatisticController(statisticClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
starbookCtrl, err := controller.NewStarbookController(starbookClient)
|
starbookCtrl, err := controller.NewStarbookController(starbookClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -109,6 +114,19 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
auth.POST("/verify-code", authCtrl.VerifyCode) // 验证验证码
|
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 := v1.Group("/auth")
|
||||||
authProtected.Use(middleware.AuthMiddleware())
|
authProtected.Use(middleware.AuthMiddleware())
|
||||||
|
|||||||
91
backend/pkg/statistic/client.go
Normal file
91
backend/pkg/statistic/client.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user