2026-06-29 19:44:25 +08:00

568 lines
21 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/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const (
// ZGame 文档的业务码会被游戏服务端直接消费HTTP 200 只表示网关成功接收并处理回调。
zgameCodeOK = 0
zgameCodeInvalidSession = 201
zgameCodeInvalidSignature = 400
zgameCodeUnauthorized = 401
zgameCodeInvalidCode = 403
zgameCodeInsufficientBalance = 501
zgameCodeInternalError = 500
zgameDefaultTokenTTL = 24 * time.Hour
)
type zgameAdapterConfig struct {
// uid_mode 必须和启动链接、login open_id、后续签名 open_id 保持一致,避免同一 token 被不同用户标识复用。
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
// game_urls 支持 ZGame 每款游戏独立 H5 地址launch_url_template 用于批量上新时按 provider_game_id 生成地址。
GameURLs map[string]string `json:"game_urls"`
LaunchURLTemplate string `json:"launch_url_template"`
// callback_base 可按环境覆盖为完整公网 HTTPS 地址,避免 H5 在第三方域名下把相对路径解析到厂商域名。
CallbackBase string `json:"callback_base"`
CallbackBaseAlias string `json:"callbackBase"`
// merchant_payload 是 ZGame consume 回调的透传字段来源;只参与厂商回传,不进入内部业务判断。
MerchantPayload string `json:"merchant_payload"`
// level/gender 是 ZGame 用户资料响应里的展示字段;真实等级接入前保持显式默认值,避免从其它体系误推。
Level int32 `json:"level"`
Gender int32 `json:"gender"`
}
func zgameConfigFromPlatform(value any) zgameAdapterConfig {
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
raw = typed.AdapterConfigJSON
case gamedomain.LaunchableGame:
raw = typed.AdapterConfigJSON
}
var config zgameAdapterConfig
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
if config.UIDMode == "" {
// laluparty 游戏侧展示和签名都用短号作为 open_id历史配置未写 uid_mode 时按文档默认走 display_user_id。
config.UIDMode = "display_user_id"
}
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
config.CallbackBase = strings.TrimSpace(firstNonEmpty(config.CallbackBase, config.CallbackBaseAlias))
config.MerchantPayload = strings.TrimSpace(config.MerchantPayload)
config.GameURLs = normalizeStringMap(config.GameURLs)
return config
}
func (c zgameAdapterConfig) TokenTTL() time.Duration {
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
return zgameDefaultTokenTTL
}
func (c zgameAdapterConfig) CallbackBaseValue() string {
if c.CallbackBase != "" {
return c.CallbackBase
}
return "/api/v1/zgame/callback"
}
func zgameLaunchBaseURL(game gamedomain.LaunchableGame, config zgameAdapterConfig) 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 (s *Service) handleZGameOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
// IP 白名单为空时兼容联调环境;一旦后台配置,固定回调入口先挡掉非厂商来源。
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", false, "")
}
config := zgameConfigFromPlatform(platform)
switch zgameOperation(operation) {
case "login":
return s.handleZGameLogin(ctx, app, config, req)
case "session_renew":
return s.handleZGameSessionRenew(ctx, app, config, platform.CallbackSecretCiphertext, req)
case "user_info_batch":
return s.handleZGameUserInfoBatch(ctx, app, config, platform.CallbackSecretCiphertext, req)
case "consume":
return s.handleZGameConsume(ctx, app, config, platform.CallbackSecretCiphertext, req, requestHash)
default:
return zgameAdapterError(zgameCodeOK, "success", false, "")
}
}
func zgameOperation(operation string) string {
normalized := strings.Trim(strings.ToLower(strings.TrimSpace(operation)), "/")
normalized = strings.TrimPrefix(normalized, "api/")
normalized = strings.TrimPrefix(normalized, "server/")
normalized = strings.TrimPrefix(normalized, "client/")
return strings.Trim(normalized, "/")
}
type zgameLoginBody struct {
OpenID string `json:"open_id"`
OpenIDV2 string `json:"openId"`
JSCode string `json:"js_code"`
JSCodeV2 string `json:"jsCode"`
JSCodeV3 string `json:"code"`
CallbackURL string `json:"callback_url"`
}
func (body zgameLoginBody) openID() string {
return firstNonEmpty(body.OpenID, body.OpenIDV2)
}
func (body zgameLoginBody) jsCode() string {
return firstNonEmpty(body.JSCode, body.JSCodeV2, body.JSCodeV3)
}
func (s *Service) handleZGameLogin(ctx context.Context, app string, config zgameAdapterConfig, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
var body zgameLoginBody
if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil {
return zgameAdapterError(zgameCodeInvalidCode, "Invalid or Expired Code", false, "")
}
openID := body.openID()
jsCode := body.jsCode()
session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, jsCode, "")
if err != nil || !zgameSessionMatches(session, config, openID, "") {
return zgameAdapterError(zgameCodeInvalidCode, "Invalid or Expired Code", true, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return zgameAdapterError(zgameCodeFromError(err), zgameMessageFromError(err), true, "")
}
raw, contentType := zgameJSON(zgameCodeOK, "success", zgameLoginData(session, config, openID, jsCode, snapshot, s.now()))
return raw, contentType, strconv.Itoa(zgameCodeOK), true, "", nil
}
type zgameSignedSessionBody struct {
OpenID string `json:"open_id"`
OpenIDV2 string `json:"openId"`
Session string `json:"session"`
Signature string `json:"signature"`
RawGameID any `json:"game_id"`
RawGameIDV2 any `json:"gameId"`
RawTimestamp any `json:"timestamp"`
}
func (body zgameSignedSessionBody) openID() string {
return firstNonEmpty(body.OpenID, body.OpenIDV2)
}
func (body zgameSignedSessionBody) gameIDText() string {
return zgameAnyString(firstNonEmpty(zgameAnyString(body.RawGameID), zgameAnyString(body.RawGameIDV2)))
}
func (s *Service) handleZGameSessionRenew(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
body, signed, session, err := s.zgameSignedSession(ctx, app, config, secret, req)
if !signed {
return zgameAdapterError(zgameCodeInvalidSignature, "Invalid Signature", false, "")
}
if err != nil || !zgameSessionMatches(session, config, body.openID(), body.gameIDText()) {
return zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", true, "")
}
raw, contentType := zgameJSON(zgameCodeOK, "success", map[string]any{
"session": body.Session,
"session_expired": zgameSessionTTLMS(session, s.now()),
})
return raw, contentType, strconv.Itoa(zgameCodeOK), true, "", nil
}
type zgameUserInfoBatchBody struct {
OpenIDList []string `json:"open_id_list"`
OpenIDListV2 []string `json:"openIdList"`
OpenID string `json:"open_id"`
OpenIDV2 string `json:"openId"`
Session string `json:"session"`
Signature string `json:"signature"`
}
func (body zgameUserInfoBatchBody) openID() string {
return firstNonEmpty(body.OpenID, body.OpenIDV2)
}
func (body zgameUserInfoBatchBody) openIDList() []string {
if len(body.OpenIDList) > 0 {
return body.OpenIDList
}
return body.OpenIDListV2
}
func (s *Service) handleZGameUserInfoBatch(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
var body zgameUserInfoBatchBody
if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil {
return zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", false, "")
}
if !zgameSignatureValid(body.openID(), body.Session, body.Signature, secret) {
return zgameAdapterError(zgameCodeInvalidSignature, "Invalid Signature", false, "")
}
session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, body.Session, "")
if err != nil || !zgameSessionMatches(session, config, body.openID(), "") {
return zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", true, "")
}
userInfos := make(map[string]any, len(body.openIDList()))
for _, openID := range body.openIDList() {
openID = strings.TrimSpace(openID)
if openID == "" {
continue
}
data, ok := s.zgameUserInfoForOpenID(ctx, app, config, req, session, openID)
if ok {
userInfos[openID] = data
}
}
raw, contentType := zgameJSON(zgameCodeOK, "success", map[string]any{"user_infos": userInfos})
return raw, contentType, strconv.Itoa(zgameCodeOK), true, "", nil
}
type zgameConsumeBody struct {
OpenID string `json:"open_id"`
OpenIDV2 string `json:"openId"`
Session string `json:"session"`
Amount json.Number `json:"amount"`
OrderID string `json:"order_id"`
OrderIDV2 string `json:"orderId"`
GameID string `json:"game_id"`
GameIDV2 string `json:"gameId"`
Timestamp int64 `json:"timestamp"`
RoundID string `json:"round_id"`
RoundIDV2 string `json:"roundId"`
Type int32 `json:"type"`
MerchantPayload string `json:"merchant_payload"`
MerchantPayloadV2 string `json:"merchantPayload"`
Signature string `json:"signature"`
}
func (body zgameConsumeBody) openID() string {
return firstNonEmpty(body.OpenID, body.OpenIDV2)
}
func (body zgameConsumeBody) orderID() string {
return firstNonEmpty(body.OrderID, body.OrderIDV2)
}
func (body zgameConsumeBody) gameID() string {
return firstNonEmpty(body.GameID, body.GameIDV2)
}
func (body zgameConsumeBody) roundID() string {
return firstNonEmpty(body.RoundID, body.RoundIDV2)
}
func (body zgameConsumeBody) merchantPayload() string {
return firstNonEmpty(body.MerchantPayload, body.MerchantPayloadV2)
}
func (s *Service) handleZGameConsume(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) {
var body zgameConsumeBody
if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil {
return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", false, "")
}
if !zgameSignatureValid(body.openID(), body.Session, body.Signature, secret) {
return zgameAdapterError(zgameCodeInvalidSignature, "Invalid Signature", false, body.orderID())
}
session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, body.Session, body.gameID())
if err != nil || !zgameSessionMatches(session, config, body.openID(), body.gameID()) {
return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", true, body.orderID())
}
amount, amountOK := zgameAmount(body.Amount)
opType, coinAmount := zgameOpType(body.Type, amount)
if body.orderID() == "" || body.gameID() == "" || !amountOK || coinAmount < 0 || opType == "" {
return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", true, body.orderID())
}
if session.GameID == "" {
session.GameID = body.gameID()
}
if session.ProviderGameID == "" {
session.ProviderGameID = body.gameID()
}
providerOrderID := zgameProviderOrderID(body.orderID(), opType)
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.roundID(), opType, coinAmount, "", requestHash)
if err != nil {
code := zgameCodeFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
raw, contentType := zgameBalanceJSON(code, zgameMessageFromError(err), balance)
return raw, contentType, strconv.Itoa(code), true, body.orderID(), nil
}
raw, contentType := zgameBalanceJSON(zgameCodeOK, "success", result.BalanceAfter)
return raw, contentType, strconv.Itoa(zgameCodeOK), true, body.orderID(), nil
}
func (s *Service) zgameSignedSession(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest) (zgameSignedSessionBody, bool, gamedomain.LaunchSession, error) {
var body zgameSignedSessionBody
if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil {
return zgameSignedSessionBody{}, false, gamedomain.LaunchSession{}, xerr.New(xerr.InvalidArgument, "invalid payload")
}
if !zgameSignatureValid(body.openID(), body.Session, body.Signature, secret) {
return body, false, gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "signature invalid")
}
session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, body.Session, body.gameIDText())
if err != nil {
return body, true, gamedomain.LaunchSession{}, err
}
if !zgameSessionMatches(session, config, body.openID(), body.gameIDText()) {
return body, true, gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session mismatch")
}
return body, true, session, nil
}
func (s *Service) validZGameSession(ctx context.Context, app string, platformCode string, token string, providerGameID string) (gamedomain.LaunchSession, error) {
token = strings.TrimSpace(token)
if token == "" {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session invalid")
}
// 新版启动链路使用服务端短期 js_code保留 JWT 兼容旧测试链接,避免已打开的 H5 立即失效。
if strings.Count(token, ".") == 2 {
if session, err := s.appSessionFromAccessToken(app, token); err == nil {
return session, nil
}
}
if session, err := s.validScopedYomiSession(ctx, app, platformCode, token, providerGameID); err == nil {
return session, nil
}
if strings.Count(token, ".") == 2 {
return s.appSessionFromAccessToken(app, token)
}
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session invalid")
}
func zgameSessionMatches(session gamedomain.LaunchSession, config zgameAdapterConfig, openID string, gameID string) bool {
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
openID = strings.TrimSpace(openID)
return openID != "" && (openID == externalUserID(session, config.UIDMode) ||
openID == strings.TrimSpace(session.DisplayUserID) ||
openID == strconv.FormatInt(session.UserID, 10))
}
func zgameSignatureValid(openID string, session string, signature string, secret string) bool {
openID = strings.TrimSpace(openID)
session = strings.TrimSpace(session)
secret = strings.TrimSpace(secret)
signature = strings.TrimSpace(signature)
if openID == "" || session == "" || secret == "" || signature == "" {
return false
}
sum := sha256.Sum256([]byte(openID + session + secret))
expected := hex.EncodeToString(sum[:])
return subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) == 1
}
func zgameLoginData(session gamedomain.LaunchSession, config zgameAdapterConfig, requestedOpenID string, token string, snapshot yomiUserSnapshot, now time.Time) map[string]any {
openID := strings.TrimSpace(requestedOpenID)
if openID == "" {
openID = externalUserID(session, config.UIDMode)
}
return map[string]any{
"session": token,
"session_expired": zgameSessionTTLMS(session, now),
"openId": openID,
"userName": snapshot.Nickname,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
"balance": snapshot.Balance,
"level": config.Level,
"country": "",
"gender": config.Gender,
"sex": config.Gender,
}
}
func (s *Service) zgameUserInfoForOpenID(ctx context.Context, app string, config zgameAdapterConfig, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, openID string) (map[string]any, bool) {
userID := int64(0)
if openID == externalUserID(session, config.UIDMode) || openID == strings.TrimSpace(session.DisplayUserID) {
userID = session.UserID
} else if !strings.EqualFold(config.UIDMode, "display_user_id") {
// 批量查询没有 display_id -> user_id 解析依赖;默认 user_id 模式可直接按 open_id 查用户资料。
parsed, err := strconv.ParseInt(openID, 10, 64)
if err == nil && parsed > 0 {
userID = parsed
}
}
if userID <= 0 {
return nil, false
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, userID)
if err != nil {
return nil, false
}
return zgameUserInfoData(config, snapshot), true
}
func zgameUserInfoData(config zgameAdapterConfig, snapshot yomiUserSnapshot) map[string]any {
return map[string]any{
"userName": snapshot.Nickname,
"nickname": snapshot.Nickname,
"gender": config.Gender,
"level": config.Level,
"avatar": snapshot.Avatar,
"balance": snapshot.Balance,
}
}
func zgameSessionTTLMS(session gamedomain.LaunchSession, now time.Time) int64 {
if session.ExpiresAtMS <= 0 {
return int64(zgameDefaultTokenTTL / time.Millisecond)
}
ttl := session.ExpiresAtMS - now.UnixMilli()
if ttl < 0 {
return 0
}
return ttl
}
func zgameAmount(value json.Number) (int64, bool) {
raw := strings.TrimSpace(value.String())
if raw == "" {
return 0, false
}
amount, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, false
}
return amount, true
}
func zgameOpType(op int32, amount int64) (string, int64) {
if amount == -1<<63 {
return "", -1
}
coinAmount := amount
if coinAmount < 0 {
coinAmount = -coinAmount
}
if amount < 0 || op == 1 {
return "credit", coinAmount
}
if op == 0 {
return "debit", coinAmount
}
return "", coinAmount
}
func zgameProviderOrderID(orderID string, opType string) string {
orderID = strings.TrimSpace(orderID)
opType = strings.TrimSpace(opType)
if orderID == "" || opType == "" {
return orderID
}
return orderID + ":" + opType
}
func zgameAnyString(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case json.Number:
return strings.TrimSpace(typed.String())
case float64:
if typed == math.Trunc(typed) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case int32:
return strconv.FormatInt(int64(typed), 10)
case uint64:
return strconv.FormatUint(typed, 10)
default:
return strings.TrimSpace(fmt.Sprint(value))
}
}
func zgameAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
raw, contentType := zgameJSON(code, message, nil)
return raw, contentType, strconv.Itoa(code), signatureValid, strings.TrimSpace(providerRequestID), nil
}
func zgameJSON(code int, message string, data any) ([]byte, string) {
if strings.TrimSpace(message) == "" && code == zgameCodeOK {
message = "success"
}
return jsonResponse(map[string]any{
"code": code,
"errmsg": strings.TrimSpace(message),
"data": data,
})
}
func zgameBalanceJSON(code int, message string, balance int64) ([]byte, string) {
if strings.TrimSpace(message) == "" && code == zgameCodeOK {
message = "success"
}
return jsonResponse(map[string]any{
"code": code,
"errmsg": strings.TrimSpace(message),
"balance": balance,
"data": map[string]any{
"balance": balance,
},
})
}
func zgameCodeFromError(err error) int {
switch {
case err == nil:
return zgameCodeOK
case xerr.IsCode(err, xerr.InsufficientBalance):
return zgameCodeInsufficientBalance
case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
return zgameCodeUnauthorized
default:
return zgameCodeInternalError
}
}
func zgameMessageFromError(err error) string {
switch zgameCodeFromError(err) {
case zgameCodeInsufficientBalance:
return "Insufficient Funds"
case zgameCodeUnauthorized:
return "Unauthorized"
case zgameCodeOK:
return "success"
default:
return "Internal Error"
}
}