灵仙游戏

This commit is contained in:
zhx 2026-05-21 15:31:00 +08:00
parent 9d4dd79a86
commit 5e0e02cd2a
10 changed files with 259 additions and 12 deletions

View File

@ -117,7 +117,7 @@ type GamePlatform struct {
CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
AdapterType string `protobuf:"bytes,9,opt,name=adapter_type,json=adapterType,proto3" json:"adapter_type,omitempty"`
// callback_secret is write-only for admin updates; list responses keep it empty.
// callback_secret is visible to admin platform configuration pages and writable for key rotation.
CallbackSecret string `protobuf:"bytes,10,opt,name=callback_secret,json=callbackSecret,proto3" json:"callback_secret,omitempty"`
CallbackSecretSet bool `protobuf:"varint,11,opt,name=callback_secret_set,json=callbackSecretSet,proto3" json:"callback_secret_set,omitempty"`
CallbackIpWhitelist []string `protobuf:"bytes,12,rep,name=callback_ip_whitelist,json=callbackIpWhitelist,proto3" json:"callback_ip_whitelist,omitempty"`

View File

@ -24,7 +24,7 @@ message GamePlatform {
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
string adapter_type = 9;
// callback_secret is write-only for admin updates; list responses keep it empty.
// callback_secret is visible to admin platform configuration pages and writable for key rotation.
string callback_secret = 10;
bool callback_secret_set = 11;
repeated string callback_ip_whitelist = 12;

View File

@ -10,7 +10,8 @@ type platformDTO struct {
APIBaseURL string `json:"apiBaseUrl"`
// adapterType 决定服务端按哪家厂商协议拼启动 URL 和处理回调。
AdapterType string `json:"adapterType"`
// callbackSecretSet 只告诉后台“已配置”,不把密钥明文下发到浏览器。
// callbackSecret 是后台配置页可见的厂商 key/AppSecret只对有 game:view 权限的管理员返回。
CallbackSecret string `json:"callbackSecret"`
CallbackSecretSet bool `json:"callbackSecretSet"`
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
// adapterConfigJson 承载 uid_mode/default_lang/token_ttl_seconds 等可热更新配置。
@ -50,6 +51,7 @@ func platformFromProto(item *gamev1.GamePlatform) platformDTO {
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
AdapterType: item.GetAdapterType(),
CallbackSecret: item.GetCallbackSecret(),
CallbackSecretSet: item.GetCallbackSecretSet(),
CallbackIPWhitelist: item.GetCallbackIpWhitelist(),
AdapterConfigJSON: item.GetAdapterConfigJson(),

View File

@ -0,0 +1,24 @@
package gamemanagement
import (
"testing"
gamev1 "hyapp.local/api/proto/game/v1"
)
func TestPlatformFromProtoReturnsCallbackSecret(t *testing.T) {
const secret = "yomi-or-lingxian-secret"
got := platformFromProto(&gamev1.GamePlatform{
PlatformCode: "yomi",
CallbackSecret: secret,
CallbackSecretSet: true,
})
if got.CallbackSecret != secret {
t.Fatalf("CallbackSecret = %q, want %q", got.CallbackSecret, secret)
}
if !got.CallbackSecretSet {
t.Fatal("CallbackSecretSet = false, want true")
}
}

View File

@ -46,7 +46,7 @@ func (h *Handler) UpdatePlatform(c *gin.Context) {
h.upsertPlatform(c, strings.TrimSpace(c.Param("platform_code")))
}
// upsertPlatform 创建或更新第三方游戏平台配置;平台密钥后续走单独的密钥托管接口
// upsertPlatform 创建或更新第三方游戏平台配置;callbackSecret 非空时才会轮换厂商密钥
func (h *Handler) upsertPlatform(c *gin.Context, platformCode string) {
var req platformRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -37,7 +37,7 @@ type Platform struct {
APIBaseURL string
// AdapterType 决定启动参数、签名/解密规则、回包结构和错误码映射。
AdapterType string
// CallbackSecretCiphertext 存放厂商 key/AppSecret接口响应不回显明文
// CallbackSecretCiphertext 存放厂商 key/AppSecret后台配置页需要回显,回调校验也直接使用它
CallbackSecretCiphertext string
CallbackSecretConfigured bool
// CallbackIPWhitelist 为空表示不限制;配置后支持单 IP 或 CIDR。

View File

@ -9,7 +9,9 @@ import (
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
@ -89,7 +91,14 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
// key 存在 callback_secret 字段里;后台不回显明文,更新时才覆盖。
if leaderccTokenOnlyProbe(body.Token, body.GameID, body.UID, body.RoomID, body.Sign) {
if _, err := s.validLeaderCCSession(ctx, app, body.Token); err != nil {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "")
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, "", nil
}
// key 存在 callback_secret 字段里;后台会回显当前值,便于直接提供给灵仙排查签名。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.GameID, body.UID, body.Token, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, "")
}
@ -132,6 +141,13 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
if leaderccTokenOnlyProbe(body.Token, body.OrderID, body.GameID, body.RoundID, body.UID, body.WinID, body.RoomID, body.Sign) && body.Coin == 0 && body.Type == 0 && body.RewardType == 0 {
if _, err := s.validLeaderCCSession(ctx, app, body.Token); err != nil {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
// 文档签名顺序orderId + gameId + roundId + uid + coin + type + rewardType + token + winId + roomId + key。
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)
@ -142,6 +158,12 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
if session.ProviderGameID == "" {
session.ProviderGameID = strings.TrimSpace(body.GameID)
}
if session.GameID == "" {
session.GameID = firstNonEmpty(strings.TrimSpace(body.GameID), "leadercc")
}
opType := leaderccOpType(body.Type)
// type=1 扣款、type=2 加款coin=0 没有实际改账,但仍会按幂等订单记成功。
if providerOrderID == "" || opType == "" || body.Coin < 0 {
@ -170,6 +192,7 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla
UID string `json:"uid"`
Coin int64 `json:"coin"`
RewardType int32 `json:"rewardType"`
Token string `json:"token"`
WinID string `json:"winId"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
@ -178,6 +201,13 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
if leaderccTokenOnlyProbe(body.Token, body.OrderID, body.GameID, body.RoundID, body.UID, body.WinID, body.RoomID, body.Sign) && body.Coin == 0 && body.RewardType == 0 {
if _, err := s.validLeaderCCSession(ctx, app, body.Token); err != nil {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
// 文档签名顺序orderId + gameId + roundId + uid + coin + rewardType + winId + roomId + key。
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)
@ -202,7 +232,11 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla
}
func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
// 灵仙和 Yomi 都复用 game_launch_sessions差异在字段名不在 token 存储方式。
// 灵仙回调直接使用 App 登录 access token无需预先进入游戏创建 launch session。
if strings.Count(strings.TrimSpace(token), ".") == 2 {
return s.leaderccSessionFromAppToken(app, token)
}
// 兼容历史联调 token老链路会先 launch再按 game_launch_sessions 校验。
session, err := s.validYomiSession(ctx, app, token)
if err != nil {
return gamedomain.LaunchSession{}, err
@ -211,11 +245,50 @@ func (s *Service) validLeaderCCSession(ctx context.Context, app string, token st
}
func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAdapterConfig, uid string, gameID string) bool {
// gameId 对应内部目录里的 provider_game_iduid 取值由 uid_mode 决定
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
// 使用 App token 时 session.ProviderGameID 可能为空,此时不强制要求先配置/启动游戏目录
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
return strings.TrimSpace(uid) == externalUserID(session, config.UIDMode)
normalizedUID := strings.TrimSpace(uid)
return normalizedUID == externalUserID(session, config.UIDMode) ||
normalizedUID == strings.TrimSpace(session.DisplayUserID) ||
normalizedUID == strconv.FormatInt(session.UserID, 10)
}
func (s *Service) leaderccSessionFromAppToken(app string, token string) (gamedomain.LaunchSession, error) {
claims := jwt.MapClaims{}
_, _, err := new(jwt.Parser).ParseUnverified(strings.TrimSpace(token), claims)
if err != nil {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
if typ := strings.TrimSpace(leaderccStringClaim(claims, "typ")); typ != "" && typ != "access" {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
expiresAt, err := claims.GetExpirationTime()
if err != nil || expiresAt == nil || !expiresAt.After(s.now()) {
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
}
tokenApp := appcode.Normalize(leaderccStringClaim(claims, "app_code"))
if tokenApp != "" && tokenApp != appcode.Normalize(app) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
userID, err := strconv.ParseInt(strings.TrimSpace(leaderccStringClaim(claims, "user_id")), 10, 64)
if err != nil || userID <= 0 {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
displayUserID := firstNonEmpty(
leaderccStringClaim(claims, "display_user_id"),
leaderccStringClaim(claims, "default_display_user_id"),
strconv.FormatInt(userID, 10),
)
return gamedomain.LaunchSession{
AppCode: tokenApp,
SessionID: leaderccStringClaim(claims, "sid"),
UserID: userID,
DisplayUserID: displayUserID,
Status: gamedomain.SessionActive,
ExpiresAtMS: expiresAt.UnixMilli(),
}, nil
}
func leaderccOpType(value int32) string {
@ -254,6 +327,23 @@ func leaderccSignValid(actual string, key string, parts ...string) bool {
return strings.EqualFold(strings.TrimSpace(actual), expected)
}
func leaderccTokenOnlyProbe(token string, parts ...string) bool {
if strings.TrimSpace(token) == "" {
return false
}
for _, part := range parts {
if strings.TrimSpace(part) != "" {
return false
}
}
return true
}
func leaderccStringClaim(claims jwt.MapClaims, key string) string {
value, _ := claims[key].(string)
return strings.TrimSpace(value)
}
func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
// signatureValid/providerRequestID 会写入 callback log帮助后续排查签名和重复订单问题。
raw, contentType := leaderccJSON(code, message, map[string]any{})
@ -262,10 +352,13 @@ func leaderccAdapterError(code int, message string, signatureValid bool, provide
func leaderccJSON(errorCode int, message string, data any) ([]byte, string) {
// 灵仙成功可以不带 message失败时带 message 方便联调定位。
payload := map[string]any{"errorCode": errorCode}
payload := map[string]any{"code": errorCode, "errorCode": errorCode}
if data != nil {
payload["data"] = data
}
if errorCode == leaderccCodeOK && strings.TrimSpace(message) == "" {
message = "ok"
}
if strings.TrimSpace(message) != "" {
payload["message"] = message
}

View File

@ -7,10 +7,12 @@ import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"strconv"
"strings"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
gamev1 "hyapp.local/api/proto/game/v1"
@ -227,6 +229,40 @@ func TestHandleLeaderCCUserInfoValidatesSignAndToken(t *testing.T) {
}
}
func TestHandleLeaderCCUserInfoAcceptsAppAccessTokenWithoutLaunchSession(t *testing.T) {
key := "leadercc-test-key"
token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600)
repo := &fakeRepository{
platform: gamedomain.Platform{
PlatformCode: "lingxian",
AdapterType: gamedomain.AdapterLeaderCCV1,
CallbackSecretCiphertext: key,
AdapterConfigJSON: `{"uid_mode":"display_user_id"}`,
},
}
wallet := &fakeWallet{balanceAfter: 1680}
user := &fakeUser{}
svc := New(Config{}, repo, wallet, user)
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
sign := leaderccTestSign(key, "101", "420001", token, "room_1")
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-app-token-user", AppCode: "lalu"},
PlatformCode: "lingxian",
Operation: "userinfo",
RawBody: []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","roomId":"room_1","sign":"` + sign + `"}`),
})
if err != nil {
t.Fatalf("HandleCallback failed: %v", err)
}
if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1680`) {
t.Fatalf("leadercc app-token user response mismatch: %s", raw)
}
if user.lastGet.GetUserId() != 42 {
t.Fatalf("user request must use token user_id, got %+v", user.lastGet)
}
}
func TestHandleLeaderCCChangeCoinAndRepairOrder(t *testing.T) {
key := "leadercc-test-key"
token := "gt_lingxian_token"
@ -292,6 +328,52 @@ func TestHandleLeaderCCChangeCoinAndRepairOrder(t *testing.T) {
}
}
func TestHandleLeaderCCChangeCoinAcceptsAppAccessTokenAndTokenOnlyProbe(t *testing.T) {
key := "leadercc-test-key"
token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600)
repo := &fakeRepository{
platform: gamedomain.Platform{
PlatformCode: "lingxian",
AdapterType: gamedomain.AdapterLeaderCCV1,
CallbackSecretCiphertext: key,
AdapterConfigJSON: `{"uid_mode":"display_user_id"}`,
},
}
wallet := &fakeWallet{balanceAfter: 1560}
svc := New(Config{}, repo, wallet, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
probeRaw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-token-probe", AppCode: "lalu"},
PlatformCode: "lingxian",
Operation: "change_coin",
RawBody: []byte(`{"token":"` + token + `"}`),
})
if err != nil {
t.Fatalf("token-only probe failed: %v", err)
}
if !strings.Contains(string(probeRaw), `"code":0`) || wallet.lastApply != nil {
t.Fatalf("token-only probe must return ok without applying wallet: raw=%s wallet=%+v", probeRaw, wallet.lastApply)
}
changeSign := leaderccTestSign(key, "order_app_token_1", "101", "round_1", "420001", "120", "1", "2", token, "", "room_1")
raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-app-token-change", AppCode: "lalu"},
PlatformCode: "lingxian",
Operation: "change_coin",
RawBody: []byte(`{"orderId":"order_app_token_1","gameId":"101","roundId":"round_1","uid":"420001","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`),
})
if err != nil {
t.Fatalf("HandleCallback failed: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"coin":1560`) {
t.Fatalf("leadercc app-token change response mismatch: %s", raw)
}
if wallet.lastApply.GetUserId() != 42 || wallet.lastApply.GetGameId() != "101" || wallet.lastApply.GetCommandId() != "game:lingxian:order_app_token_1" {
t.Fatalf("wallet command must use token user and request game: %+v", wallet.lastApply)
}
}
func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) {
secret := "12345678901234567890123456789012"
token := "gt_test_token"
@ -654,3 +736,24 @@ func leaderccTestSign(key string, parts ...string) string {
sum := md5.Sum([]byte(builder.String()))
return hex.EncodeToString(sum[:])
}
func leaderccTestAccessToken(t *testing.T, app string, userID int64, displayUserID string, expiresAtSec int64) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "hyapp",
"app_code": app,
"sub": strconv.FormatInt(userID, 10),
"user_id": strconv.FormatInt(userID, 10),
"display_user_id": displayUserID,
"default_display_user_id": displayUserID,
"sid": "sess_test_app_token",
"typ": "access",
"iat": int64(1700000000),
"exp": expiresAtSec,
})
signed, err := token.SignedString([]byte("test-secret"))
if err != nil {
t.Fatalf("sign test access token: %v", err)
}
return signed
}

View File

@ -267,7 +267,8 @@ func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
AdapterType: item.AdapterType,
// callback_secret 明文只允许写入,不允许通过列表/详情接口回显到后台前端。
// admin 后台要把当前厂商 key 提供给运营/对接人员核对;更新接口仍以空值表示保留旧密钥。
CallbackSecret: item.CallbackSecretCiphertext,
CallbackSecretSet: item.CallbackSecretConfigured,
CallbackIpWhitelist: item.CallbackIPWhitelist,
AdapterConfigJson: item.AdapterConfigJSON,

View File

@ -0,0 +1,24 @@
package grpc
import (
"testing"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
func TestPlatformToProtoExposesCallbackSecretForAdmin(t *testing.T) {
const secret = "leadercc-admin-visible-key"
got := platformToProto(gamedomain.Platform{
PlatformCode: "lingxian",
CallbackSecretCiphertext: secret,
CallbackSecretConfigured: true,
})
if got.GetCallbackSecret() != secret {
t.Fatalf("callback_secret = %q, want %q", got.GetCallbackSecret(), secret)
}
if !got.GetCallbackSecretSet() {
t.Fatal("callback_secret_set = false, want true")
}
}