1409 lines
47 KiB
Go

package service
import (
"bytes"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/util"
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
baishunVendorType = "BAISHUN"
baishunLaunchModeRemote = "H5_REMOTE"
baishunLaunchModeLocalZip = "H5_ZIP_LOCAL"
baishunCatalogEnabled = "ENABLED"
baishunRoomStateIdle = "IDLE"
baishunRoomStatePlaying = "PLAYING"
baishunSessionInit = "INIT"
baishunSessionCodeIssued = "CODE_ISSUED"
baishunSessionTokenIssued = "TOKEN_ISSUED"
baishunSessionActive = "ACTIVE"
baishunSessionClosed = "CLOSED"
baishunSessionExpired = "EXPIRED"
baishunOrderStatusInit = "INIT"
baishunOrderStatusSuccess = "SUCCESS"
baishunOrderStatusFailed = "FAILED"
baishunCallbackStatusSuccess = "SUCCESS"
baishunCallbackStatusFailed = "FAILED"
baishunCodeInsufficientAssets = 1008
baishunCodeTokenExpired = 1009
baishunCodeSignatureError = 1010
baishunCodeRepeatUnknown = 1011
baishunCodeServerError = 1999
)
type BaishunService struct {
cfg config.Config
repo *repo.Repository
java *integration.Client
httpClient *http.Client
}
func NewBaishunService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *BaishunService {
return &BaishunService{
cfg: cfg,
repo: repository,
java: javaClient,
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
}
}
type RoomGameListItem struct {
GameID string `json:"gameId"`
VendorType string `json:"vendorType"`
VendorGameID int `json:"vendorGameId"`
Name string `json:"name"`
Cover string `json:"cover"`
Category string `json:"category,omitempty"`
Sort int64 `json:"sort,omitempty"`
LaunchMode string `json:"launchMode"`
FullScreen bool `json:"fullScreen"`
GameMode int `json:"gameMode"`
SafeHeight int `json:"safeHeight"`
Orientation int `json:"orientation"`
PackageVersion string `json:"packageVersion,omitempty"`
Status string `json:"status,omitempty"`
}
type RoomGameListResponse struct {
Items []RoomGameListItem `json:"items"`
}
type BaishunRoomStateResponse struct {
RoomID string `json:"roomId"`
State string `json:"state"`
GameSessionID string `json:"gameSessionId,omitempty"`
CurrentGameID string `json:"currentGameId,omitempty"`
CurrentVendorGameID int `json:"currentVendorGameId,omitempty"`
CurrentGameName string `json:"currentGameName,omitempty"`
CurrentGameCover string `json:"currentGameCover,omitempty"`
HostUserID int64 `json:"hostUserId,omitempty"`
}
type BaishunLaunchAppRequest struct {
RoomID string `json:"roomId"`
GameID string `json:"gameId"`
SceneMode int `json:"sceneMode"`
ClientOrigin string `json:"clientOrigin"`
}
type BaishunCloseAppRequest struct {
RoomID string `json:"roomId"`
GameSessionID string `json:"gameSessionId"`
Reason string `json:"reason"`
}
type BaishunGameConfig struct {
SceneMode int `json:"sceneMode"`
CurrencyIcon string `json:"currencyIcon"`
}
type BaishunBridgeConfig struct {
AppName string `json:"appName"`
AppChannel string `json:"appChannel"`
AppID int64 `json:"appId"`
UserID string `json:"userId"`
Code string `json:"code"`
RoomID string `json:"roomId"`
GameMode string `json:"gameMode"`
Language string `json:"language"`
GSP int `json:"gsp"`
GameConfig BaishunGameConfig `json:"gameConfig"`
}
type BaishunLaunchEntry struct {
LaunchMode string `json:"launchMode"`
EntryURL string `json:"entryUrl"`
PreviewURL string `json:"previewUrl"`
DownloadURL string `json:"downloadUrl"`
PackageVersion string `json:"packageVersion"`
Orientation int `json:"orientation"`
SafeHeight int `json:"safeHeight"`
}
type BaishunLaunchAppResponse struct {
GameSessionID string `json:"gameSessionId"`
VendorType string `json:"vendorType"`
GameID string `json:"gameId"`
VendorGameID int `json:"vendorGameId"`
Entry BaishunLaunchEntry `json:"entry"`
BridgeConfig BaishunBridgeConfig `json:"bridgeConfig"`
RoomState BaishunRoomStateResponse `json:"roomState"`
}
type SyncCatalogRequest struct {
SysOrigin string `json:"sysOrigin"`
VendorGameIDs []int `json:"vendorGameIds"`
Force bool `json:"force"`
}
type SyncCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
type BaishunTokenRequest struct {
AppID int64 `json:"app_id"`
UserID string `json:"user_id"`
Code string `json:"code"`
Signature string `json:"signature"`
SignatureNonce string `json:"signature_nonce"`
Timestamp int64 `json:"timestamp"`
}
type BaishunProfileRequest struct {
AppID int64 `json:"app_id"`
UserID string `json:"user_id"`
SSToken string `json:"ss_token"`
ClientIP string `json:"client_ip"`
GameID int `json:"game_id"`
Signature string `json:"signature"`
SignatureNonce string `json:"signature_nonce"`
Timestamp int64 `json:"timestamp"`
}
type BaishunUpdateTokenRequest struct {
AppID int64 `json:"app_id"`
UserID string `json:"user_id"`
SSToken string `json:"ss_token"`
GameID int `json:"game_id"`
Signature string `json:"signature"`
SignatureNonce string `json:"signature_nonce"`
Timestamp int64 `json:"timestamp"`
}
type BaishunChangeBalanceRequest struct {
AppID int64 `json:"app_id"`
UserID string `json:"user_id"`
SSToken string `json:"ss_token"`
CurrencyDiff int64 `json:"currency_diff"`
DiffMsg string `json:"diff_msg"`
GameID int `json:"game_id"`
GameRoundID string `json:"game_round_id"`
RoomID string `json:"room_id"`
ChangeTimeAt int64 `json:"change_time_at"`
OrderID string `json:"order_id"`
Extend string `json:"extend"`
MsgType string `json:"msg_type"`
CurrencyType *int `json:"currency_type"`
Signature string `json:"signature"`
SignatureNonce string `json:"signature_nonce"`
Timestamp int64 `json:"timestamp"`
}
type BaishunBalanceInfoRequest struct {
UserID string `json:"user_id"`
AppID int64 `json:"app_id"`
AppChannel string `json:"app_channel"`
Signature string `json:"signature"`
SignatureNonce string `json:"signature_nonce"`
Timestamp int64 `json:"timestamp"`
}
type baishunStandardResponse struct {
Code int `json:"code"`
Message string `json:"message"`
UniqueID string `json:"unique_id,omitempty"`
Data interface{} `json:"data,omitempty"`
UserInfo interface{} `json:"user_info,omitempty"`
}
type baishunBalanceInfoResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
type gameListRow struct {
ConfigID int64
SysOrigin string
InternalGameID string
Name string
Category string
Cover string
Sort int64
FullScreen bool
GameModeRaw string
VendorType string
VendorGameID string
LaunchMode string
ExtPackageVersion string
ExtPreviewURL string
PackageURL string
ExtOrientation *int
ExtSafeHeight int
CurrencyIcon string
ExtGSP string
CatalogName string
CatalogCover string
CatalogPreviewURL string
CatalogDownloadURL string
CatalogPackageVersion string
CatalogOrientation *int
CatalogSafeHeight int
CatalogStatus string
}
type baishunPlatformResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data []baishunPlatformGame `json:"data"`
}
type baishunPlatformOneGameResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data baishunPlatformGame `json:"data"`
}
type baishunPlatformGame struct {
GameID int `json:"game_id"`
Name string `json:"name"`
PreviewURL string `json:"preview_url"`
GameVersion string `json:"game_version"`
DownloadURL string `json:"download_url"`
GameMode []int `json:"game_mode"`
GameOrientation int `json:"game_orientation"`
SafeHeight int `json:"safe_height"`
VenueLevel []int `json:"venue_level"`
}
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "")
if err != nil {
return nil, err
}
if len(items) > 5 {
items = items[:5]
}
return items, nil
}
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) {
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category)
if err != nil {
return nil, err
}
return &RoomGameListResponse{Items: items}, nil
}
func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID string) (*BaishunRoomStateResponse, error) {
var state model.BaishunRoomState
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND room_id = ?", user.SysOrigin, roomID).
First(&state).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil
}
return nil, err
}
resp := &BaishunRoomStateResponse{
RoomID: roomID,
State: defaultIfBlank(state.State, baishunRoomStateIdle),
GameSessionID: state.GameSessionID,
CurrentGameID: state.CurrentGameID,
CurrentGameName: state.CurrentGameName,
CurrentGameCover: state.CurrentGameCover,
HostUserID: state.HostUserID,
}
if state.CurrentVendorGameID != nil {
resp.CurrentVendorGameID = *state.CurrentVendorGameID
}
return resp, nil
}
func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req BaishunLaunchAppRequest, clientIP string) (*BaishunLaunchAppResponse, error) {
if strings.TrimSpace(req.RoomID) == "" {
return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required")
}
if strings.TrimSpace(req.GameID) == "" {
return nil, NewAppError(http.StatusBadRequest, "missing_game_id", "gameId is required")
}
row, err := s.findGameRow(ctx, user.SysOrigin, req.GameID)
if err != nil {
return nil, err
}
sessionID, err := util.NextID()
if err != nil {
return nil, err
}
sessionDBID, err := util.NextID()
if err != nil {
return nil, err
}
roomStateID, err := util.NextID()
if err != nil {
return nil, err
}
launchCode, err := s.randomToken(24)
if err != nil {
return nil, err
}
now := time.Now()
session := model.BaishunLaunchSession{
ID: sessionDBID,
SysOrigin: user.SysOrigin,
RoomID: req.RoomID,
UserID: user.UserID,
InternalGameID: row.InternalGameID,
VendorGameID: row.vendorGameIDInt(),
GameSessionID: fmt.Sprintf("bs_%s_%d", sanitizeRoomID(req.RoomID), sessionID),
LaunchCode: launchCode,
LaunchCodeExpireTime: now.Add(time.Duration(s.cfg.BaishunLaunchCodeTTLSeconds) * time.Second),
Language: s.resolveLanguage(ctx, user.UserID),
GSP: s.resolveGSP(row),
CurrencyType: 0,
CurrencyIcon: row.currencyIcon(),
SceneMode: req.SceneMode,
GameMode: row.gameMode(),
PackageVersion: row.packageVersion(),
ClientIP: clientIP,
ClientOrigin: defaultIfBlank(req.ClientOrigin, "COMMON"),
Status: baishunSessionCodeIssued,
CreateTime: now,
UpdateTime: now,
}
roomState := model.BaishunRoomState{
ID: roomStateID,
SysOrigin: user.SysOrigin,
RoomID: req.RoomID,
HostUserID: user.UserID,
CurrentGameID: row.InternalGameID,
CurrentGameName: row.name(),
CurrentGameCover: row.cover(),
GameSessionID: session.GameSessionID,
LaunchUserID: &user.UserID,
State: baishunRoomStatePlaying,
StartTime: &now,
CreateTime: now,
UpdateTime: now,
}
vendorGameID := row.vendorGameIDInt()
roomState.CurrentVendorGameID = &vendorGameID
if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&session).Error; err != nil {
return err
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "room_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"host_user_id", "current_game_id", "current_vendor_game_id", "current_game_name",
"current_game_cover", "game_session_id", "launch_user_id", "state", "start_time",
"end_time", "update_time",
}),
}).Create(&roomState).Error
}); err != nil {
return nil, err
}
return &BaishunLaunchAppResponse{
GameSessionID: session.GameSessionID,
VendorType: baishunVendorType,
GameID: row.InternalGameID,
VendorGameID: row.vendorGameIDInt(),
Entry: BaishunLaunchEntry{
LaunchMode: row.launchMode(),
EntryURL: row.entryURL(),
PreviewURL: row.previewURL(),
DownloadURL: row.downloadURL(),
PackageVersion: row.packageVersion(),
Orientation: row.orientation(),
SafeHeight: row.safeHeight(),
},
BridgeConfig: BaishunBridgeConfig{
AppName: s.cfg.BaishunAppName,
AppChannel: defaultIfBlank(s.cfg.BaishunAppChannel, "skychat"),
AppID: s.cfg.BaishunAppID,
UserID: strconv.FormatInt(user.UserID, 10),
Code: launchCode,
RoomID: req.RoomID,
GameMode: strconv.Itoa(row.gameMode()),
Language: session.Language,
GSP: session.GSP,
GameConfig: BaishunGameConfig{
SceneMode: req.SceneMode,
CurrencyIcon: session.CurrencyIcon,
},
},
RoomState: BaishunRoomStateResponse{
RoomID: req.RoomID,
State: baishunRoomStatePlaying,
GameSessionID: session.GameSessionID,
CurrentGameID: row.InternalGameID,
CurrentVendorGameID: row.vendorGameIDInt(),
CurrentGameName: row.name(),
CurrentGameCover: row.cover(),
HostUserID: user.UserID,
},
}, nil
}
func (s *BaishunService) CloseGame(ctx context.Context, user AuthUser, req BaishunCloseAppRequest) (*BaishunRoomStateResponse, error) {
if strings.TrimSpace(req.RoomID) == "" {
return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required")
}
now := time.Now()
if strings.TrimSpace(req.GameSessionID) != "" {
if err := s.repo.DB.WithContext(ctx).
Model(&model.BaishunLaunchSession{}).
Where("sys_origin = ? AND room_id = ? AND game_session_id = ?", user.SysOrigin, req.RoomID, req.GameSessionID).
Updates(map[string]any{
"status": baishunSessionClosed,
"closed_reason": defaultIfBlank(req.Reason, "user_exit"),
"update_time": now,
}).Error; err != nil {
return nil, err
}
}
if err := s.repo.DB.WithContext(ctx).
Model(&model.BaishunRoomState{}).
Where("sys_origin = ? AND room_id = ?", user.SysOrigin, req.RoomID).
Updates(map[string]any{
"state": baishunRoomStateIdle,
"end_time": now,
"game_session_id": "",
"current_game_id": "",
"current_vendor_game_id": nil,
"current_game_name": "",
"current_game_cover": "",
"update_time": now,
}).Error; err != nil {
return nil, err
}
return &BaishunRoomStateResponse{
RoomID: req.RoomID,
State: baishunRoomStateIdle,
}, nil
}
func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
if strings.TrimSpace(s.cfg.BaishunPlatformBaseURL) == "" || s.cfg.BaishunAppID == 0 {
return nil, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun platform config is missing")
}
sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI")
if strings.TrimSpace(sysOrigin) == "" {
sysOrigin = "LIKEI"
}
games, err := s.fetchPlatformGames(ctx)
if err != nil {
return nil, err
}
filter := make(map[int]struct{}, len(req.VendorGameIDs))
for _, id := range req.VendorGameIDs {
filter[id] = struct{}{}
}
resp := &SyncCatalogResponse{}
for _, game := range games {
if len(filter) > 0 {
if _, ok := filter[game.GameID]; !ok {
resp.Skipped++
continue
}
}
id, err := util.NextID()
if err != nil {
return nil, err
}
record := model.BaishunGameCatalog{
ID: id,
SysOrigin: sysOrigin,
InternalGameID: fmt.Sprintf("bs_%d", game.GameID),
VendorGameID: game.GameID,
Name: game.Name,
Cover: defaultIfBlank(game.PreviewURL, game.DownloadURL),
PreviewURL: game.PreviewURL,
DownloadURL: game.DownloadURL,
PackageVersion: game.GameVersion,
GameModeJSON: mustJSONString(game.GameMode),
Orientation: intPtr(game.GameOrientation),
SafeHeight: game.SafeHeight,
VenueLevelJSON: mustJSONString(game.VenueLevel),
GSP: strconv.Itoa(s.cfg.BaishunGSP),
RawJSON: mustJSONString(game),
Status: baishunCatalogEnabled,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
err = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "vendor_game_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"internal_game_id", "name", "cover", "preview_url", "download_url",
"package_version", "game_mode_json", "orientation", "safe_height",
"venue_level_json", "gsp", "raw_json", "status", "update_time",
}),
}).Create(&record).Error
if err != nil {
return nil, err
}
if req.Force {
resp.Updated++
} else {
resp.Inserted++
}
}
return resp, nil
}
func (s *BaishunService) RefreshRoomState(ctx context.Context, sysOrigin, roomID string) (*BaishunRoomStateResponse, error) {
var state model.BaishunRoomState
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND room_id = ?", sysOrigin, roomID).
First(&state).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil
}
return nil, err
}
if strings.TrimSpace(state.GameSessionID) == "" {
state.State = baishunRoomStateIdle
_ = s.repo.DB.WithContext(ctx).Model(&model.BaishunRoomState{}).
Where("id = ?", state.ID).
Update("state", baishunRoomStateIdle).Error
}
resp := &BaishunRoomStateResponse{
RoomID: roomID,
State: defaultIfBlank(state.State, baishunRoomStateIdle),
GameSessionID: state.GameSessionID,
CurrentGameID: state.CurrentGameID,
CurrentGameName: state.CurrentGameName,
CurrentGameCover: state.CurrentGameCover,
HostUserID: state.HostUserID,
}
if state.CurrentVendorGameID != nil {
resp.CurrentVendorGameID = *state.CurrentVendorGameID
}
return resp, nil
}
func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenRequest, rawJSON string) baishunStandardResponse {
if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) {
return s.failStandard(baishunCodeSignatureError, "signature error")
}
var session model.BaishunLaunchSession
if err := s.repo.DB.WithContext(ctx).
Where("launch_code = ?", strings.TrimSpace(req.Code)).
First(&session).Error; err != nil {
resp := s.failStandard(baishunCodeTokenExpired, "code invalid")
s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
if session.Status != baishunSessionCodeIssued || time.Now().After(session.LaunchCodeExpireTime) {
resp := s.failStandard(baishunCodeTokenExpired, "code expired")
s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
if strings.TrimSpace(req.UserID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(req.UserID) {
resp := s.failStandard(baishunCodeTokenExpired, "code invalid")
s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
ssToken, err := s.randomToken(32)
if err != nil {
resp := s.failStandard(baishunCodeServerError, "token create failed")
s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
expire := time.Now().Add(time.Duration(s.cfg.BaishunSSTokenTTLSeconds) * time.Second)
if err := s.repo.DB.WithContext(ctx).
Model(&model.BaishunLaunchSession{}).
Where("id = ?", session.ID).
Updates(map[string]any{
"ss_token": ssToken,
"ss_token_expire_time": expire,
"status": baishunSessionTokenIssued,
"update_time": time.Now(),
}).Error; err != nil {
resp := s.failStandard(baishunCodeServerError, "session update failed")
s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
userInfo := s.readUserInfo(ctx, session.UserID)
balance := s.readUserBalance(ctx, session.UserID)
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{
"ss_token": ssToken,
"expire_date": expire.UnixMilli(),
},
UserInfo: map[string]any{
"user_id": req.UserID,
"user_name": userInfo.UserNickname,
"user_avatar": userInfo.UserAvatar,
"balance": balance,
},
}
s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRequest, rawJSON string) baishunStandardResponse {
if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) {
return s.failStandard(baishunCodeSignatureError, "signature error")
}
session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID)
if !ok {
resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid")
s.saveCallbackLog(ctx, "profile", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
userInfo := s.readUserInfo(ctx, session.UserID)
balance := s.readUserBalance(ctx, session.UserID)
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{
"user_id": req.UserID,
"user_name": userInfo.UserNickname,
"user_avatar": userInfo.UserAvatar,
"balance": balance,
"balance_list": []map[string]any{{"name": "Gold", "currency_type": 0, "currency_amount": balance}},
"user_type": 1,
},
}
_ = s.repo.DB.WithContext(ctx).
Model(&model.BaishunLaunchSession{}).
Where("id = ?", session.ID).
Updates(map[string]any{"status": baishunSessionActive, "update_time": time.Now()}).Error
s.saveCallbackLog(ctx, "profile", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdateTokenRequest, rawJSON string) baishunStandardResponse {
if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) {
return s.failStandard(baishunCodeSignatureError, "signature error")
}
session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID)
if !ok {
resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid")
s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
newToken, err := s.randomToken(32)
if err != nil {
resp := s.failStandard(baishunCodeServerError, "token create failed")
s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
expire := time.Now().Add(time.Duration(s.cfg.BaishunSSTokenTTLSeconds) * time.Second)
if err := s.repo.DB.WithContext(ctx).
Model(&model.BaishunLaunchSession{}).
Where("id = ?", session.ID).
Updates(map[string]any{
"ss_token": newToken,
"ss_token_expire_time": expire,
"status": baishunSessionActive,
"update_time": time.Now(),
}).Error; err != nil {
resp := s.failStandard(baishunCodeServerError, "session update failed")
s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{
"ss_token": newToken,
"expire_date": expire.UnixMilli(),
},
}
s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunChangeBalanceRequest, rawJSON string) baishunStandardResponse {
if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) {
return s.failStandard(baishunCodeSignatureError, "signature error")
}
session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID)
if !ok {
resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid")
s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), "", req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
if req.CurrencyDiff == 0 {
resp := s.failStandard(baishunCodeServerError, "currency_diff invalid")
s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
lockKey := fmt.Sprintf("bs:lock:user:%s:%s", session.SysOrigin, req.UserID)
lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
if err == nil && lockOK {
defer s.repo.Redis.Del(context.Background(), lockKey)
}
var order model.BaishunOrderIdempotency
err = s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND order_id = ?", session.SysOrigin, req.OrderID).
First(&order).Error
if err == nil && order.Status == baishunOrderStatusSuccess && strings.TrimSpace(order.ResponseJSON) != "" {
var resp baishunStandardResponse
if json.Unmarshal([]byte(order.ResponseJSON), &resp) == nil {
s.saveCallbackLog(ctx, "change-balance", rawJSON, order.ResponseJSON, session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
}
walletEventID := fmt.Sprintf("BAISHUN:%s:%s", session.SysOrigin, req.OrderID)
exists, existsErr := s.java.ExistsGoldEvent(ctx, walletEventID)
if existsErr == nil && !exists {
changeErr := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: receiptTypeFromDiff(req.CurrencyDiff),
UserID: session.UserID,
SysOrigin: session.SysOrigin,
EventID: walletEventID,
Remark: defaultIfBlank(req.DiffMsg, req.MsgType),
Amount: abs64(req.CurrencyDiff),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: fmt.Sprintf("BAISHUN_GAME_%d", req.GameID),
CustomizeOriginDesc: fmt.Sprintf("BAISHUN GAME[%d]", req.GameID),
})
if changeErr != nil {
message := changeErr.Error()
code := baishunCodeServerError
if strings.Contains(strings.ToLower(message), "insufficient") || strings.Contains(message, "5000") {
code = baishunCodeInsufficientAssets
}
resp := s.failStandard(code, message)
s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusFailed, rawJSON, mustJSONString(resp), nil)
s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
}
balance := s.readUserBalance(ctx, session.UserID)
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{
"currency_balance": balance,
},
}
s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusSuccess, rawJSON, mustJSONString(resp), nil)
s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
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, mustJSONString(resp), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse {
if !s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp) {
return baishunBalanceInfoResponse{Code: baishunCodeSignatureError, Msg: "signature error"}
}
userID, _ := strconv.ParseInt(req.UserID, 10, 64)
balance := s.readUserBalance(ctx, userID)
resp := baishunBalanceInfoResponse{
Code: 0,
Msg: "success",
Data: map[string]any{"cur_coin": balance},
}
s.saveCallbackLog(ctx, "balance-info", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Msg)
return resp
}
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) {
var rows []gameListRow
query := s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Select(`
cfg.id AS config_id,
cfg.sys_origin AS sys_origin,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
cfg.cover AS cover,
cfg.sort AS sort,
cfg.full_screen AS full_screen,
cfg.game_mode AS game_mode_raw,
ext.vendor_type AS vendor_type,
ext.vendor_game_id AS vendor_game_id,
ext.launch_mode AS launch_mode,
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package_version,
cat.orientation AS catalog_orientation,
cat.safe_height AS catalog_safe_height,
cat.status AS catalog_status
`).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1").
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType)
if strings.TrimSpace(category) != "" {
query = query.Where("cfg.category = ?", category)
}
if strings.TrimSpace(roomID) != "" {
query = query.Where("cfg.game_mode LIKE ?", "%CHAT_ROOM%")
}
if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]RoomGameListItem, 0, len(rows))
for _, row := range rows {
items = append(items, row.toListItem())
}
if len(items) > 0 {
return items, nil
}
var catalogs []model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled).
Order("vendor_game_id ASC").
Find(&catalogs).Error; err != nil {
return nil, err
}
for _, catalog := range catalogs {
gameID := defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID))
items = append(items, RoomGameListItem{
GameID: gameID,
VendorType: baishunVendorType,
VendorGameID: catalog.VendorGameID,
Name: catalog.Name,
Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL),
Category: "CHAT_ROOM",
Sort: int64(catalog.VendorGameID),
LaunchMode: baishunLaunchModeRemote,
FullScreen: true,
GameMode: pickFirstInt(catalog.GameModeJSON, 3),
SafeHeight: catalog.SafeHeight,
Orientation: derefInt(catalog.Orientation),
PackageVersion: catalog.PackageVersion,
Status: catalog.Status,
})
}
sort.Slice(items, func(i, j int) bool {
if items[i].Sort == items[j].Sort {
return items[i].VendorGameID < items[j].VendorGameID
}
return items[i].Sort > items[j].Sort
})
return items, nil
}
func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID string) (gameListRow, error) {
var row gameListRow
err := s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Select(`
cfg.id AS config_id,
cfg.sys_origin AS sys_origin,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
cfg.cover AS cover,
cfg.sort AS sort,
cfg.full_screen AS full_screen,
cfg.game_mode AS game_mode_raw,
ext.vendor_type AS vendor_type,
ext.vendor_game_id AS vendor_game_id,
ext.launch_mode AS launch_mode,
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package_version,
cat.orientation AS catalog_orientation,
cat.safe_height AS catalog_safe_height,
cat.status AS catalog_status
`).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1").
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.game_id = ? AND ext.vendor_type = ?", sysOrigin, gameID, baishunVendorType).
Limit(1).
Scan(&row).Error
if err != nil {
return gameListRow{}, err
}
if strings.TrimSpace(row.InternalGameID) != "" {
return row, nil
}
var catalog model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID).
First(&catalog).Error; err == nil {
return gameListRow{
SysOrigin: sysOrigin,
InternalGameID: catalog.InternalGameID,
Name: catalog.Name,
Cover: catalog.Cover,
VendorType: baishunVendorType,
VendorGameID: strconv.Itoa(catalog.VendorGameID),
LaunchMode: baishunLaunchModeRemote,
CatalogCover: catalog.Cover,
CatalogPreviewURL: catalog.PreviewURL,
CatalogDownloadURL: catalog.DownloadURL,
CatalogPackageVersion: catalog.PackageVersion,
CatalogSafeHeight: catalog.SafeHeight,
CatalogOrientation: catalog.Orientation,
CatalogStatus: catalog.Status,
GameModeRaw: catalog.GameModeJSON,
}, nil
}
return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found")
}
func (s *BaishunService) fetchPlatformGames(ctx context.Context) ([]baishunPlatformGame, error) {
timestamp := time.Now().Unix()
nonce := s.randomNonce()
payload := map[string]any{
"app_channel": s.cfg.BaishunAppChannel,
"app_id": s.cfg.BaishunAppID,
"signature": s.signBAISHUN(timestamp, nonce),
"signature_nonce": nonce,
"timestamp": timestamp,
}
if strings.TrimSpace(s.cfg.BaishunAppName) != "" {
payload["app_name"] = s.cfg.BaishunAppName
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.BaishunPlatformBaseURL, "/")+"/v1/api/gamelist", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("baishun platform failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var result baishunPlatformResponse
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
if result.Code != 0 {
return nil, fmt.Errorf("baishun platform failed: code=%d message=%s", result.Code, result.Message)
}
return result.Data, nil
}
func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) bool {
if s.cfg.BaishunAppID != 0 && appID != 0 && appID != s.cfg.BaishunAppID {
return false
}
if strings.TrimSpace(appChannel) != "" && s.cfg.BaishunAppChannel != "" && appChannel != s.cfg.BaishunAppChannel {
return false
}
if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(s.cfg.BaishunAppKey) == "" {
return false
}
if abs64(time.Now().Unix()-timestamp) > 15 {
return false
}
expected := s.signBAISHUN(timestamp, nonce)
if !strings.EqualFold(expected, signature) {
return false
}
return s.rememberNonce(ctx, nonce)
}
func (s *BaishunService) rememberNonce(ctx context.Context, nonce string) bool {
ok, err := s.repo.Redis.SetNX(ctx, "bs:sign:nonce:"+nonce, "1", 15*time.Second).Result()
return err == nil && ok
}
func (s *BaishunService) signBAISHUN(timestamp int64, nonce string) string {
sum := md5.Sum([]byte(nonce + s.cfg.BaishunAppKey + strconv.FormatInt(timestamp, 10)))
return hex.EncodeToString(sum[:])
}
func (s *BaishunService) randomNonce() string {
token, err := s.randomToken(8)
if err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return token
}
func (s *BaishunService) randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func (s *BaishunService) uniqueID() string {
id, err := util.NextID()
if err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
return strconv.FormatInt(id, 10)
}
func (s *BaishunService) readUserInfo(ctx context.Context, userID int64) integration.UserProfile {
profile, err := s.java.GetUserProfile(ctx, userID)
if err != nil {
return integration.UserProfile{ID: integration.Int64Value(userID)}
}
return profile
}
func (s *BaishunService) readUserBalance(ctx context.Context, userID int64) int64 {
balanceMap, err := s.java.MapGoldBalance(ctx, []int64{userID})
if err != nil {
return 0
}
return balanceMap[userID]
}
func (s *BaishunService) resolveLanguage(ctx context.Context, userID int64) string {
language, err := s.java.GetUserLanguage(ctx, userID)
if err != nil || strings.TrimSpace(language) == "" {
return "0"
}
return language
}
func (s *BaishunService) findSessionByToken(ctx context.Context, ssToken, userID string) (model.BaishunLaunchSession, bool) {
var session model.BaishunLaunchSession
err := s.repo.DB.WithContext(ctx).
Where("ss_token = ?", strings.TrimSpace(ssToken)).
First(&session).Error
if err != nil || time.Now().After(derefTime(session.SSTokenExpireTime)) {
return model.BaishunLaunchSession{}, false
}
if strings.TrimSpace(userID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(userID) {
return model.BaishunLaunchSession{}, false
}
return session, true
}
func (s *BaishunService) persistOrderRecord(ctx context.Context, sysOrigin string, req BaishunChangeBalanceRequest, walletEventID, status, requestJSON, responseJSON string, assetRecordID *int64) {
id, err := util.NextID()
if err != nil {
return
}
record := model.BaishunOrderIdempotency{
ID: id,
SysOrigin: sysOrigin,
OrderID: req.OrderID,
GameRoundID: req.GameRoundID,
RoomID: req.RoomID,
UserID: parseInt64Default(req.UserID),
VendorGameID: req.GameID,
CurrencyDiff: req.CurrencyDiff,
DiffMsg: req.DiffMsg,
MsgType: req.MsgType,
CurrencyType: req.CurrencyType,
WalletEventID: walletEventID,
WalletAssetRecordID: assetRecordID,
Status: status,
RequestJSON: requestJSON,
ResponseJSON: responseJSON,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
_ = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "order_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"wallet_event_id", "wallet_asset_record_id", "status", "request_json", "response_json", "update_time",
}),
}).Create(&record).Error
}
func (s *BaishunService) saveCallbackLog(ctx context.Context, requestType, requestJSON, responseJSON, sysOrigin, userID string, orderID, gameRoundID *string, status string, bizCode int, bizMessage string) {
id, err := util.NextID()
if err != nil {
return
}
logRecord := model.BaishunCallbackLog{
ID: id,
SysOrigin: sysOrigin,
RequestType: requestType,
RequestJSON: requestJSON,
ResponseJSON: responseJSON,
BizCode: strconv.Itoa(bizCode),
BizMessage: bizMessage,
Status: status,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if orderID != nil {
logRecord.OrderID = *orderID
}
if gameRoundID != nil {
logRecord.GameRoundID = *gameRoundID
}
if strings.TrimSpace(userID) != "" {
parsed := parseInt64Default(userID)
logRecord.UserID = &parsed
}
_ = s.repo.DB.WithContext(ctx).Create(&logRecord).Error
}
func (s *BaishunService) failStandard(code int, message string) baishunStandardResponse {
return baishunStandardResponse{
Code: code,
Message: defaultIfBlank(message, "failed"),
UniqueID: s.uniqueID(),
}
}
func (r gameListRow) toListItem() RoomGameListItem {
return RoomGameListItem{
GameID: r.InternalGameID,
VendorType: defaultIfBlank(r.VendorType, baishunVendorType),
VendorGameID: r.vendorGameIDInt(),
Name: r.name(),
Cover: r.cover(),
Category: r.Category,
Sort: r.Sort,
LaunchMode: r.launchMode(),
FullScreen: r.FullScreen,
GameMode: r.gameMode(),
SafeHeight: r.safeHeight(),
Orientation: r.orientation(),
PackageVersion: r.packageVersion(),
Status: defaultIfBlank(r.CatalogStatus, baishunCatalogEnabled),
}
}
func (r gameListRow) name() string {
return defaultIfBlank(r.Name, r.CatalogName)
}
func (r gameListRow) cover() string {
return defaultIfBlank(r.Cover, defaultIfBlank(r.CatalogCover, r.previewURL()))
}
func (r gameListRow) previewURL() string {
return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL)
}
func (r gameListRow) downloadURL() string {
return defaultIfBlank(r.PackageURL, r.CatalogDownloadURL)
}
func (r gameListRow) entryURL() string {
switch r.launchMode() {
case baishunLaunchModeLocalZip:
if strings.TrimSpace(r.PackageURL) != "" {
return r.PackageURL
}
}
if strings.TrimSpace(r.CatalogDownloadURL) != "" {
return r.CatalogDownloadURL
}
return defaultIfBlank(r.PackageURL, defaultIfBlank(r.ExtPreviewURL, "mock://baishun"))
}
func (r gameListRow) packageVersion() string {
return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion)
}
func (r gameListRow) safeHeight() int {
if r.ExtSafeHeight > 0 {
return r.ExtSafeHeight
}
return r.CatalogSafeHeight
}
func (r gameListRow) orientation() int {
if r.ExtOrientation != nil {
return *r.ExtOrientation
}
return derefInt(r.CatalogOrientation)
}
func (r gameListRow) launchMode() string {
return defaultIfBlank(r.LaunchMode, baishunLaunchModeRemote)
}
func (r gameListRow) gameMode() int {
if parsed := pickFirstInt(r.GameModeRaw, 3); parsed > 0 {
return parsed
}
return 3
}
func (r gameListRow) vendorGameIDInt() int {
parsed, _ := strconv.Atoi(strings.TrimSpace(r.VendorGameID))
return parsed
}
func (r gameListRow) currencyIcon() string {
return r.CurrencyIcon
}
func (s *BaishunService) resolveGSP(row gameListRow) int {
if parsed, err := strconv.Atoi(strings.TrimSpace(row.ExtGSP)); err == nil && parsed > 0 {
return parsed
}
if s.cfg.BaishunGSP > 0 {
return s.cfg.BaishunGSP
}
return 101
}
func defaultIfBlank(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func pickFirstInt(raw string, fallback int) int {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback
}
var list []int
if strings.HasPrefix(raw, "[") {
if json.Unmarshal([]byte(raw), &list) == nil && len(list) > 0 {
return list[len(list)-1]
}
}
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
return parsed
}
return fallback
}
func receiptTypeFromDiff(diff int64) string {
if diff >= 0 {
return "INCOME"
}
return "EXPENDITURE"
}
func mustJSONString(value interface{}) string {
data, err := json.Marshal(value)
if err != nil {
return ""
}
return string(data)
}
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
func parseInt64Default(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}
func sanitizeRoomID(roomID string) string {
roomID = strings.ReplaceAll(strings.TrimSpace(roomID), " ", "_")
if roomID == "" {
return "room"
}
return roomID
}
func intPtr(value int) *int {
return &value
}
func derefInt(value *int) int {
if value == nil {
return 0
}
return *value
}
func derefTime(value *time.Time) time.Time {
if value == nil {
return time.Unix(0, 0)
}
return *value
}
func stringPtr(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
return &value
}
func toString(value any) string {
if value == nil {
return ""
}
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
default:
return fmt.Sprint(typed)
}
}