通用im 中奖消息

This commit is contained in:
hy001 2026-05-22 14:22:27 +08:00
parent a02e22ac74
commit 8e8878a038
15 changed files with 870 additions and 25 deletions

View File

@ -69,6 +69,8 @@ func main() {
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways) wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
smashEggService := smashegg.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) 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 := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)

View File

@ -248,7 +248,7 @@ func registerBaishunRoutes(
}) })
callbackGroup.POST("/report", func(c *gin.Context) { callbackGroup.POST("/report", func(c *gin.Context) {
raw, payload := readRawPayload(c) 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) { callbackGroup.POST("/balance-info", func(c *gin.Context) {
raw, payload := readRawPayload(c) 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。 // mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。
func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest { func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest {
return 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 宽松读取字符串字段。 // asString 宽松读取字符串字段。
func asString(value any) string { func asString(value any) string {
if value == nil { if value == nil {

View File

@ -243,21 +243,6 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha
return resp 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) { func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) {
if s.taskReporter == nil || req.CurrencyDiff >= 0 { if s.taskReporter == nil || req.CurrencyDiff >= 0 {
return return

View File

@ -49,6 +49,7 @@ func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) {
&model.BaishunProviderConfig{}, &model.BaishunProviderConfig{},
&model.BaishunGameCatalog{}, &model.BaishunGameCatalog{},
&model.BaishunLaunchSession{}, &model.BaishunLaunchSession{},
&model.BaishunCallbackLog{},
&model.BaishunRoomState{}, &model.BaishunRoomState{},
); err != nil { ); err != nil {
t.Fatalf("auto migrate: %v", err) t.Fatalf("auto migrate: %v", err)

View File

@ -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),
}, ":")
}

View File

@ -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 &regionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
}

View File

@ -3,6 +3,7 @@ package baishun
import ( import (
"chatapp3-golang/internal/config" "chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context" "context"
"net/http" "net/http"
@ -61,11 +62,12 @@ type baishunPorts struct {
// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。 // BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。
type BaishunService struct { type BaishunService struct {
cfg config.Config cfg config.Config
repo baishunPorts repo baishunPorts
java baishunGateway java baishunGateway
httpClient *http.Client httpClient *http.Client
taskReporter taskEventReporter taskReporter taskEventReporter
highWinBroadcaster highwin.RegionBroadcaster
} }
// NewBaishunService 创建百顺接入服务实例。 // NewBaishunService 创建百顺接入服务实例。
@ -84,6 +86,10 @@ func (s *BaishunService) SetTaskEventReporter(reporter taskEventReporter) {
s.taskReporter = reporter s.taskReporter = reporter
} }
func (s *BaishunService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
s.highWinBroadcaster = broadcaster
}
// RoomGameListItem 是房间游戏列表中的单个游戏项。 // RoomGameListItem 是房间游戏列表中的单个游戏项。
type RoomGameListItem struct { type RoomGameListItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@ -418,6 +424,13 @@ type BaishunBalanceInfoRequest struct {
Timestamp int64 `json:"timestamp"` 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 是百顺标准回包结构。 // baishunStandardResponse 是百顺标准回包结构。
type baishunStandardResponse struct { type baishunStandardResponse struct {
Code int `json:"code"` Code int `json:"code"`

View File

@ -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 != "<nil>" {
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))
}
}

View File

@ -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 &regionimgroup.RegionBroadcastResponse{
SysOrigin: req.SysOrigin,
RegionCode: "AR",
GroupID: "@region-ar",
Type: req.Type,
}, nil
}

View File

@ -140,6 +140,8 @@ func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) (
return nil, err return nil, err
} }
s.publishHighWinRegionBroadcasts(ctx, req, results, balanceAfter)
return resp, nil return resp, nil
} }

View File

@ -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"
}

View File

@ -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 &regionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
}

View File

@ -3,6 +3,7 @@ package luckygift
import ( import (
"chatapp3-golang/internal/config" "chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context" "context"
"net/http" "net/http"
"time" "time"
@ -39,10 +40,11 @@ type luckyGiftPorts struct {
// LuckyGiftService 负责幸运礼物抽奖、三方调用和钱包返奖。 // LuckyGiftService 负责幸运礼物抽奖、三方调用和钱包返奖。
type LuckyGiftService struct { type LuckyGiftService struct {
cfg config.Config cfg config.Config
repo luckyGiftPorts repo luckyGiftPorts
java luckyGiftGateway java luckyGiftGateway
httpClient *http.Client httpClient *http.Client
highWinBroadcaster highwin.RegionBroadcaster
} }
// LuckyGiftDrawRequest 是幸运礼物抽奖入参。 // LuckyGiftDrawRequest 是幸运礼物抽奖入参。
@ -60,6 +62,10 @@ type LuckyGiftDrawRequest struct {
Orders []LuckyGiftDrawOrderInput `json:"orders"` Orders []LuckyGiftDrawOrderInput `json:"orders"`
} }
func (s *LuckyGiftService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
s.highWinBroadcaster = broadcaster
}
// LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。 // LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。
type LuckyGiftDrawOrderInput struct { type LuckyGiftDrawOrderInput struct {
ID string `json:"id"` ID string `json:"id"`

View File

@ -66,6 +66,7 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe
return nil, err return nil, err
} }
} }
refreshHighWinMessage(data, messageType)
body := map[string]any{ body := map[string]any{
"type": messageType, "type": messageType,
@ -182,6 +183,41 @@ func firstInt64Value(values ...any) int64 {
return 0 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 != "<nil>" {
return text
}
}
return ""
}
func firstInt64FromList(value any) int64 { func firstInt64FromList(value any) int64 {
switch typed := value.(type) { switch typed := value.(type) {
case []any: case []any:

View File

@ -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) { func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
service, db := newRegionIMGroupTestService(t) service, db := newRegionIMGroupTestService(t)
service.regionResolver = fakeRegionResolver{ service.regionResolver = fakeRegionResolver{