284 lines
11 KiB
Go
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"
|
|
}
|
|
}
|