package gameopen import ( "context" "encoding/json" "fmt" "log" "strings" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/service/taskcenter" ) const ( hotgameVendorType = "HOTGAME" hotgameCodeTokenInvalid = 1001 hotgameCodeInsufficientBalance = 2001 hotgameCodeDuplicateOrder = 3001 hotgameCodeBadRequest = 4001 hotgameCodeSignatureError = 4002 hotgameCodeServerError = 5000 ) // HotgameGetUserInfoRequest 是热游 /getUserInfo 回调入参。 type HotgameGetUserInfoRequest struct { GameID string `json:"gameId"` UID string `json:"uid"` Token string `json:"token"` Sign string `json:"sign"` } // HotgameUpdateBalanceRequest 是热游 /updateBalance 回调入参;roundId 文档是 int,所以这里兼容数字和字符串。 type HotgameUpdateBalanceRequest struct { OrderID string `json:"orderId"` GameID string `json:"gameId"` RoundID any `json:"roundId"` UID string `json:"uid"` Coin int64 `json:"coin"` Type int `json:"type"` Token string `json:"token"` Sign string `json:"sign"` } // HandleHotgameGetUserInfo 按热游协议查询玩家资料和余额。 func (s *GameOpenService) HandleHotgameGetUserInfo(ctx context.Context, req HotgameGetUserInfoRequest, rawJSON string) CallbackResponse { if err := s.validateHotgameGetUserInfoRequest(req); err != nil { resp := failResponse(hotgameCodeBadRequest, err.Error()) s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed) return resp } appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token) if strings.TrimSpace(appKey) == "" { resp := failResponse(hotgameCodeBadRequest, "app key is blank") s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed) return resp } if !verifySign(req.Sign, buildHotgameGetUserInfoSign(req, appKey)) { resp := failResponse(hotgameCodeSignatureError, "signature error") s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed) return resp } req.Token = normalizeCallbackToken(req.Token) credential, err := s.java.AuthenticateToken(ctx, req.Token) if err != nil { resp := failResponse(hotgameCodeTokenInvalid, "token invalid") s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed) return resp } resolved, err := s.resolveUserWithToken(ctx, credential, req.UID) if err != nil { resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch") userID := int64(credential.UserID) s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed) return resp } userID := resolved.userID profile := resolved.profile if int64(profile.ID) == 0 { profile, err = s.java.GetUserProfile(ctx, userID) } if err != nil { resp := failResponse(hotgameCodeServerError, "profile load failed") s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed) return resp } balance, err := s.readUserBalance(ctx, userID) if err != nil { resp := failResponse(hotgameCodeServerError, "balance load failed") s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed) return resp } resp := successResponse(map[string]any{ "uid": req.UID, "nickname": profile.UserNickname, "avatar": profile.UserAvatar, "coin": balance, "vipLevel": 0, }) s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusSuccess) return resp } // HandleHotgameUpdateBalance 按热游协议处理实时扣加币,并用 orderId 做钱包事件幂等。 func (s *GameOpenService) HandleHotgameUpdateBalance(ctx context.Context, req HotgameUpdateBalanceRequest, rawJSON string) CallbackResponse { updateReq := req.toUpdateCoinRequest() if err := s.validateHotgameUpdateBalanceRequest(req); err != nil { resp := failResponse(hotgameCodeBadRequest, err.Error()) s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed) return resp } appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token) if strings.TrimSpace(appKey) == "" { resp := failResponse(hotgameCodeBadRequest, "app key is blank") s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed) return resp } if !verifySign(req.Sign, buildHotgameUpdateBalanceSign(req, appKey)) { resp := failResponse(hotgameCodeSignatureError, "signature error") s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed) return resp } req.Token = normalizeCallbackToken(req.Token) updateReq.Token = req.Token credential, err := s.java.AuthenticateToken(ctx, req.Token) if err != nil { resp := failResponse(hotgameCodeTokenInvalid, "token invalid") s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed) return resp } resolved, err := s.resolveUserWithToken(ctx, credential, req.UID) if err != nil { resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch") userID := int64(credential.UserID) s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed) return resp } userID := resolved.userID eventID := "GAME_HOTGAME:" + strings.TrimSpace(req.OrderID) exists, err := s.java.ExistsGoldEvent(ctx, eventID) if err != nil { resp := failResponse(hotgameCodeServerError, "event check failed") s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed) return resp } if exists { resp := failResponse(hotgameCodeDuplicateOrder, "duplicate orderId") s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed) return resp } if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: receiptTypeFromType(req.Type), UserID: userID, SysOrigin: credential.SysOrigin, EventID: eventID, Remark: fmt.Sprintf("hotgame=%s type=%d round=%s", req.GameID, req.Type, updateReq.RoundID), Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: "GAME_HOTGAME_" + strings.TrimSpace(req.GameID), CustomizeOriginDesc: "HOTGAME[" + strings.TrimSpace(req.GameID) + "]", }); err != nil { // 热游文档要求扣款失败必须返回失败码;支出失败统一归到余额不足类,防止游戏继续开奖。 code := hotgameCodeServerError if req.Type == 1 || looksLikeInsufficientBalance(err) { code = hotgameCodeInsufficientBalance } resp := failResponse(code, err.Error()) s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed) return resp } balance, err := s.readUserBalance(ctx, userID) if err != nil { resp := failResponse(hotgameCodeServerError, "balance load failed") s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed) return resp } resp := successResponse(map[string]any{"coin": balance, "responseId": eventID}) s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusSuccess) s.reportHotgameConsumeTaskEvent(ctx, credential.SysOrigin, userID, updateReq) return resp } func (req HotgameGetUserInfoRequest) toQueryUserRequest() QueryUserRequest { return QueryUserRequest{ GameID: req.GameID, UID: req.UID, Token: req.Token, Sign: req.Sign, } } func (req HotgameUpdateBalanceRequest) toUpdateCoinRequest() UpdateCoinRequest { return UpdateCoinRequest{ OrderID: req.OrderID, GameID: req.GameID, RoundID: hotgameRoundIDString(req.RoundID), UID: req.UID, Coin: req.Coin, Type: req.Type, Token: req.Token, Sign: req.Sign, } } func (s *GameOpenService) validateHotgameGetUserInfoRequest(req HotgameGetUserInfoRequest) error { switch { case strings.TrimSpace(req.GameID) == "": return newBadRequest("gameId is required") case strings.TrimSpace(req.UID) == "": return newBadRequest("uid is required") case strings.TrimSpace(req.Token) == "": return newBadRequest("token is required") case strings.TrimSpace(req.Sign) == "": return newBadRequest("sign is required") default: return nil } } func (s *GameOpenService) validateHotgameUpdateBalanceRequest(req HotgameUpdateBalanceRequest) error { switch { case strings.TrimSpace(req.OrderID) == "": return newBadRequest("orderId is required") case strings.TrimSpace(req.GameID) == "": return newBadRequest("gameId is required") case strings.TrimSpace(hotgameRoundIDString(req.RoundID)) == "": return newBadRequest("roundId is required") case strings.TrimSpace(req.UID) == "": return newBadRequest("uid is required") case req.Coin <= 0: return newBadRequest("coin is required") case req.Type != 1 && req.Type != 2: return newBadRequest("type must be 1 or 2") case strings.TrimSpace(req.Token) == "": return newBadRequest("token is required") case strings.TrimSpace(req.Sign) == "": return newBadRequest("sign is required") default: return nil } } func buildHotgameGetUserInfoSign(req HotgameGetUserInfoRequest, key string) string { return md5Hex(req.GameID + req.UID + req.Token + key) } func buildHotgameUpdateBalanceSign(req HotgameUpdateBalanceRequest, key string) string { return md5Hex( req.OrderID + req.GameID + hotgameRoundIDString(req.RoundID) + req.UID + fmt.Sprintf("%d", req.Coin) + fmt.Sprintf("%d", req.Type) + req.Token + key, ) } func hotgameRoundIDString(value any) string { switch typed := value.(type) { case nil: return "" case string: return strings.TrimSpace(typed) case json.Number: return strings.TrimSpace(typed.String()) case float64: if typed == float64(int64(typed)) { return fmt.Sprintf("%d", int64(typed)) } return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", typed), "0"), ".") default: return strings.TrimSpace(fmt.Sprint(typed)) } } func (s *GameOpenService) resolveCallbackAppKeyForVendor(ctx context.Context, vendorType string, gameID, token string) string { if s != nil && s.repo.DB != nil { if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" { if appKey := s.resolveActiveProviderAppKey(ctx, vendorType, sysOrigin); appKey != "" { return appKey } } if appKey := s.resolveProviderAppKeyByGame(ctx, vendorType, gameID); appKey != "" { return appKey } } if s == nil { return "" } return strings.TrimSpace(s.cfg.GameOpen.AppKey) } func (s *GameOpenService) resolveProviderAppKeyByGame(ctx context.Context, vendorType string, gameID string) string { gameID = strings.TrimSpace(gameID) if gameID == "" { return "" } switch strings.ToUpper(strings.TrimSpace(vendorType)) { case "LINGXIAN": return s.resolveLingxianAppKeyByGame(ctx, gameID) case hotgameVendorType: var row struct { AppKey string } err := s.repo.DB.WithContext(ctx). Table("hotgame_provider_config AS cfg"). Select("cfg.app_key AS app_key"). Joins("JOIN sys_game_list_vendor_ext ext ON ext.sys_origin = cfg.sys_origin AND ext.profile = cfg.profile AND ext.vendor_type = ? AND ext.enabled = ?", hotgameVendorType, true). Where("cfg.active = ? AND ext.vendor_game_id = ?", true, gameID). Order("cfg.update_time DESC"). Limit(1). Scan(&row).Error if err != nil { return "" } return strings.TrimSpace(row.AppKey) default: return "" } } func (s *GameOpenService) resolveActiveProviderAppKey(ctx context.Context, vendorType string, sysOrigin string) string { switch strings.ToUpper(strings.TrimSpace(vendorType)) { case "LINGXIAN": return s.resolveActiveLingxianAppKey(ctx, sysOrigin) case hotgameVendorType: sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) if sysOrigin == "" { return "" } var row model.HotgameProviderConfig err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND active = ?", sysOrigin, true). Order("update_time DESC"). Limit(1). First(&row).Error if err != nil { return "" } return strings.TrimSpace(row.AppKey) default: return "" } } func looksLikeInsufficientBalance(err error) bool { if err == nil { return false } message := strings.ToLower(err.Error()) return strings.Contains(message, "insufficient") || strings.Contains(message, "balance not made") || strings.Contains(message, "余额不足") } func (s *GameOpenService) reportHotgameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) { if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" { return } deltaValue := req.Coin if deltaValue < 0 { deltaValue = -deltaValue } if deltaValue <= 0 { return } payload := map[string]any{ "provider": hotgameVendorType, "orderId": strings.TrimSpace(req.OrderID), "gameId": strings.TrimSpace(req.GameID), "roundId": strings.TrimSpace(req.RoundID), } if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_HOTGAME_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil { log.Printf("report hotgame task event failed: %v", err) } }