2026-05-27 17:27:13 +08:00

426 lines
16 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 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"
}
}