2026-05-22 14:22:27 +08:00

427 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package baishun
import (
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/utils"
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
"gorm.io/gorm/clause"
)
// HandleToken 处理百顺获取启动 code/token 的回调。
func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenRequest, rawJSON string) baishunStandardResponse {
runtimeCfg, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp)
if !ok {
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, utils.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, utils.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, utils.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, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
expire := time.Now().Add(time.Duration(runtimeCfg.ssTokenTTLSeconds()) * 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, utils.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, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
// HandleProfile 处理百顺获取用户资料的回调。
func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRequest, rawJSON string) baishunStandardResponse {
if _, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp); !ok {
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, utils.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, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
// HandleUpdateToken 处理百顺刷新 ss_token 的回调。
func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdateTokenRequest, rawJSON string) baishunStandardResponse {
runtimeCfg, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp)
if !ok {
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, utils.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, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message)
return resp
}
expire := time.Now().Add(time.Duration(runtimeCfg.ssTokenTTLSeconds()) * 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, utils.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, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
// HandleChangeBalance 处理百顺扣增币回调,并驱动钱包变更。
func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunChangeBalanceRequest, rawJSON string) baishunStandardResponse {
if _, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp); !ok {
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, utils.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, utils.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: integration.NewPennyAmountPayloadFromDollar(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, utils.MustJSONString(resp, ""), nil)
s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.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, utils.MustJSONString(resp, ""), nil)
s.saveCallbackLog(ctx, "change-balance", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message)
s.reportGameConsumeTaskEvent(ctx, session, req)
return resp
}
func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) {
if s.taskReporter == nil || req.CurrencyDiff >= 0 {
return
}
deltaValue := abs64(req.CurrencyDiff)
if deltaValue <= 0 {
return
}
payload := map[string]any{
"provider": "BAISHUN",
"orderId": strings.TrimSpace(req.OrderID),
"gameId": req.GameID,
"gameRoundId": strings.TrimSpace(req.GameRoundID),
"msgType": strings.TrimSpace(req.MsgType),
"diffMsg": strings.TrimSpace(req.DiffMsg),
}
if err := s.taskReporter.ReportSimpleEvent(ctx, session.SysOrigin, fmt.Sprintf("BAISHUN_GAME_CONSUME:%s:%s", session.SysOrigin, strings.TrimSpace(req.OrderID)), taskcenter.EventTypeGameConsumeGold, session.UserID, deltaValue, payload); err != nil {
log.Printf("report baishun task event failed: %v", err)
}
}
// HandleBalanceInfo 处理百顺查询余额回调。
func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse {
if _, ok := s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp); !ok {
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, utils.MustJSONString(resp, ""), "", req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Msg)
return resp
}
// validateCommonSignature 校验百顺回调签名。
func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) (baishunRuntimeConfig, bool) {
runtimeCfg, err := s.resolveRuntimeConfigByApp(ctx, appID, appChannel)
if err != nil {
return baishunRuntimeConfig{}, false
}
if runtimeCfg.AppID != 0 && appID != 0 && appID != runtimeCfg.AppID {
return baishunRuntimeConfig{}, false
}
if strings.TrimSpace(appChannel) != "" && runtimeCfg.appChannel() != "" && appChannel != runtimeCfg.appChannel() {
return baishunRuntimeConfig{}, false
}
if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(runtimeCfg.AppKey) == "" {
return baishunRuntimeConfig{}, false
}
if abs64(time.Now().Unix()-timestamp) > 15 {
return baishunRuntimeConfig{}, false
}
expected := signBAISHUN(runtimeCfg.AppKey, timestamp, nonce)
if !strings.EqualFold(expected, signature) {
return baishunRuntimeConfig{}, false
}
if !s.rememberNonce(ctx, nonce) {
return baishunRuntimeConfig{}, false
}
return runtimeCfg, true
}
// rememberNonce 记录已用 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
}
// signBAISHUN 按百顺约定生成签名。
func signBAISHUN(appKey string, timestamp int64, nonce string) string {
sum := md5.Sum([]byte(nonce + appKey + strconv.FormatInt(timestamp, 10)))
return hex.EncodeToString(sum[:])
}
// randomNonce 生成随机 nonce。
func (s *BaishunService) randomNonce() string {
token, err := s.randomToken(8)
if err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return token
}
// randomToken 生成指定长度的随机 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
}
// uniqueID 生成一条可返回给百顺的唯一 ID。
func (s *BaishunService) uniqueID() string {
id, err := utils.NextID()
if err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
return strconv.FormatInt(id, 10)
}
// persistOrderRecord 持久化扣增币回调的幂等记录。
func (s *BaishunService) persistOrderRecord(ctx context.Context, sysOrigin string, req BaishunChangeBalanceRequest, walletEventID, status, requestJSON, responseJSON string, assetRecordID *int64) {
id, err := utils.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
}
// saveCallbackLog 保存百顺回调审计日志。
func (s *BaishunService) saveCallbackLog(ctx context.Context, requestType, requestJSON, responseJSON, sysOrigin, userID string, orderID, gameRoundID *string, status string, bizCode int, bizMessage string) {
id, err := utils.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
}
err = s.repo.DB.WithContext(ctx).Create(&logRecord).Error
if err != nil && isBizMessageTooLongError(err) {
logRecord.BizMessage = truncateRunes(logRecord.BizMessage, legacyBizMessageRuneLimit)
_ = s.repo.DB.WithContext(ctx).Create(&logRecord).Error
}
}
// failStandard 构造百顺标准失败回包。
func (s *BaishunService) failStandard(code int, message string) baishunStandardResponse {
return baishunStandardResponse{
Code: code,
Message: defaultIfBlank(message, "failed"),
UniqueID: s.uniqueID(),
}
}