package grpc import ( "context" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" dicedomain "hyapp/services/game-service/internal/domain/dice" gamedomain "hyapp/services/game-service/internal/domain/game" diceservice "hyapp/services/game-service/internal/service/dice" gameservice "hyapp/services/game-service/internal/service/game" roomrpsservice "hyapp/services/game-service/internal/service/roomrps" ) // Server 把 game-service 用例层适配为 gRPC 契约。 type Server struct { gamev1.UnimplementedGameAppServiceServer gamev1.UnimplementedGameCallbackServiceServer gamev1.UnimplementedGameAdminServiceServer gamev1.UnimplementedGameCronServiceServer svc *gameservice.Service diceSvc *diceservice.Service roomRPSSvc *roomrpsservice.Service } func NewServer(svc *gameservice.Service, extras ...any) *Server { // extras 用类型分派保留旧测试构造方式;生产 app 会同时传入骰子服务和房内猜拳服务。 var ( diceSvc *diceservice.Service roomRPSSvc *roomrpsservice.Service ) for _, extra := range extras { switch typed := extra.(type) { case *diceservice.Service: diceSvc = typed case *roomrpsservice.Service: roomRPSSvc = typed } } return &Server{svc: svc, diceSvc: diceSvc, roomRPSSvc: roomRPSSvc} } 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) ListExploreWinners(ctx context.Context, req *gamev1.ListExploreWinnersRequest) (*gamev1.ListExploreWinnersResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) items, serverTimeMS, err := s.diceSvc.ListExploreWinners(ctx, req.GetMeta().GetAppCode(), req.GetPageSize()) if err != nil { return nil, xerr.ToGRPCError(err) } resp := &gamev1.ListExploreWinnersResponse{ServerTimeMs: serverTimeMS, Winners: make([]*gamev1.ExploreWinner, 0, len(items))} for _, item := range items { resp.Winners = append(resp.Winners, exploreWinnerToProto(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) GetDiceConfig(ctx context.Context, req *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) config, serverTimeMS, err := s.diceSvc.GetConfig(ctx, req.GetMeta().GetAppCode(), req.GetGameId()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceConfigResponse{Config: diceConfigToProto(config), ServerTimeMs: serverTimeMS}, nil } func (s *Server) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) match, serverTimeMS, err := s.diceSvc.Match(ctx, diceservice.MatchCommand{ AppCode: req.GetMeta().GetAppCode(), RequestID: req.GetMeta().GetRequestId(), UserID: req.GetUserId(), GameID: req.GetGameId(), RoomID: req.GetRoomId(), RegionID: req.GetRegionId(), StakeCoin: req.GetStakeCoin(), RPSGesture: req.GetRpsGesture(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil } func (s *Server) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } // gRPC 层只把 proto 字段转成 command;人数、金额、游戏状态和钱包逻辑都留给 service 层验证。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) match, serverTimeMS, err := s.diceSvc.CreateMatch(ctx, diceservice.CreateMatchCommand{ AppCode: req.GetMeta().GetAppCode(), RequestID: req.GetMeta().GetRequestId(), UserID: req.GetUserId(), GameID: req.GetGameId(), RoomID: req.GetRoomId(), RegionID: req.GetRegionId(), StakeCoin: req.GetStakeCoin(), MinPlayers: req.GetMinPlayers(), MaxPlayers: req.GetMaxPlayers(), RPSGesture: req.GetRpsGesture(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil } func (s *Server) JoinDiceMatch(ctx context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } // user_id 由 gateway 从登录态注入;transport 不允许用 actor_user_id 推导业务用户,避免调用方混淆身份字段。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) match, serverTimeMS, err := s.diceSvc.JoinMatch(ctx, diceservice.JoinMatchCommand{ AppCode: req.GetMeta().GetAppCode(), RequestID: req.GetMeta().GetRequestId(), UserID: req.GetUserId(), MatchID: req.GetMatchId(), GameID: req.GetGameId(), RPSGesture: req.GetRpsGesture(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil } func (s *Server) GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } // 查询接口仍要求 user_id,后续如果要做观战/隐私控制,可以在 service 层基于同一 command 加规则。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) match, serverTimeMS, err := s.diceSvc.GetMatch(ctx, diceservice.GetMatchCommand{ AppCode: req.GetMeta().GetAppCode(), UserID: req.GetUserId(), MatchID: req.GetMatchId(), GameID: req.GetGameId(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil } func (s *Server) RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } // roll 是唯一会触发钱包改账的骰子 RPC;gRPC 层不拆分扣款/派奖,避免客户端绕过局状态机。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) match, serverTimeMS, err := s.diceSvc.RollMatch(ctx, diceservice.RollMatchCommand{ AppCode: req.GetMeta().GetAppCode(), RequestID: req.GetMeta().GetRequestId(), UserID: req.GetUserId(), MatchID: req.GetMatchId(), GameID: req.GetGameId(), RPSGesture: req.GetRpsGesture(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil } func (s *Server) CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) match, serverTimeMS, err := s.diceSvc.CancelMatch(ctx, diceservice.CancelMatchCommand{ AppCode: req.GetMeta().GetAppCode(), RequestID: req.GetMeta().GetRequestId(), UserID: req.GetUserId(), MatchID: req.GetMatchId(), GameID: req.GetGameId(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil } func (s *Server) GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 配置只按 meta.app_code 读取;App/Admin 都不能在 body 里传 app_code 读写其它租户配置。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) config, serverTimeMS, err := s.roomRPSSvc.GetConfig(ctx, req.GetMeta().GetAppCode()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSConfigResponse{Config: config, ServerTimeMs: serverTimeMS}, nil } func (s *Server) ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 列表参数完整透传给 service;room_id、状态、用户筛选、时间窗和游标必须在同一个事实源内判断。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) items, nextCursor, pageSize, serverTimeMS, err := s.roomRPSSvc.ListChallenges(ctx, req) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.ListRoomRPSChallengesResponse{Challenges: items, NextCursor: nextCursor, PageSize: pageSize, ServerTimeMs: serverTimeMS}, nil } func (s *Server) CreateRoomRPSChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 发起人身份、房间、礼物和手势全部交给 service 原子校验;transport 不在这里复制状态规则。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) challenge, serverTimeMS, err := s.roomRPSSvc.CreateChallenge(ctx, req) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil } func (s *Server) AcceptRoomRPSChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 应战入口只做 proto 到 service 的调用;pending 锁定、本人不能应战、超时收敛和胜负结算都在 service 内完成。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) challenge, serverTimeMS, err := s.roomRPSSvc.AcceptChallenge(ctx, req) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil } func (s *Server) GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 详情是 IM 事件后的权威补拉接口;transport 不根据 user_id 推断展示内容,统一交给 service 返回事实。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) challenge, serverTimeMS, err := s.roomRPSSvc.GetChallenge(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil } func (s *Server) UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 后台写配置只落 room_rps 独立配置域;固定四档礼物、默认开启和倒计时默认值由 service 统一归一。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) config, serverTimeMS, err := s.roomRPSSvc.UpdateConfig(ctx, req.GetMeta().GetAppCode(), req.GetConfig()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSConfigResponse{Config: config, ServerTimeMs: serverTimeMS}, nil } func (s *Server) RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 结算重试只能从 settlement_failed 进入;transport 不允许 admin 传目标状态,避免后台绕过状态机。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) challenge, serverTimeMS, err := s.roomRPSSvc.RetrySettlement(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil } func (s *Server) ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) { if s == nil || s.roomRPSSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured")) } // 手动过期只允许 still-pending 且无人应战的挑战;服务端重查事实,不能相信后台按钮状态。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) challenge, serverTimeMS, err := s.roomRPSSvc.ExpireChallenge(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: 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) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) items, serverTimeMS, err := s.diceSvc.ListSelfGames(ctx, req.GetMeta().GetAppCode()) if err != nil { return nil, xerr.ToGRPCError(err) } resp := &gamev1.ListSelfGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.DiceConfig, 0, len(items))} for _, item := range items { resp.Games = append(resp.Games, diceConfigToProto(item)) } return resp, nil } func (s *Server) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) config := diceConfigFromProto(req.GetConfig()) config.AppCode = req.GetMeta().GetAppCode() saved, serverTimeMS, err := s.diceSvc.UpdateConfig(ctx, config) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceConfigResponse{Config: diceConfigToProto(saved), ServerTimeMs: serverTimeMS}, nil } func (s *Server) AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) adjustment, config, serverTimeMS, err := s.diceSvc.AdjustPool(ctx, dicedomain.PoolAdjustment{ AppCode: req.GetMeta().GetAppCode(), GameID: req.GetGameId(), Direction: req.GetDirection(), AmountCoin: req.GetAmountCoin(), Reason: req.GetReason(), CreatedBy: req.GetMeta().GetActorUserId(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.AdjustDicePoolResponse{Adjustment: dicePoolAdjustmentToProto(adjustment), Config: diceConfigToProto(config), ServerTimeMs: serverTimeMS}, nil } func (s *Server) ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) items, next, serverTimeMS, err := s.diceSvc.ListRobots(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetStatus(), req.GetPageSize(), req.GetCursor()) if err != nil { return nil, xerr.ToGRPCError(err) } resp := &gamev1.ListDiceRobotsResponse{NextCursor: next, ServerTimeMs: serverTimeMS, Robots: make([]*gamev1.DiceRobot, 0, len(items))} for _, item := range items { resp.Robots = append(resp.Robots, diceRobotToProto(item)) } return resp, nil } func (s *Server) RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) items, serverTimeMS, err := s.diceSvc.RegisterRobots(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserIds(), req.GetMeta().GetActorUserId()) if err != nil { return nil, xerr.ToGRPCError(err) } resp := &gamev1.RegisterDiceRobotsResponse{ServerTimeMs: serverTimeMS, Robots: make([]*gamev1.DiceRobot, 0, len(items))} for _, item := range items { resp.Robots = append(resp.Robots, diceRobotToProto(item)) } return resp, nil } func (s *Server) SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) item, serverTimeMS, err := s.diceSvc.SetRobotStatus(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserId(), req.GetStatus()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DiceRobotResponse{Robot: diceRobotToProto(item), ServerTimeMs: serverTimeMS}, nil } // DeleteDiceRobot 只对管理端暴露机器人池删除能力,删除范围由 app_code、game_id、user_id 三元组限定。 func (s *Server) DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error) { if s == nil || s.diceSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured")) } ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) serverTimeMS, err := s.diceSvc.DeleteRobot(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserId()) if err != nil { return nil, xerr.ToGRPCError(err) } return &gamev1.DeleteDiceRobotResponse{Deleted: true, ServerTimeMs: serverTimeMS}, 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) GetLaunchSessionByTokenScope(context.Context, string, string, 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 diceMatchToProto(match dicedomain.Match) *gamev1.DiceMatch { return diceMatchToProtoAt(match, time.Now().UnixMilli()) } func diceMatchToProtoAt(match dicedomain.Match, nowMS int64) *gamev1.DiceMatch { participants := make([]*gamev1.DiceParticipant, 0, len(match.Participants)) for _, participant := range match.Participants { participants = append(participants, diceParticipantToProto(participant)) } phase, phaseDeadlineMS := dicePhase(match, nowMS) // proto 只暴露客户端需要的局事实;订单 ID 和 provider 快照留在 MySQL,避免把账务内部键泄漏到 H5。 return &gamev1.DiceMatch{ AppCode: match.AppCode, MatchId: match.MatchID, GameId: match.GameID, RoomId: match.RoomID, RegionId: match.RegionID, MinPlayers: match.MinPlayers, MaxPlayers: match.MaxPlayers, CurrentPlayers: match.CurrentPlayers, StakeCoin: match.StakeCoin, RoundNo: match.RoundNo, Status: match.Status, Result: match.Result, Participants: participants, JoinDeadlineMs: match.JoinDeadlineMS, CreatedAtMs: match.CreatedAtMS, UpdatedAtMs: match.UpdatedAtMS, SettledAtMs: match.SettledAtMS, Phase: phase, PhaseDeadlineMs: phaseDeadlineMS, FeeBps: match.FeeBPS, PoolBps: match.PoolBPS, MatchMode: match.MatchMode, ForcedResult: match.ForcedResult, ReadyAtMs: match.ReadyAtMS, CanceledAtMs: match.CanceledAtMS, PoolDeltaCoin: match.PoolDeltaCoin, } } func diceParticipantToProto(participant dicedomain.Participant) *gamev1.DiceParticipant { // balance_after 是最近一次钱包操作后的快照,供 H5 结算弹层刷新余额;不是局内可计算字段。 return &gamev1.DiceParticipant{ UserId: participant.UserID, SeatNo: participant.SeatNo, Status: participant.Status, StakeCoin: participant.StakeCoin, DicePoints: participant.DicePoints, Result: participant.Result, PayoutCoin: participant.PayoutCoin, BalanceAfter: participant.BalanceAfter, JoinedAtMs: participant.JoinedAtMS, UpdatedAtMs: participant.UpdatedAtMS, ParticipantType: participant.ParticipantType, IsRobot: participant.ParticipantType == dicedomain.ParticipantTypeRobot, RpsGesture: participant.RPSGesture, } } func diceConfigToProto(config dicedomain.Config) *gamev1.DiceConfig { options := make([]*gamev1.DiceStakeOption, 0, len(config.StakeOptions)) for _, option := range config.StakeOptions { options = append(options, &gamev1.DiceStakeOption{StakeCoin: option.StakeCoin, Enabled: option.Enabled, SortOrder: option.SortOrder}) } return &gamev1.DiceConfig{ AppCode: config.AppCode, GameId: config.GameID, Status: config.Status, StakeOptions: options, FeeBps: config.FeeBPS, PoolBps: config.PoolBPS, MinPlayers: config.MinPlayers, MaxPlayers: config.MaxPlayers, RobotEnabled: config.RobotEnabled, RobotMatchWaitMs: config.RobotMatchWaitMS, PoolBalanceCoin: config.PoolBalanceCoin, CreatedAtMs: config.CreatedAtMS, UpdatedAtMs: config.UpdatedAtMS, } } func diceConfigFromProto(config *gamev1.DiceConfig) dicedomain.Config { if config == nil { return dicedomain.Config{} } options := make([]dicedomain.StakeOption, 0, len(config.GetStakeOptions())) for _, option := range config.GetStakeOptions() { options = append(options, dicedomain.StakeOption{StakeCoin: option.GetStakeCoin(), Enabled: option.GetEnabled(), SortOrder: option.GetSortOrder()}) } return dicedomain.Config{ AppCode: config.GetAppCode(), GameID: config.GetGameId(), Status: config.GetStatus(), StakeOptions: options, FeeBPS: config.GetFeeBps(), PoolBPS: config.GetPoolBps(), MinPlayers: config.GetMinPlayers(), MaxPlayers: config.GetMaxPlayers(), RobotEnabled: config.GetRobotEnabled(), RobotMatchWaitMS: config.GetRobotMatchWaitMs(), } } func dicePoolAdjustmentToProto(adjustment dicedomain.PoolAdjustment) *gamev1.DicePoolAdjustment { return &gamev1.DicePoolAdjustment{ AdjustmentId: adjustment.AdjustmentID, GameId: adjustment.GameID, AmountCoin: adjustment.AmountCoin, Direction: adjustment.Direction, Reason: adjustment.Reason, BalanceAfter: adjustment.BalanceAfter, CreatedAtMs: adjustment.CreatedAtMS, } } func diceRobotToProto(robot dicedomain.Robot) *gamev1.DiceRobot { return &gamev1.DiceRobot{ AppCode: robot.AppCode, GameId: robot.GameID, UserId: robot.UserID, Status: robot.Status, CreatedByAdminId: robot.CreatedByAdminID, CreatedAtMs: robot.CreatedAtMS, UpdatedAtMs: robot.UpdatedAtMS, } } func exploreWinnerToProto(item dicedomain.ExploreWinner) *gamev1.ExploreWinner { return &gamev1.ExploreWinner{ WinId: item.WinID, GameCode: item.GameCode, GameId: item.GameID, UserId: item.UserID, DisplayName: item.DisplayName, AvatarUrl: item.AvatarURL, CoinAmount: item.CoinAmount, WonAtMs: item.WonAtMS, } } func dicePhase(match dicedomain.Match, nowMS int64) (string, int64) { switch match.Status { case dicedomain.MatchStatusCanceled: return dicedomain.MatchPhaseCanceled, match.CanceledAtMS case dicedomain.MatchStatusFailed: return dicedomain.MatchPhaseFailed, match.UpdatedAtMS case dicedomain.MatchStatusSettled: return dicedomain.MatchPhaseSettled, match.SettledAtMS + dicedomain.SettlementShowMillis } if match.Result == dicedomain.ParticipantResultDraw { // 和局不是终局,只给客户端一个 2 秒比点展示窗口,窗口结束后 H5 可以继续调用 roll。 return dicedomain.MatchPhaseComparing, match.UpdatedAtMS + dicedomain.DrawRerollMillis } if match.ReadyAtMS <= 0 { return dicedomain.MatchPhaseWaiting, match.JoinDeadlineMS } countdownEnd := match.ReadyAtMS + dicedomain.CountdownMillis rollingEnd := countdownEnd + dicedomain.RollingMillis comparingEnd := rollingEnd + dicedomain.ComparingMillis if nowMS < countdownEnd { return dicedomain.MatchPhaseCountdown, countdownEnd } if nowMS < rollingEnd { return dicedomain.MatchPhaseRolling, rollingEnd } return dicedomain.MatchPhaseComparing, comparingEnd } 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 }