From 5e0e02cd2a49603f8a186d15b51d694cc428b1ab Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 21 May 2026 15:31:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=81=B5=E4=BB=99=E6=B8=B8=E6=88=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/game/v1/game.pb.go | 2 +- api/proto/game/v1/game.proto | 2 +- .../internal/modules/gamemanagement/dto.go | 4 +- .../modules/gamemanagement/dto_test.go | 24 ++++ .../modules/gamemanagement/handler.go | 2 +- .../game-service/internal/domain/game/game.go | 2 +- .../internal/service/game/leadercc_adapter.go | 105 +++++++++++++++++- .../internal/service/game/service_test.go | 103 +++++++++++++++++ .../internal/transport/grpc/server.go | 3 +- .../internal/transport/grpc/server_test.go | 24 ++++ 10 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 server/admin/internal/modules/gamemanagement/dto_test.go create mode 100644 services/game-service/internal/transport/grpc/server_test.go diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index eb7072c1..5ce97fc1 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -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"` diff --git a/api/proto/game/v1/game.proto b/api/proto/game/v1/game.proto index 2e2030b7..963b2034 100644 --- a/api/proto/game/v1/game.proto +++ b/api/proto/game/v1/game.proto @@ -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; diff --git a/server/admin/internal/modules/gamemanagement/dto.go b/server/admin/internal/modules/gamemanagement/dto.go index 2e4f2315..4250e8a5 100644 --- a/server/admin/internal/modules/gamemanagement/dto.go +++ b/server/admin/internal/modules/gamemanagement/dto.go @@ -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(), diff --git a/server/admin/internal/modules/gamemanagement/dto_test.go b/server/admin/internal/modules/gamemanagement/dto_test.go new file mode 100644 index 00000000..91249f90 --- /dev/null +++ b/server/admin/internal/modules/gamemanagement/dto_test.go @@ -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") + } +} diff --git a/server/admin/internal/modules/gamemanagement/handler.go b/server/admin/internal/modules/gamemanagement/handler.go index c2884b1d..d27e682c 100644 --- a/server/admin/internal/modules/gamemanagement/handler.go +++ b/server/admin/internal/modules/gamemanagement/handler.go @@ -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 { diff --git a/services/game-service/internal/domain/game/game.go b/services/game-service/internal/domain/game/game.go index 154916c3..cd656e71 100644 --- a/services/game-service/internal/domain/game/game.go +++ b/services/game-service/internal/domain/game/game.go @@ -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。 diff --git a/services/game-service/internal/service/game/leadercc_adapter.go b/services/game-service/internal/service/game/leadercc_adapter.go index 50bbac7c..abed78be 100644 --- a/services/game-service/internal/service/game/leadercc_adapter.go +++ b/services/game-service/internal/service/game/leadercc_adapter.go @@ -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_id;uid 取值由 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 } diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index cd3f98bb..fcbd1a74 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -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 +} diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 9feabe0b..75fb3212 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -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, diff --git a/services/game-service/internal/transport/grpc/server_test.go b/services/game-service/internal/transport/grpc/server_test.go new file mode 100644 index 00000000..770bffdf --- /dev/null +++ b/services/game-service/internal/transport/grpc/server_test.go @@ -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") + } +}