2026-06-09 11:04:31 +08:00

496 lines
19 KiB
Go

package grpc
import (
"context"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
gameservice "hyapp/services/game-service/internal/service/game"
)
// Server 把 game-service 用例层适配为 gRPC 契约。
type Server struct {
gamev1.UnimplementedGameAppServiceServer
gamev1.UnimplementedGameCallbackServiceServer
gamev1.UnimplementedGameAdminServiceServer
gamev1.UnimplementedGameCronServiceServer
svc *gameservice.Service
}
func NewServer(svc *gameservice.Service) *Server {
return &Server{svc: svc}
}
func (s *Server) ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, serverTimeMS, err := s.svc.ListGames(ctx, gameservice.ListGamesQuery{
AppCode: req.GetMeta().GetAppCode(),
UserID: req.GetUserId(),
Scene: req.GetScene(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
Language: req.GetLanguage(),
ClientPlatform: req.GetClientPlatform(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.AppGame, 0, len(items))}
for _, item := range items {
resp.Games = append(resp.Games, appGameToProto(item))
}
return resp, nil
}
func (s *Server) ListRecentGames(ctx context.Context, req *gamev1.ListRecentGamesRequest) (*gamev1.ListGamesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, serverTimeMS, err := s.svc.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{
AppCode: req.GetMeta().GetAppCode(),
UserID: req.GetUserId(),
Scene: req.GetScene(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
Language: req.GetLanguage(),
ClientPlatform: req.GetClientPlatform(),
PageSize: req.GetPageSize(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.AppGame, 0, len(items))}
for _, item := range items {
resp.Games = append(resp.Games, appGameToProto(item))
}
return resp, nil
}
func (s *Server) GetBridgeScript(ctx context.Context, req *gamev1.GetBridgeScriptRequest) (*gamev1.GetBridgeScriptResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.GetBridgeScript(ctx, req.GetMeta().GetAppCode())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.GetBridgeScriptResponse{
Config: &gamev1.GameBridgeScriptConfig{
BridgeScriptUrl: result.URL,
BridgeScriptVersion: result.Version,
BridgeScriptSha256: result.SHA256,
},
ServerTimeMs: result.ServerTimeMS,
}, nil
}
func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.LaunchGame(ctx, gameservice.LaunchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
DisplayUserID: req.GetDisplayUserId(),
GameID: req.GetGameId(),
Scene: req.GetScene(),
RoomID: req.GetRoomId(),
AccessToken: req.GetAccessToken(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.LaunchGameResponse{
SessionId: result.SessionID,
LaunchUrl: result.LaunchURL,
ExpiresAtMs: result.ExpiresAtMS,
Orientation: result.Orientation,
SafeHeight: result.SafeHeight,
ServerTimeMs: result.ServerTimeMS,
}, nil
}
func (s *Server) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
raw, contentType, err := s.svc.HandleCallback(ctx, req)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CallbackResponse{RawBody: raw, ContentType: contentType}, nil
}
// ProcessLevelEventOutboxBatch 只由 cron-service 调用,批量接管并投递游戏消耗等级事件。
func (s *Server) ProcessLevelEventOutboxBatch(ctx context.Context, req *gamev1.CronBatchRequest) (*gamev1.CronBatchResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
claimed, processed, success, failure, hasMore, err := s.svc.ProcessLevelEventOutboxBatch(ctx, req.GetRunId(), req.GetWorkerId(), int(req.GetBatchSize()), durationFromMillis(req.GetLockTtlMs()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CronBatchResponse{
ClaimedCount: claimed,
ProcessedCount: processed,
SuccessCount: success,
FailureCount: failure,
HasMore: hasMore,
}, nil
}
func (s *Server) ListPlatforms(ctx context.Context, req *gamev1.ListPlatformsRequest) (*gamev1.ListPlatformsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, err := s.svcRepository().ListPlatforms(ctx, req.GetMeta().GetAppCode(), req.GetStatus())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListPlatformsResponse{ServerTimeMs: time.Now().UnixMilli(), Platforms: make([]*gamev1.GamePlatform, 0, len(items))}
for _, item := range items {
resp.Platforms = append(resp.Platforms, platformToProto(item))
}
return resp, nil
}
func (s *Server) UpsertPlatform(ctx context.Context, req *gamev1.UpsertPlatformRequest) (*gamev1.PlatformResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
platform := platformFromProto(req.GetPlatform())
platform.AppCode = req.GetMeta().GetAppCode()
normalized, err := normalizePlatform(platform)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
saved, err := s.svcRepository().UpsertPlatform(ctx, normalized)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.PlatformResponse{Platform: platformToProto(saved), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, next, err := s.svcRepository().ListCatalog(ctx, gameservice.ListCatalogQuery{
AppCode: req.GetMeta().GetAppCode(),
PlatformCode: req.GetPlatformCode(),
Status: req.GetStatus(),
PageSize: req.GetPageSize(),
Cursor: req.GetCursor(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListCatalogResponse{NextCursor: next, ServerTimeMs: time.Now().UnixMilli(), Games: make([]*gamev1.GameCatalogItem, 0, len(items))}
for _, item := range items {
resp.Games = append(resp.Games, catalogToProto(item))
}
return resp, nil
}
func (s *Server) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item := catalogFromProto(req.GetGame())
item.AppCode = req.GetMeta().GetAppCode()
normalized, err := normalizeCatalog(item)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
saved, err := s.svcRepository().UpsertCatalog(ctx, normalized)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CatalogResponse{Game: catalogToProto(saved), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
status := normalizeStatus(req.GetStatus())
if status == "" {
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "status is invalid"))
}
item, err := s.svcRepository().SetGameStatus(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), status, time.Now().UnixMilli())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CatalogResponse{Game: catalogToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if err := s.svcRepository().DeleteCatalog(ctx, req.GetMeta().GetAppCode(), req.GetGameId()); err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DeleteCatalogResponse{ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) svcRepository() gameservice.Repository {
if s == nil || s.svc == nil || s.svc.Repository() == nil {
return unavailableRepository{}
}
return s.svc.Repository()
}
type unavailableRepository struct{}
func (unavailableRepository) Ping(context.Context) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListGames(context.Context, gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListRecentGames(context.Context, gameservice.ListRecentGamesQuery) ([]gamedomain.AppGame, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
return gamedomain.LaunchableGame{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetPlatform(context.Context, string, string) (gamedomain.Platform, error) {
return gamedomain.Platform{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateLaunchSession(context.Context, gamedomain.LaunchSession) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetLaunchSessionByToken(context.Context, string, string) (gamedomain.LaunchSession, error) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateGameOrder(context.Context, gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
return gamedomain.GameOrder{}, false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkOrderSucceeded(context.Context, string, string, string, int64, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkOrderFailed(context.Context, string, string, string, string, string, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateRepairOrder(context.Context, gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
return gamedomain.RepairOrder{}, false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkLevelEventDelivered(context.Context, string, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkLevelEventFailed(context.Context, string, string, int64, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListPlatforms(context.Context, string, string) ([]gamedomain.Platform, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) UpsertPlatform(context.Context, gamedomain.Platform) (gamedomain.Platform, error) {
return gamedomain.Platform{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListCatalog(context.Context, gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) UpsertCatalog(context.Context, gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) SetGameStatus(context.Context, string, string, string, int64) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) DeleteCatalog(context.Context, string, string) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame {
return &gamev1.AppGame{
GameId: item.GameID,
PlatformCode: item.PlatformCode,
NameKey: item.NameKey,
Name: item.Name,
IconUrl: item.IconURL,
CoverUrl: item.CoverURL,
Category: item.Category,
LaunchMode: item.LaunchMode,
Orientation: item.Orientation,
SafeHeight: item.SafeHeight,
MinCoin: item.MinCoin,
Enabled: item.Enabled,
Maintenance: item.Maintenance,
SortOrder: item.SortOrder,
}
}
func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
return &gamev1.GamePlatform{
AppCode: item.AppCode,
PlatformCode: item.PlatformCode,
PlatformName: item.PlatformName,
Status: item.Status,
ApiBaseUrl: item.APIBaseURL,
SortOrder: item.SortOrder,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
AdapterType: item.AdapterType,
// admin 后台要把当前厂商 key 提供给运营/对接人员核对;更新接口仍以空值表示保留旧密钥。
CallbackSecret: item.CallbackSecretCiphertext,
CallbackSecretSet: item.CallbackSecretConfigured,
CallbackIpWhitelist: item.CallbackIPWhitelist,
AdapterConfigJson: item.AdapterConfigJSON,
}
}
func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform {
if item == nil {
return gamedomain.Platform{}
}
return gamedomain.Platform{
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
AdapterType: item.GetAdapterType(),
// 更新接口只有在 callback_secret 非空时才会覆盖旧密钥,空值表示保留原密钥。
CallbackSecretCiphertext: item.GetCallbackSecret(),
CallbackSecretConfigured: item.GetCallbackSecretSet(),
CallbackIPWhitelist: item.GetCallbackIpWhitelist(),
AdapterConfigJSON: item.GetAdapterConfigJson(),
SortOrder: item.GetSortOrder(),
}
}
func catalogToProto(item gamedomain.CatalogItem) *gamev1.GameCatalogItem {
return &gamev1.GameCatalogItem{
AppCode: item.AppCode,
GameId: item.GameID,
PlatformCode: item.PlatformCode,
ProviderGameId: item.ProviderGameID,
GameName: item.GameName,
Category: item.Category,
IconUrl: item.IconURL,
CoverUrl: item.CoverURL,
LaunchMode: item.LaunchMode,
Orientation: item.Orientation,
SafeHeight: item.SafeHeight,
MinCoin: item.MinCoin,
Status: item.Status,
SortOrder: item.SortOrder,
Tags: item.Tags,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func catalogFromProto(item *gamev1.GameCatalogItem) gamedomain.CatalogItem {
if item == nil {
return gamedomain.CatalogItem{}
}
return gamedomain.CatalogItem{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
SafeHeight: item.GetSafeHeight(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
}
}
func normalizePlatform(item gamedomain.Platform) (gamedomain.Platform, error) {
// gRPC 边界只做通用字段清洗,厂商 JSON 的具体语义留给 adapter 解析。
item.PlatformCode = strings.TrimSpace(item.PlatformCode)
item.PlatformName = strings.TrimSpace(item.PlatformName)
item.Status = normalizeStatus(item.Status)
item.APIBaseURL = strings.TrimSpace(item.APIBaseURL)
item.AdapterType = normalizeAdapterType(item.AdapterType)
item.CallbackSecretCiphertext = strings.TrimSpace(item.CallbackSecretCiphertext)
item.CallbackIPWhitelist = compactStrings(item.CallbackIPWhitelist)
item.AdapterConfigJSON = strings.TrimSpace(item.AdapterConfigJSON)
if item.AdapterConfigJSON == "" {
item.AdapterConfigJSON = "{}"
}
if item.PlatformCode == "" || item.PlatformName == "" || item.Status == "" {
return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform is incomplete")
}
if item.AdapterType == "" {
return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform adapter_type is invalid")
}
return item, nil
}
func normalizeCatalog(item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
item.GameID = strings.TrimSpace(item.GameID)
item.PlatformCode = strings.TrimSpace(item.PlatformCode)
item.ProviderGameID = strings.TrimSpace(item.ProviderGameID)
item.GameName = strings.TrimSpace(item.GameName)
item.Status = normalizeStatus(item.Status)
if item.LaunchMode == "" {
item.LaunchMode = gamedomain.LaunchModeFullScreen
}
if item.Orientation == "" {
item.Orientation = "portrait"
}
if item.SafeHeight < 0 {
item.SafeHeight = 0
}
if item.GameID == "" || item.PlatformCode == "" || item.ProviderGameID == "" || item.GameName == "" || item.Status == "" {
return gamedomain.CatalogItem{}, xerr.New(xerr.InvalidArgument, "game is incomplete")
}
return item, nil
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case gamedomain.StatusActive:
return gamedomain.StatusActive
case gamedomain.StatusMaintenance:
return gamedomain.StatusMaintenance
case gamedomain.StatusDisabled:
return gamedomain.StatusDisabled
default:
return ""
}
}
func normalizeAdapterType(adapterType string) string {
// 允许的 adapter 必须在这里登记,后台传错值时直接拒绝,避免落库后回调走错协议。
switch strings.ToLower(strings.TrimSpace(adapterType)) {
case gamedomain.AdapterYomiV4:
return gamedomain.AdapterYomiV4
case gamedomain.AdapterLeaderCCV1:
return gamedomain.AdapterLeaderCCV1
case gamedomain.AdapterZeeOneV1:
return gamedomain.AdapterZeeOneV1
case gamedomain.AdapterBaishunV1:
return gamedomain.AdapterBaishunV1
case gamedomain.AdapterVivaGamesV1:
return gamedomain.AdapterVivaGamesV1
case gamedomain.AdapterReyouV1:
return gamedomain.AdapterReyouV1
default:
return ""
}
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func durationFromMillis(value int64) time.Duration {
if value <= 0 {
return 0
}
return time.Duration(value) * time.Millisecond
}