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) 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) 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 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 }