426 lines
16 KiB
Go
426 lines
16 KiB
Go
package game
|
||
|
||
import (
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
gamev1 "hyapp.local/api/proto/game/v1"
|
||
"hyapp/pkg/xerr"
|
||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||
)
|
||
|
||
const (
|
||
// ZeeOne 文档定义 code=0 成功;HTTP 200 不代表业务成功,游戏侧读取 JSON code。
|
||
zeeoneCodeOK = 0
|
||
zeeoneCodeInvalidArgument = 1
|
||
zeeoneCodeSessionNotFound = 2
|
||
zeeoneCodeSignatureInvalid = 3
|
||
zeeoneCodeSessionInvalid = 5
|
||
zeeoneCodeInsufficientBalance = 6
|
||
|
||
zeeoneDefaultTokenTTL = 24 * time.Hour
|
||
zeeoneSignatureValidWindow = 15 * time.Second
|
||
zeeoneDefaultSuccessMessage = "succeed"
|
||
)
|
||
|
||
type zeeoneAdapterConfig struct {
|
||
// merchant_id/platform_id 来自 ZeeOne 商户后台;启动 URL 和 appId 校验都依赖这两个值。
|
||
MerchantID int64 `json:"merchant_id"`
|
||
PlatformID int64 `json:"platform_id"`
|
||
PlatformIDAlias int64 `json:"platform"`
|
||
AppID string `json:"app_id"`
|
||
UIDMode string `json:"uid_mode"`
|
||
DefaultLang string `json:"default_lang"`
|
||
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
||
GameMode string `json:"game_mode"`
|
||
Extra string `json:"extra"`
|
||
// game_urls 解决同一个 ZeeOne 商户下每款游戏 H5 域名/路径不同的问题;后台改 JSON 后下一次启动即生效。
|
||
GameURLs map[string]string `json:"game_urls"`
|
||
// launch_url_template 适合 ZeeOne 后续按固定路径批量上新,支持 {provider_game_id}/{providerGameId}/{game_id}/{gameId} 占位。
|
||
LaunchURLTemplate string `json:"launch_url_template"`
|
||
}
|
||
|
||
func zeeoneConfigFromPlatform(value any) zeeoneAdapterConfig {
|
||
var raw string
|
||
switch typed := value.(type) {
|
||
case gamedomain.Platform:
|
||
raw = typed.AdapterConfigJSON
|
||
case gamedomain.LaunchableGame:
|
||
raw = typed.AdapterConfigJSON
|
||
}
|
||
var config zeeoneAdapterConfig
|
||
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
||
if config.PlatformID == 0 {
|
||
config.PlatformID = config.PlatformIDAlias
|
||
}
|
||
config.AppID = strings.TrimSpace(config.AppID)
|
||
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
||
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
||
config.GameMode = strings.TrimSpace(config.GameMode)
|
||
config.Extra = strings.TrimSpace(config.Extra)
|
||
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
|
||
config.GameURLs = normalizeStringMap(config.GameURLs)
|
||
return config
|
||
}
|
||
|
||
func (c zeeoneAdapterConfig) TokenTTL() time.Duration {
|
||
if c.TokenTTLSeconds > 0 {
|
||
return time.Duration(c.TokenTTLSeconds) * time.Second
|
||
}
|
||
return zeeoneDefaultTokenTTL
|
||
}
|
||
|
||
func (c zeeoneAdapterConfig) ExpectedAppID() string {
|
||
if c.AppID != "" {
|
||
return c.AppID
|
||
}
|
||
if c.MerchantID > 0 && c.PlatformID > 0 {
|
||
return fmt.Sprintf("M%d_P%d", c.MerchantID, c.PlatformID)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func zeeoneLaunchBaseURL(game gamedomain.LaunchableGame, config zeeoneAdapterConfig) string {
|
||
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
|
||
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
|
||
return raw
|
||
}
|
||
}
|
||
if config.LaunchURLTemplate == "" {
|
||
return ""
|
||
}
|
||
replacer := strings.NewReplacer(
|
||
"{provider_game_id}", strings.TrimSpace(game.ProviderGameID),
|
||
"{providerGameId}", strings.TrimSpace(game.ProviderGameID),
|
||
"{game_id}", strings.TrimSpace(game.GameID),
|
||
"{gameId}", strings.TrimSpace(game.GameID),
|
||
)
|
||
return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate))
|
||
}
|
||
|
||
func normalizeStringMap(input map[string]string) map[string]string {
|
||
if len(input) == 0 {
|
||
return nil
|
||
}
|
||
output := make(map[string]string, len(input))
|
||
for key, value := range input {
|
||
key = strings.TrimSpace(key)
|
||
value = strings.TrimSpace(value)
|
||
if key != "" && value != "" {
|
||
output[key] = value
|
||
}
|
||
}
|
||
if len(output) == 0 {
|
||
return nil
|
||
}
|
||
return output
|
||
}
|
||
|
||
func (s *Service) handleZeeOneOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
|
||
config := zeeoneConfigFromPlatform(platform)
|
||
body, signed, decoded, providerRequestID := s.decodeZeeOneSignedBody(platform, req)
|
||
if !signed {
|
||
raw, contentType := zeeoneJSON(zeeoneCodeSignatureInvalid, "signature invalid", map[string]any{})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeSignatureInvalid), false, providerRequestID, nil
|
||
}
|
||
if !decoded {
|
||
raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid argument", map[string]any{})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil
|
||
}
|
||
if err := validateZeeOneAppID(config, body.AppID); err != nil {
|
||
raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid appId", map[string]any{})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil
|
||
}
|
||
switch operation {
|
||
case "get_user_info", "userinfo", "user_info":
|
||
return s.handleZeeOneUserInfo(ctx, app, config, req, body)
|
||
case "change_balance", "change_coin", "update_coin":
|
||
return s.handleZeeOneChangeBalance(ctx, app, config, req, body, requestHash)
|
||
case "update_session", "refresh_session":
|
||
return s.handleZeeOneUpdateSession(ctx, app, config, body)
|
||
case "repair_order", "repair":
|
||
return s.handleZeeOneRepairOrder(ctx, app, platform, body)
|
||
default:
|
||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), signed, providerRequestID, nil
|
||
}
|
||
}
|
||
|
||
type zeeoneSignedEnvelope struct {
|
||
ReqBody string `json:"reqbody"`
|
||
Signature string `json:"signature"`
|
||
SignatureNonce string `json:"signature_nonce"`
|
||
Timestamp int64 `json:"timestamp"`
|
||
}
|
||
|
||
type zeeoneBusinessBody struct {
|
||
AppID string `json:"appId"`
|
||
UserID string `json:"userId"`
|
||
SessionID string `json:"sessionId"`
|
||
Extra string `json:"extra"`
|
||
CurrencyDiff int64 `json:"currency_diff"`
|
||
DiffMsg string `json:"diff_msg"`
|
||
GameID int64 `json:"game_id"`
|
||
RoomID string `json:"room_id"`
|
||
ChangeTimeAt int64 `json:"change_time_at"`
|
||
OrderID string `json:"order_id"`
|
||
Extend string `json:"extend"`
|
||
}
|
||
|
||
func (s *Service) decodeZeeOneSignedBody(platform gamedomain.Platform, req *gamev1.CallbackRequest) (zeeoneBusinessBody, bool, bool, string) {
|
||
var envelope zeeoneSignedEnvelope
|
||
if err := json.Unmarshal(req.GetRawBody(), &envelope); err != nil {
|
||
return zeeoneBusinessBody{}, false, false, ""
|
||
}
|
||
envelope.ReqBody = strings.TrimSpace(envelope.ReqBody)
|
||
providerRequestID := zeeoneProviderRequestID(envelope.ReqBody)
|
||
if !s.zeeoneSignatureValid(envelope, platform.CallbackSecretCiphertext) {
|
||
return zeeoneBusinessBody{}, false, false, providerRequestID
|
||
}
|
||
var body zeeoneBusinessBody
|
||
decoder := json.NewDecoder(strings.NewReader(envelope.ReqBody))
|
||
decoder.UseNumber()
|
||
if err := decoder.Decode(&body); err != nil {
|
||
return zeeoneBusinessBody{}, false, true, providerRequestID
|
||
}
|
||
body.normalize()
|
||
return body, true, true, firstNonEmpty(body.OrderID, providerRequestID)
|
||
}
|
||
|
||
func (s *Service) zeeoneSignatureValid(envelope zeeoneSignedEnvelope, secret string) bool {
|
||
secret = strings.TrimSpace(secret)
|
||
if secret == "" || envelope.ReqBody == "" || strings.TrimSpace(envelope.SignatureNonce) == "" || envelope.Timestamp <= 0 {
|
||
return false
|
||
}
|
||
now := s.now()
|
||
requestAt := time.UnixMilli(envelope.Timestamp)
|
||
if requestAt.After(now.Add(zeeoneSignatureValidWindow)) || now.Sub(requestAt) > zeeoneSignatureValidWindow {
|
||
return false
|
||
}
|
||
raw := fmt.Sprintf("%s%s%d%s", strings.TrimSpace(envelope.SignatureNonce), envelope.ReqBody, envelope.Timestamp, secret)
|
||
sum := md5.Sum([]byte(raw))
|
||
expected := hex.EncodeToString(sum[:])
|
||
return strings.EqualFold(strings.TrimSpace(envelope.Signature), expected)
|
||
}
|
||
|
||
func (body *zeeoneBusinessBody) normalize() {
|
||
body.AppID = strings.TrimSpace(body.AppID)
|
||
body.UserID = strings.TrimSpace(body.UserID)
|
||
body.SessionID = strings.TrimSpace(body.SessionID)
|
||
body.Extra = strings.TrimSpace(body.Extra)
|
||
body.DiffMsg = strings.ToLower(strings.TrimSpace(body.DiffMsg))
|
||
body.RoomID = strings.TrimSpace(body.RoomID)
|
||
body.OrderID = strings.TrimSpace(body.OrderID)
|
||
body.Extend = strings.TrimSpace(body.Extend)
|
||
}
|
||
|
||
func validateZeeOneAppID(config zeeoneAdapterConfig, appID string) error {
|
||
expected := config.ExpectedAppID()
|
||
if expected == "" || strings.TrimSpace(appID) == "" {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(appID) != expected {
|
||
return xerr.New(xerr.InvalidArgument, "appId mismatch")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) handleZeeOneUserInfo(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
|
||
session, err := s.validZeeOneSession(ctx, app, body.SessionID)
|
||
if err != nil {
|
||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
|
||
}
|
||
if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
|
||
return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "")
|
||
}
|
||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||
if err != nil {
|
||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
|
||
}
|
||
data := map[string]any{
|
||
"userId": firstNonEmpty(body.UserID, externalUserID(session, config.UIDMode)),
|
||
"name": snapshot.Nickname,
|
||
"avatar": snapshot.Avatar,
|
||
"balance": snapshot.Balance,
|
||
"sessionId": body.SessionID,
|
||
"expireDate": session.ExpiresAtMS,
|
||
}
|
||
if body.Extra != "" {
|
||
data["extra"] = body.Extra
|
||
}
|
||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, data)
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil
|
||
}
|
||
|
||
func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody, requestHash string) ([]byte, string, string, bool, string, error) {
|
||
if body.OrderID == "" || body.UserID == "" || body.SessionID == "" || body.GameID <= 0 || body.DiffMsg == "" {
|
||
return zeeoneAdapterError(zeeoneCodeInvalidArgument, "invalid argument", true, body.OrderID)
|
||
}
|
||
session, err := s.validZeeOneSession(ctx, app, body.SessionID)
|
||
if err != nil {
|
||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID)
|
||
}
|
||
if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
|
||
return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, body.OrderID)
|
||
}
|
||
if session.ProviderGameID == "" {
|
||
session.ProviderGameID = strconv.FormatInt(body.GameID, 10)
|
||
}
|
||
if session.GameID == "" {
|
||
session.GameID = session.ProviderGameID
|
||
}
|
||
opType, amount := zeeoneOpType(body.CurrencyDiff)
|
||
providerOrderID := zeeoneCoinChangeProviderOrderID(body.OrderID, opType)
|
||
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
|
||
if err != nil {
|
||
code := zeeoneCodeFromError(err)
|
||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||
raw, contentType := zeeoneJSON(code, zeeoneMessageFromError(err), map[string]any{"currency_balance": balance})
|
||
return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil
|
||
}
|
||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{"currency_balance": result.BalanceAfter})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil
|
||
}
|
||
|
||
func (s *Service) handleZeeOneUpdateSession(ctx context.Context, app string, config zeeoneAdapterConfig, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
|
||
session, err := s.validZeeOneSession(ctx, app, body.SessionID)
|
||
if err != nil {
|
||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
|
||
}
|
||
if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
|
||
return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "")
|
||
}
|
||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{
|
||
"sessionId": body.SessionID,
|
||
"expireDate": session.ExpiresAtMS,
|
||
})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil
|
||
}
|
||
|
||
func (s *Service) handleZeeOneRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
|
||
if body.OrderID != "" {
|
||
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
|
||
AppCode: app,
|
||
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+body.OrderID),
|
||
PlatformCode: platform.PlatformCode,
|
||
ProviderOrderID: body.OrderID,
|
||
Reason: "zeeone_repair_order",
|
||
Status: gamedomain.RepairStatusLogged,
|
||
})
|
||
if err != nil {
|
||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID)
|
||
}
|
||
}
|
||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{})
|
||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil
|
||
}
|
||
|
||
func (s *Service) validZeeOneSession(ctx context.Context, app string, sessionID string) (gamedomain.LaunchSession, error) {
|
||
sessionID = strings.TrimSpace(sessionID)
|
||
if sessionID == "" {
|
||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found")
|
||
}
|
||
// ZeeOne 约定 sessionId 直接使用 App access token;同一个登录 token 可能启动多款游戏。
|
||
// 因此 JWT 必须优先解析,否则会先命中 game_launch_sessions 里旧游戏的 hash,导致 game_id 校验误判。
|
||
if strings.Count(sessionID, ".") == 2 {
|
||
if session, err := s.appSessionFromAccessToken(app, sessionID); err == nil {
|
||
return session, nil
|
||
}
|
||
}
|
||
if session, err := s.validYomiSession(ctx, app, sessionID); err == nil {
|
||
return session, nil
|
||
}
|
||
if strings.Count(sessionID, ".") == 2 {
|
||
return s.appSessionFromAccessToken(app, sessionID)
|
||
}
|
||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found")
|
||
}
|
||
|
||
func zeeoneSessionMatches(session gamedomain.LaunchSession, config zeeoneAdapterConfig, userID string, gameID int64) bool {
|
||
if gameID > 0 && strings.TrimSpace(session.ProviderGameID) != "" && strconv.FormatInt(gameID, 10) != strings.TrimSpace(session.ProviderGameID) {
|
||
return false
|
||
}
|
||
userID = strings.TrimSpace(userID)
|
||
return userID == "" ||
|
||
userID == externalUserID(session, config.UIDMode) ||
|
||
userID == strings.TrimSpace(session.DisplayUserID) ||
|
||
userID == strconv.FormatInt(session.UserID, 10)
|
||
}
|
||
|
||
func zeeoneOpType(diff int64) (string, int64) {
|
||
if diff < 0 {
|
||
return "debit", -diff
|
||
}
|
||
return "credit", diff
|
||
}
|
||
|
||
func zeeoneCoinChangeProviderOrderID(orderID string, opType string) string {
|
||
orderID = strings.TrimSpace(orderID)
|
||
opType = strings.ToLower(strings.TrimSpace(opType))
|
||
if orderID == "" || opType == "" {
|
||
return orderID
|
||
}
|
||
return orderID + ":" + opType
|
||
}
|
||
|
||
func zeeoneProviderRequestID(reqBody string) string {
|
||
var body struct {
|
||
OrderID string `json:"order_id"`
|
||
}
|
||
_ = json.Unmarshal([]byte(reqBody), &body)
|
||
return strings.TrimSpace(body.OrderID)
|
||
}
|
||
|
||
func zeeoneAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
|
||
raw, contentType := zeeoneJSON(code, message, map[string]any{})
|
||
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
|
||
}
|
||
|
||
func zeeoneJSON(code int, message string, data any) ([]byte, string) {
|
||
if strings.TrimSpace(message) == "" && code == zeeoneCodeOK {
|
||
message = zeeoneDefaultSuccessMessage
|
||
}
|
||
payload := map[string]any{"code": code, "message": strings.TrimSpace(message)}
|
||
if data != nil {
|
||
payload["data"] = data
|
||
}
|
||
return jsonResponse(payload)
|
||
}
|
||
|
||
func zeeoneCodeFromError(err error) int {
|
||
switch {
|
||
case err == nil:
|
||
return zeeoneCodeOK
|
||
case xerr.IsCode(err, xerr.InsufficientBalance):
|
||
return zeeoneCodeInsufficientBalance
|
||
case xerr.IsCode(err, xerr.SessionExpired):
|
||
return zeeoneCodeSessionInvalid
|
||
case xerr.IsCode(err, xerr.Unauthorized):
|
||
return zeeoneCodeSessionNotFound
|
||
default:
|
||
return zeeoneCodeInvalidArgument
|
||
}
|
||
}
|
||
|
||
func zeeoneMessageFromError(err error) string {
|
||
switch zeeoneCodeFromError(err) {
|
||
case zeeoneCodeInsufficientBalance:
|
||
return "coin not enough"
|
||
case zeeoneCodeSessionNotFound:
|
||
return "sessionId not found"
|
||
case zeeoneCodeSessionInvalid:
|
||
return "sessionId invalid"
|
||
default:
|
||
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
|
||
return xerr.MessageOf(err)
|
||
}
|
||
return "invalid argument"
|
||
}
|
||
}
|