diff --git a/cmd/api/main.go b/cmd/api/main.go index ea590fb..230b96b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -69,6 +69,8 @@ func main() { wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways) smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways) regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways) + baishunService.SetHighWinBroadcaster(regionIMGroupService) + luckyGiftService.SetHighWinBroadcaster(regionIMGroupService) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) diff --git a/internal/router/baishun_routes.go b/internal/router/baishun_routes.go index 96f8277..360700c 100644 --- a/internal/router/baishun_routes.go +++ b/internal/router/baishun_routes.go @@ -248,7 +248,7 @@ func registerBaishunRoutes( }) callbackGroup.POST("/report", func(c *gin.Context) { raw, payload := readRawPayload(c) - writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw)) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), mapToReportRequest(payload), raw)) }) callbackGroup.POST("/balance-info", func(c *gin.Context) { raw, payload := readRawPayload(c) @@ -338,6 +338,16 @@ func mapToChangeBalanceRequest(payload map[string]any) baishun.BaishunChangeBala } } +// mapToReportRequest 把百顺 report 回调报文映射成服务层 DTO。 +func mapToReportRequest(payload map[string]any) baishun.BaishunReportRequest { + return baishun.BaishunReportRequest{ + ReportType: asString(payload["report_type"]), + ReportMsg: asMap(payload["report_msg"]), + UserID: asString(payload["user_id"]), + SSToken: asString(payload["ss_token"]), + } +} + // mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。 func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest { return baishun.BaishunBalanceInfoRequest{ @@ -350,6 +360,26 @@ func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoR } } +// asMap 宽松读取对象字段。 +func asMap(value any) map[string]any { + switch typed := value.(type) { + case map[string]any: + return typed + case string: + result := map[string]any{} + _ = json.Unmarshal([]byte(typed), &result) + return result + default: + body, err := json.Marshal(typed) + if err != nil { + return map[string]any{} + } + result := map[string]any{} + _ = json.Unmarshal(body, &result) + return result + } +} + // asString 宽松读取字符串字段。 func asString(value any) string { if value == nil { diff --git a/internal/service/baishun/callback.go b/internal/service/baishun/callback.go index 443b9e1..f4834fa 100644 --- a/internal/service/baishun/callback.go +++ b/internal/service/baishun/callback.go @@ -243,21 +243,6 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha return resp } -// HandleReport 处理百顺上报类回调,目前仅记录日志并返回成功。 -func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse { - userID := toString(payload["user_id"]) - orderID := toString(payload["order_id"]) - gameRoundID := toString(payload["game_round_id"]) - resp := baishunStandardResponse{ - Code: 0, - Message: "succeed", - UniqueID: s.uniqueID(), - Data: map[string]any{}, - } - s.saveCallbackLog(ctx, "report", rawJSON, utils.MustJSONString(resp, ""), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message) - return resp -} - func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) { if s.taskReporter == nil || req.CurrencyDiff >= 0 { return diff --git a/internal/service/baishun/launch_fallback_test.go b/internal/service/baishun/launch_fallback_test.go index 69891c3..d01fb85 100644 --- a/internal/service/baishun/launch_fallback_test.go +++ b/internal/service/baishun/launch_fallback_test.go @@ -49,6 +49,7 @@ func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) { &model.BaishunProviderConfig{}, &model.BaishunGameCatalog{}, &model.BaishunLaunchSession{}, + &model.BaishunCallbackLog{}, &model.BaishunRoomState{}, ); err != nil { t.Fatalf("auto migrate: %v", err) diff --git a/internal/service/baishun/report.go b/internal/service/baishun/report.go new file mode 100644 index 0000000..dd1fbe3 --- /dev/null +++ b/internal/service/baishun/report.go @@ -0,0 +1,291 @@ +package baishun + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/highwin" + "chatapp3-golang/internal/utils" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + baishunReportTypeStart = "game_start" + baishunReportTypeSettle = "game_settle" + baishunReportBetTTL = 24 * time.Hour + baishunReportBetRedisPrefix = "baishun:report:bet" +) + +type baishunGameBetRecord struct { + SysOrigin string `json:"sysOrigin"` + RoomID string `json:"roomId"` + GameID int64 `json:"gameId"` + GameRoundID string `json:"gameRoundId"` + UserID int64 `json:"userId"` + Bet int64 `json:"bet"` +} + +func (s *BaishunService) HandleReport(ctx context.Context, req BaishunReportRequest, rawJSON string) baishunStandardResponse { + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{}, + } + + sysOrigin := "" + if session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID); ok { + sysOrigin = session.SysOrigin + if err := s.handleReportHighWins(ctx, session.SysOrigin, req); err != nil { + log.Printf("handle baishun report high win failed userId=%s reportType=%s: %v", req.UserID, req.ReportType, err) + } + } + + s.saveCallbackLog( + ctx, + "report", + rawJSON, + utils.MustJSONString(resp, ""), + sysOrigin, + req.UserID, + nil, + stringPtr(reportGameRoundID(req.ReportMsg)), + baishunCallbackStatusSuccess, + 0, + resp.Message, + ) + return resp +} + +func (s *BaishunService) handleReportHighWins(ctx context.Context, sysOrigin string, req BaishunReportRequest) error { + switch strings.ToLower(strings.TrimSpace(req.ReportType)) { + case baishunReportTypeStart: + return s.rememberBaishunGameBets(ctx, sysOrigin, req.ReportMsg) + case baishunReportTypeSettle: + return s.publishBaishunGameHighWins(ctx, sysOrigin, req.ReportMsg) + default: + return nil + } +} + +func (s *BaishunService) rememberBaishunGameBets(ctx context.Context, sysOrigin string, payload map[string]any) error { + if s == nil || s.repo.Redis == nil { + return nil + } + roomID := reportText(payload, "room_id", "roomId") + gameRoundID := reportGameRoundID(payload) + if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" { + return nil + } + gameID := reportInt64(payload, "game_id", "gameId") + for _, player := range reportPlayers(payload) { + if reportInt64(player, "is_ai", "isAi") != 0 { + continue + } + userID := reportInt64(player, "user_id", "userId") + bet := reportInt64(player, "bet") + if userID <= 0 || bet <= 0 { + continue + } + record := baishunGameBetRecord{ + SysOrigin: normalizeAdminSysOrigin(sysOrigin), + RoomID: roomID, + GameID: gameID, + GameRoundID: gameRoundID, + UserID: userID, + Bet: bet, + } + body, err := json.Marshal(record) + if err != nil { + return err + } + if err := s.repo.Redis.Set(ctx, baishunGameBetKey(record.SysOrigin, gameRoundID, userID), string(body), baishunReportBetTTL).Err(); err != nil { + return err + } + } + return nil +} + +func (s *BaishunService) publishBaishunGameHighWins(ctx context.Context, sysOrigin string, payload map[string]any) error { + if s == nil || s.highWinBroadcaster == nil || s.repo.Redis == nil { + return nil + } + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + roomID := reportText(payload, "room_id", "roomId") + gameRoundID := reportGameRoundID(payload) + if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" { + return nil + } + gameID := reportInt64(payload, "game_id", "gameId") + gameCover := s.resolveBaishunReportGameCover(ctx, sysOrigin, roomID, gameID) + + for _, player := range reportPlayers(payload) { + if reportInt64(player, "is_ai", "isAi") != 0 { + continue + } + userID := reportInt64(player, "user_id", "userId") + reward := reportInt64(player, "reward") + if userID <= 0 || reward <= 0 { + continue + } + betRecord, ok, err := s.loadBaishunGameBet(ctx, sysOrigin, gameRoundID, userID) + if err != nil { + return err + } + if !ok || betRecord.Bet <= 0 { + continue + } + multiple := highwin.Multiple(reward, betRecord.Bet) + if !highwin.ShouldBroadcast(multiple, reward) { + continue + } + data := map[string]any{ + "gameId": gameID, + "gameRoundId": gameRoundID, + "gameUrl": gameCover, + "betAmount": betRecord.Bet, + "currencyDiff": reward, + "rank": reportInt64(player, "rank"), + "score": reportInt64(player, "score"), + } + if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{ + SysOrigin: sysOrigin, + Type: highwin.MessageTypeBaishunWin, + UserID: userID, + RoomID: roomID, + Multiple: multiple, + Amount: reward, + Data: data, + }); err != nil { + log.Printf("send baishun game high win region broadcast failed roundId=%s userId=%d: %v", gameRoundID, userID, err) + } + _ = s.repo.Redis.Del(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Err() + } + return nil +} + +func (s *BaishunService) loadBaishunGameBet(ctx context.Context, sysOrigin, gameRoundID string, userID int64) (baishunGameBetRecord, bool, error) { + raw, err := s.repo.Redis.Get(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Result() + if errors.Is(err, redis.Nil) { + return baishunGameBetRecord{}, false, nil + } + if err != nil { + return baishunGameBetRecord{}, false, err + } + var record baishunGameBetRecord + if err := json.Unmarshal([]byte(raw), &record); err != nil { + return baishunGameBetRecord{}, false, err + } + return record, true, nil +} + +func (s *BaishunService) resolveBaishunReportGameCover(ctx context.Context, sysOrigin, roomID string, gameID int64) string { + var state model.BaishunRoomState + if strings.TrimSpace(roomID) != "" { + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND room_id = ?", normalizeAdminSysOrigin(sysOrigin), roomID). + First(&state).Error + if err == nil && strings.TrimSpace(state.CurrentGameCover) != "" { + return strings.TrimSpace(state.CurrentGameCover) + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "" + } + } + if gameID <= 0 { + return "" + } + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return "" + } + var catalog model.BaishunGameCatalog + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND profile = ? AND vendor_game_id = ?", normalizeAdminSysOrigin(sysOrigin), profile, gameID). + First(&catalog).Error; err != nil { + return "" + } + return strings.TrimSpace(catalog.Cover) +} + +func reportGameRoundID(payload map[string]any) string { + return reportText(payload, "game_round_id", "gameRoundId") +} + +func reportPlayers(payload map[string]any) []map[string]any { + raw, exists := payload["players"] + if !exists { + return nil + } + switch typed := raw.(type) { + case []map[string]any: + return typed + case []any: + players := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + if player, ok := item.(map[string]any); ok { + players = append(players, player) + } + } + return players + default: + return nil + } +} + +func reportText(payload map[string]any, keys ...string) string { + for _, key := range keys { + if value, exists := payload[key]; exists { + text := strings.TrimSpace(toString(value)) + if text != "" { + return text + } + } + } + return "" +} + +func reportInt64(payload map[string]any, keys ...string) int64 { + for _, key := range keys { + value, exists := payload[key] + if !exists { + continue + } + switch typed := value.(type) { + case int: + return int64(typed) + case int64: + return 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: + parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64) + return parsed + } + } + return 0 +} + +func baishunGameBetKey(sysOrigin, gameRoundID string, userID int64) string { + return strings.Join([]string{ + baishunReportBetRedisPrefix, + normalizeAdminSysOrigin(sysOrigin), + strings.TrimSpace(gameRoundID), + strconv.FormatInt(userID, 10), + }, ":") +} diff --git a/internal/service/baishun/report_test.go b/internal/service/baishun/report_test.go new file mode 100644 index 0000000..3ec3087 --- /dev/null +++ b/internal/service/baishun/report_test.go @@ -0,0 +1,115 @@ +package baishun + +import ( + "context" + "testing" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/regionimgroup" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func TestHandleReportPublishesRegionHighWinAfterStartAndSettle(t *testing.T) { + service, db := newTestBaishunService(t) + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = redisClient.Close() }) + service.repo.Redis = redisClient + broadcaster := &fakeBaishunRegionBroadcaster{} + service.SetHighWinBroadcaster(broadcaster) + + expireAt := time.Now().Add(time.Hour) + token := "ss-token-1001" + if err := db.Create(&model.BaishunLaunchSession{ + ID: 1, + SysOrigin: "LIKEI", + RoomID: "9001", + UserID: 1001, + InternalGameID: "bs_1146", + VendorGameID: 1146, + GameSessionID: "session-1", + SSToken: &token, + SSTokenExpireTime: &expireAt, + Status: baishunSessionActive, + CreateTime: time.Now(), + UpdateTime: time.Now(), + }).Error; err != nil { + t.Fatalf("seed session: %v", err) + } + if err := db.Create(&model.BaishunRoomState{ + ID: 2, + SysOrigin: "LIKEI", + RoomID: "9001", + HostUserID: 1001, + CurrentGameID: "bs_1146", + CurrentVendorGameID: intPtr(1146), + CurrentGameName: "LordOfOlympus", + CurrentGameCover: "https://cdn.example.com/game.png", + GameSessionID: "session-1", + State: baishunRoomStatePlaying, + CreateTime: time.Now(), + UpdateTime: time.Now(), + }).Error; err != nil { + t.Fatalf("seed room state: %v", err) + } + + startResp := service.HandleReport(context.Background(), BaishunReportRequest{ + ReportType: baishunReportTypeStart, + UserID: "1001", + SSToken: token, + ReportMsg: map[string]any{ + "game_id": 1146, + "room_id": "9001", + "game_round_id": "round-1", + "players": []any{ + map[string]any{"user_id": "1001", "is_ai": 0, "bet": 100}, + }, + }, + }, `{"report_type":"game_start"}`) + if startResp.Code != 0 { + t.Fatalf("start response = %+v", startResp) + } + + settleResp := service.HandleReport(context.Background(), BaishunReportRequest{ + ReportType: baishunReportTypeSettle, + UserID: "1001", + SSToken: token, + ReportMsg: map[string]any{ + "game_id": 1146, + "room_id": "9001", + "game_round_id": "round-1", + "players": []any{ + map[string]any{"user_id": "1001", "is_ai": 0, "reward": 1000, "rank": 1, "score": 88}, + }, + }, + }, `{"report_type":"game_settle"}`) + if settleResp.Code != 0 { + t.Fatalf("settle response = %+v", settleResp) + } + + if len(broadcaster.requests) != 1 { + t.Fatalf("requests = %d, want 1", len(broadcaster.requests)) + } + req := broadcaster.requests[0] + if req.Type != "GAME_BAISHUN_WIN" { + t.Fatalf("type = %q, want GAME_BAISHUN_WIN", req.Type) + } + if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" || + req.Data["multiple"] != int64(10) || req.Data["winAmount"] != int64(1000) || + req.Data["currencyDiff"] != int64(1000) || + req.Data["gameUrl"] != "https://cdn.example.com/game.png" { + t.Fatalf("payload = %+v", req.Data) + } +} + +type fakeBaishunRegionBroadcaster struct { + requests []regionimgroup.RegionBroadcastRequest +} + +func (f *fakeBaishunRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) { + f.requests = append(f.requests, req) + return ®ionimgroup.RegionBroadcastResponse{Type: req.Type}, nil +} diff --git a/internal/service/baishun/types.go b/internal/service/baishun/types.go index 8f5ff08..b148dd5 100644 --- a/internal/service/baishun/types.go +++ b/internal/service/baishun/types.go @@ -3,6 +3,7 @@ package baishun import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/service/highwin" "context" "net/http" @@ -61,11 +62,12 @@ type baishunPorts struct { // BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。 type BaishunService struct { - cfg config.Config - repo baishunPorts - java baishunGateway - httpClient *http.Client - taskReporter taskEventReporter + cfg config.Config + repo baishunPorts + java baishunGateway + httpClient *http.Client + taskReporter taskEventReporter + highWinBroadcaster highwin.RegionBroadcaster } // NewBaishunService 创建百顺接入服务实例。 @@ -84,6 +86,10 @@ func (s *BaishunService) SetTaskEventReporter(reporter taskEventReporter) { s.taskReporter = reporter } +func (s *BaishunService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) { + s.highWinBroadcaster = broadcaster +} + // RoomGameListItem 是房间游戏列表中的单个游戏项。 type RoomGameListItem struct { ID int64 `json:"id"` @@ -418,6 +424,13 @@ type BaishunBalanceInfoRequest struct { Timestamp int64 `json:"timestamp"` } +type BaishunReportRequest struct { + ReportType string `json:"report_type"` + ReportMsg map[string]any `json:"report_msg"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` +} + // baishunStandardResponse 是百顺标准回包结构。 type baishunStandardResponse struct { Code int `json:"code"` diff --git a/internal/service/highwin/broadcast.go b/internal/service/highwin/broadcast.go new file mode 100644 index 0000000..dd88c3a --- /dev/null +++ b/internal/service/highwin/broadcast.go @@ -0,0 +1,148 @@ +package highwin + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + + "chatapp3-golang/internal/service/regionimgroup" +) + +const ( + ThresholdMultiple = 10 + + MessageTypeLuckyGift = "GAME_LUCKY_GIFT" + MessageTypeBaishunWin = "GAME_BAISHUN_WIN" +) + +type RegionBroadcaster interface { + SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) +} + +type BroadcastEvent struct { + SysOrigin string + Type string + UserID int64 + RoomID string + Multiple float64 + Amount int64 + Data map[string]any +} + +func IntegralMultiple(amount, base int64) int64 { + if amount <= 0 || base <= 0 { + return 0 + } + return amount / base +} + +func Multiple(amount, base int64) float64 { + if amount <= 0 || base <= 0 { + return 0 + } + return float64(amount) / float64(base) +} + +func ShouldBroadcast(multiple float64, amount int64) bool { + return amount > 0 && multiple >= ThresholdMultiple +} + +func SendRegionBroadcast(ctx context.Context, broadcaster RegionBroadcaster, event BroadcastEvent) error { + if broadcaster == nil || !ShouldBroadcast(event.Multiple, event.Amount) { + return nil + } + messageType := strings.TrimSpace(event.Type) + if messageType == "" { + return fmt.Errorf("high win broadcast type is required") + } + if event.UserID <= 0 { + return fmt.Errorf("high win broadcast user id is required") + } + roomID := strings.TrimSpace(event.RoomID) + if roomID == "" { + return fmt.Errorf("high win broadcast room id is required") + } + + data := cloneData(event.Data) + userIDText := strconv.FormatInt(event.UserID, 10) + setRequired(data, "userId", userIDText) + setRequired(data, "sendUserId", userIDText) + setRequired(data, "senderUserId", userIDText) + setRequired(data, "roomId", roomID) + setRequired(data, "multiple", normalizeMultiple(event.Multiple)) + setRequired(data, "winMultiple", normalizeMultiple(event.Multiple)) + setRequired(data, "winAmount", event.Amount) + setRequired(data, "awardAmount", event.Amount) + setRequired(data, "msg", highWinMessage(data, messageType, userIDText, event.Multiple, event.Amount)) + + _, err := broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{ + SysOrigin: strings.TrimSpace(event.SysOrigin), + Type: messageType, + Data: data, + }) + return err +} + +func cloneData(data map[string]any) map[string]any { + clone := map[string]any{} + for key, value := range data { + clone[key] = value + } + return clone +} + +func setRequired(data map[string]any, key string, value any) { + data[key] = value +} + +func normalizeMultiple(value float64) any { + if value <= 0 { + return 0 + } + if math.Trunc(value) == value { + return int64(value) + } + return math.Round(value*100) / 100 +} + +func highWinMessage(data map[string]any, messageType, fallbackUser string, multiple float64, amount int64) string { + user := firstTextValue(data, "userNickname", "nickname", "account", "actualAccount", "sendUserId", "senderUserId", "userId") + if user == "" { + user = fallbackUser + } + return fmt.Sprintf("%s play %s get x %s win %d coins!!!", user, highWinPlayTarget(messageType), formatMultipleText(multiple), amount) +} + +func highWinPlayTarget(messageType string) string { + switch strings.TrimSpace(messageType) { + case MessageTypeLuckyGift: + return "lucky_gift" + case MessageTypeBaishunWin: + return "game" + default: + return "game" + } +} + +func firstTextValue(data map[string]any, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(fmt.Sprint(data[key])); value != "" && value != "" { + return value + } + } + return "" +} + +func formatMultipleText(value float64) string { + normalized := normalizeMultiple(value) + switch typed := normalized.(type) { + case int64: + return strconv.FormatInt(typed, 10) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} diff --git a/internal/service/highwin/broadcast_test.go b/internal/service/highwin/broadcast_test.go new file mode 100644 index 0000000..6e125e3 --- /dev/null +++ b/internal/service/highwin/broadcast_test.go @@ -0,0 +1,71 @@ +package highwin + +import ( + "context" + "testing" + + "chatapp3-golang/internal/service/regionimgroup" +) + +func TestSendRegionBroadcastBuildsRequiredPayload(t *testing.T) { + broadcaster := &fakeRegionBroadcaster{} + + err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{ + SysOrigin: "LIKEI", + Type: MessageTypeBaishunWin, + UserID: 1001, + RoomID: "9001", + Multiple: 10.5, + Amount: 2100, + Data: map[string]any{ + "gameId": 1146, + }, + }) + if err != nil { + t.Fatalf("SendRegionBroadcast() error = %v", err) + } + if len(broadcaster.requests) != 1 { + t.Fatalf("requests = %d, want 1", len(broadcaster.requests)) + } + req := broadcaster.requests[0] + if req.Type != MessageTypeBaishunWin { + t.Fatalf("type = %q, want %q", req.Type, MessageTypeBaishunWin) + } + if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" || + req.Data["winAmount"] != int64(2100) || req.Data["multiple"] != 10.5 || + req.Data["msg"] != "1001 play game get x 10.5 win 2100 coins!!!" { + t.Fatalf("payload = %+v", req.Data) + } +} + +func TestSendRegionBroadcastSkipsBelowThreshold(t *testing.T) { + broadcaster := &fakeRegionBroadcaster{} + + err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{ + Type: MessageTypeLuckyGift, + UserID: 1001, + RoomID: "9001", + Multiple: 9, + Amount: 900, + }) + if err != nil { + t.Fatalf("SendRegionBroadcast() error = %v", err) + } + if len(broadcaster.requests) != 0 { + t.Fatalf("requests = %d, want 0", len(broadcaster.requests)) + } +} + +type fakeRegionBroadcaster struct { + requests []regionimgroup.RegionBroadcastRequest +} + +func (f *fakeRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) { + f.requests = append(f.requests, req) + return ®ionimgroup.RegionBroadcastResponse{ + SysOrigin: req.SysOrigin, + RegionCode: "AR", + GroupID: "@region-ar", + Type: req.Type, + }, nil +} diff --git a/internal/service/luckygift/draw.go b/internal/service/luckygift/draw.go index 739fdb6..a2bd8a6 100644 --- a/internal/service/luckygift/draw.go +++ b/internal/service/luckygift/draw.go @@ -140,6 +140,8 @@ func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) ( return nil, err } + s.publishHighWinRegionBroadcasts(ctx, req, results, balanceAfter) + return resp, nil } diff --git a/internal/service/luckygift/high_win.go b/internal/service/luckygift/high_win.go new file mode 100644 index 0000000..9f80b4d --- /dev/null +++ b/internal/service/luckygift/high_win.go @@ -0,0 +1,51 @@ +package luckygift + +import ( + "context" + "log" + "strconv" + + "chatapp3-golang/internal/service/highwin" +) + +func (s *LuckyGiftService) publishHighWinRegionBroadcasts(ctx context.Context, req LuckyGiftDrawRequest, results []LuckyGiftDrawResult, balanceAfter int64) { + if s == nil || s.highWinBroadcaster == nil || req.GiftPrice <= 0 { + return + } + for _, result := range results { + if result.RewardNum <= 0 { + continue + } + multiple := highwin.IntegralMultiple(result.RewardNum, req.GiftPrice) + if !highwin.ShouldBroadcast(float64(multiple), result.RewardNum) { + continue + } + data := map[string]any{ + "businessId": req.BusinessID, + "orderId": result.ID, + "providerOrderId": result.OrderID, + "acceptUserId": strconv.FormatInt(result.AcceptUserID, 10), + "giftId": strconv.FormatInt(req.GiftID, 10), + "giftCandy": req.GiftPrice, + "giftQuantity": req.GiftNum, + "multipleType": luckyGiftMultipleType(multiple), + "balance": balanceAfter, + "globalNews": true, + } + if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{ + SysOrigin: req.SysOrigin, + Type: highwin.MessageTypeLuckyGift, + UserID: req.SendUserID, + RoomID: strconv.FormatInt(req.RoomID, 10), + Multiple: float64(multiple), + Amount: result.RewardNum, + Data: data, + }); err != nil { + log.Printf("send lucky gift high win region broadcast failed businessId=%s orderId=%s: %v", req.BusinessID, result.ID, err) + } + } +} + +func luckyGiftMultipleType(_ int64) string { + return "WIN" +} diff --git a/internal/service/luckygift/high_win_test.go b/internal/service/luckygift/high_win_test.go new file mode 100644 index 0000000..52629d8 --- /dev/null +++ b/internal/service/luckygift/high_win_test.go @@ -0,0 +1,48 @@ +package luckygift + +import ( + "context" + "testing" + + "chatapp3-golang/internal/service/regionimgroup" +) + +func TestPublishHighWinRegionBroadcastsSendsOnlyTenTimesAndAbove(t *testing.T) { + broadcaster := &fakeLuckyGiftRegionBroadcaster{} + service := &LuckyGiftService{highWinBroadcaster: broadcaster} + + service.publishHighWinRegionBroadcasts(context.Background(), LuckyGiftDrawRequest{ + BusinessID: "biz-1", + SysOrigin: "LIKEI", + RoomID: 9001, + SendUserID: 1001, + GiftID: 5001, + GiftPrice: 100, + GiftNum: 1, + }, []LuckyGiftDrawResult{ + {ID: "low", AcceptUserID: 2001, RewardNum: 900}, + {ID: "high", OrderID: "LG_high", AcceptUserID: 2002, RewardNum: 1000}, + }, 90000) + + if len(broadcaster.requests) != 1 { + t.Fatalf("requests = %d, want 1", len(broadcaster.requests)) + } + req := broadcaster.requests[0] + if req.Type != "GAME_LUCKY_GIFT" { + t.Fatalf("type = %q, want GAME_LUCKY_GIFT", req.Type) + } + if req.Data["sendUserId"] != "1001" || req.Data["roomId"] != "9001" || + req.Data["multiple"] != int64(10) || req.Data["awardAmount"] != int64(1000) || + req.Data["acceptUserId"] != "2002" { + t.Fatalf("payload = %+v", req.Data) + } +} + +type fakeLuckyGiftRegionBroadcaster struct { + requests []regionimgroup.RegionBroadcastRequest +} + +func (f *fakeLuckyGiftRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) { + f.requests = append(f.requests, req) + return ®ionimgroup.RegionBroadcastResponse{Type: req.Type}, nil +} diff --git a/internal/service/luckygift/types.go b/internal/service/luckygift/types.go index 8619c21..b80d755 100644 --- a/internal/service/luckygift/types.go +++ b/internal/service/luckygift/types.go @@ -3,6 +3,7 @@ package luckygift import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/service/highwin" "context" "net/http" "time" @@ -39,10 +40,11 @@ type luckyGiftPorts struct { // LuckyGiftService 负责幸运礼物抽奖、三方调用和钱包返奖。 type LuckyGiftService struct { - cfg config.Config - repo luckyGiftPorts - java luckyGiftGateway - httpClient *http.Client + cfg config.Config + repo luckyGiftPorts + java luckyGiftGateway + httpClient *http.Client + highWinBroadcaster highwin.RegionBroadcaster } // LuckyGiftDrawRequest 是幸运礼物抽奖入参。 @@ -60,6 +62,10 @@ type LuckyGiftDrawRequest struct { Orders []LuckyGiftDrawOrderInput `json:"orders"` } +func (s *LuckyGiftService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) { + s.highWinBroadcaster = broadcaster +} + // LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。 type LuckyGiftDrawOrderInput struct { ID string `json:"id"` diff --git a/internal/service/regionimgroup/broadcast.go b/internal/service/regionimgroup/broadcast.go index 41c127a..082370f 100644 --- a/internal/service/regionimgroup/broadcast.go +++ b/internal/service/regionimgroup/broadcast.go @@ -66,6 +66,7 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe return nil, err } } + refreshHighWinMessage(data, messageType) body := map[string]any{ "type": messageType, @@ -182,6 +183,41 @@ func firstInt64Value(values ...any) int64 { return 0 } +func refreshHighWinMessage(data map[string]any, messageType string) { + target := highWinPlayTarget(messageType) + if target == "" { + return + } + user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"]) + multiple := firstTextValue(data["multiple"], data["winMultiple"]) + amount := firstTextValue(data["winAmount"], data["awardAmount"]) + if user == "" || multiple == "" || amount == "" { + return + } + data["msg"] = fmt.Sprintf("%s play %s get x %s win %s coins!!!", user, target, multiple, amount) +} + +func highWinPlayTarget(messageType string) string { + switch strings.TrimSpace(messageType) { + case "GAME_LUCKY_GIFT": + return "lucky_gift" + case "GAME_BAISHUN_WIN": + return "game" + default: + return "" + } +} + +func firstTextValue(values ...any) string { + for _, value := range values { + text := strings.TrimSpace(fmt.Sprint(value)) + if text != "" && text != "" { + return text + } + } + return "" +} + func firstInt64FromList(value any) int64 { switch typed := value.(type) { case []any: diff --git a/internal/service/regionimgroup/service_test.go b/internal/service/regionimgroup/service_test.go index 8ede8b5..f2cdc5b 100644 --- a/internal/service/regionimgroup/service_test.go +++ b/internal/service/regionimgroup/service_test.go @@ -133,6 +133,52 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) { } } +func TestSendRegionBroadcastRefreshesHighWinMessageWithProfile(t *testing.T) { + service, db := newRegionIMGroupTestService(t) + fakeIM := &fakeIMGateway{} + service.im = fakeIM + now := time.Now() + if err := db.Create(&model.VoiceRoomRegionIMGroup{ + ID: 3010, + SysOrigin: "LIKEI", + RegionCode: "AR", + RegionName: "Arab", + GroupID: "@region-ar", + GroupName: "region-ar", + Status: imGroupStatusActive, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed im group: %v", err) + } + + _, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{ + SysOrigin: "LIKEI", + Type: "GAME_BAISHUN_WIN", + Data: map[string]any{ + "userId": "1001", + "roomId": "9001", + "multiple": 10, + "winAmount": 1000, + "msg": "1001 play game get x 10 win 1000 coins!!!", + }, + }) + if err != nil { + t.Fatalf("SendRegionBroadcast() error = %v", err) + } + body, ok := fakeIM.sent[0].body.(map[string]any) + if !ok { + t.Fatalf("sent body type = %T", fakeIM.sent[0].body) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("sent data type = %T", body["data"]) + } + if data["msg"] != "sender play game get x 10 win 1000 coins!!!" { + t.Fatalf("msg = %v", data["msg"]) + } +} + func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) { service, db := newRegionIMGroupTestService(t) service.regionResolver = fakeRegionResolver{