2026-05-20 18:07:33 +08:00

284 lines
11 KiB
Go

package game
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const (
leaderccCodeOK = 0
leaderccCodeTimeout = 4000
leaderccCodeInsufficientBalance = 4004
leaderccCodeInvalidArgument = 4005
leaderccCodeTokenInvalid = 10001
leaderccCodeRiskRejected = 10002
leaderccCodeDuplicatedOrder = 10003
leaderccCodeSignatureInvalid = 10004
leaderccDefaultTokenTTL = 24 * time.Hour
)
type leaderccAdapterConfig struct {
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
}
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
raw = typed.AdapterConfigJSON
case gamedomain.LaunchableGame:
raw = typed.AdapterConfigJSON
}
var config leaderccAdapterConfig
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
return config
}
func (c leaderccAdapterConfig) TokenTTL() time.Duration {
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
return leaderccDefaultTokenTTL
}
func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
raw, contentType := leaderccJSON(leaderccCodeRiskRejected, "ip forbidden", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeRiskRejected), false, "", nil
}
switch operation {
case "userinfo", "user_info", "get_user_info":
return s.handleLeaderCCUserInfo(ctx, app, platform, req)
case "change_coin", "change_balance", "update_coin":
return s.handleLeaderCCChangeCoin(ctx, app, platform, req, requestHash)
case "repair_order", "repair", "repair_coin":
return s.handleLeaderCCRepairOrder(ctx, app, platform, req)
default:
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), false, "", nil
}
}
func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
var body struct {
GameID string `json:"gameId"`
UID string `json:"uid"`
Token string `json:"token"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
}
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.GameID, body.UID, body.Token, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, "")
}
config := leaderccConfigFromPlatform(platform)
session, err := s.validLeaderCCSession(ctx, app, body.Token)
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, "")
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{
"uid": body.UID,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
"coin": snapshot.Balance,
})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, "", nil
}
func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) {
var body struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
RoundID string `json:"roundId"`
UID string `json:"uid"`
Coin int64 `json:"coin"`
Type int32 `json:"type"`
RewardType int32 `json:"rewardType"`
Token string `json:"token"`
WinID string `json:"winId"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
}
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.Type), 10), strconv.FormatInt(int64(body.RewardType), 10), body.Token, body.WinID, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID)
}
config := leaderccConfigFromPlatform(platform)
session, err := s.validLeaderCCSession(ctx, app, body.Token)
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
opType := leaderccOpType(body.Type)
if providerOrderID == "" || opType == "" || body.Coin < 0 {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID)
}
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.RoundID, opType, body.Coin, body.RoomID, requestHash)
if err != nil {
code := leaderccCodeFromError(err)
message := leaderccMessageFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
raw, contentType := leaderccJSON(code, message, map[string]any{"coin": balance})
return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{"coin": result.BalanceAfter})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
var body struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
RoundID string `json:"roundId"`
UID string `json:"uid"`
Coin int64 `json:"coin"`
RewardType int32 `json:"rewardType"`
WinID string `json:"winId"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
}
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.RewardType), 10), body.WinID, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID)
}
if providerOrderID == "" || strings.TrimSpace(body.GameID) == "" || strings.TrimSpace(body.UID) == "" || body.Coin < 0 {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID)
}
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
AppCode: app,
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID),
PlatformCode: platform.PlatformCode,
ProviderOrderID: providerOrderID,
Reason: "leadercc_repair_order",
Status: gamedomain.RepairStatusLogged,
})
if err != nil {
return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, providerOrderID)
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
session, err := s.validYomiSession(ctx, app, token)
if err != nil {
return gamedomain.LaunchSession{}, err
}
return session, nil
}
func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAdapterConfig, uid string, gameID string) bool {
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
return strings.TrimSpace(uid) == externalUserID(session, config.UIDMode)
}
func leaderccOpType(value int32) string {
switch value {
case 1:
return "debit"
case 2:
return "credit"
default:
return ""
}
}
func decodeLeaderCCJSON(raw []byte, out any) error {
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
return decoder.Decode(out)
}
func leaderccSignValid(actual string, key string, parts ...string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
joined := strings.Builder{}
for _, part := range parts {
joined.WriteString(strings.TrimSpace(part))
}
joined.WriteString(key)
sum := md5.Sum([]byte(joined.String()))
expected := hex.EncodeToString(sum[:])
return strings.EqualFold(strings.TrimSpace(actual), expected)
}
func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
raw, contentType := leaderccJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}
func leaderccJSON(errorCode int, message string, data any) ([]byte, string) {
payload := map[string]any{"errorCode": errorCode}
if data != nil {
payload["data"] = data
}
if strings.TrimSpace(message) != "" {
payload["message"] = message
}
return jsonResponse(payload)
}
func leaderccCodeFromError(err error) int {
switch {
case err == nil:
return leaderccCodeOK
case xerr.IsCode(err, xerr.InsufficientBalance):
return leaderccCodeInsufficientBalance
case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
return leaderccCodeTokenInvalid
case xerr.IsCode(err, xerr.IdempotencyConflict):
return leaderccCodeDuplicatedOrder
case xerr.IsCode(err, xerr.Unavailable):
return leaderccCodeTimeout
default:
return leaderccCodeInvalidArgument
}
}
func leaderccMessageFromError(err error) string {
switch leaderccCodeFromError(err) {
case leaderccCodeInsufficientBalance:
return "coin not enough"
case leaderccCodeTokenInvalid:
return "token invalid"
case leaderccCodeDuplicatedOrder:
return "duplicated order"
case leaderccCodeTimeout:
return "network timeout"
default:
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
return xerr.MessageOf(err)
}
return "invalid argument"
}
}