public_experience_service.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "strings"
  6. "time"
  7. "cmr-backend/internal/apperr"
  8. "cmr-backend/internal/platform/security"
  9. "cmr-backend/internal/store/postgres"
  10. )
  11. const (
  12. GuestLaunchSource = "public-default-experience"
  13. GuestIdentityProvider = "guest_device"
  14. GuestIdentityType = "guest"
  15. )
  16. type PublicExperienceService struct {
  17. store *postgres.Store
  18. mapService *MapExperienceService
  19. eventService *EventService
  20. }
  21. type PublicEventPlayInput struct {
  22. EventPublicID string
  23. }
  24. type PublicLaunchEventInput struct {
  25. EventPublicID string `json:"-"`
  26. ReleaseID string `json:"releaseId,omitempty"`
  27. VariantID string `json:"variantId,omitempty"`
  28. ClientType string `json:"clientType"`
  29. DeviceKey string `json:"deviceKey"`
  30. }
  31. func NewPublicExperienceService(store *postgres.Store, mapService *MapExperienceService, eventService *EventService) *PublicExperienceService {
  32. return &PublicExperienceService{
  33. store: store,
  34. mapService: mapService,
  35. eventService: eventService,
  36. }
  37. }
  38. func (s *PublicExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
  39. return s.mapService.ListMaps(ctx, input)
  40. }
  41. func (s *PublicExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
  42. return s.mapService.GetMapDetail(ctx, mapPublicID)
  43. }
  44. func (s *PublicExperienceService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
  45. event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  46. if err != nil {
  47. return nil, err
  48. }
  49. if err := ensurePublicExperienceEvent(event); err != nil {
  50. return nil, err
  51. }
  52. return s.eventService.GetEventDetail(ctx, eventPublicID)
  53. }
  54. func (s *PublicExperienceService) GetEventPlay(ctx context.Context, input PublicEventPlayInput) (*EventPlayResult, error) {
  55. input.EventPublicID = strings.TrimSpace(input.EventPublicID)
  56. if input.EventPublicID == "" {
  57. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
  58. }
  59. event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
  60. if err != nil {
  61. return nil, err
  62. }
  63. if err := ensurePublicExperienceEvent(event); err != nil {
  64. return nil, err
  65. }
  66. result := &EventPlayResult{}
  67. result.Event.ID = event.PublicID
  68. result.Event.Slug = event.Slug
  69. result.Event.DisplayName = event.DisplayName
  70. result.Event.Summary = event.Summary
  71. result.Event.Status = event.Status
  72. variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
  73. result.Play.AssignmentMode = variantPlan.AssignmentMode
  74. if len(variantPlan.CourseVariants) > 0 {
  75. result.Play.CourseVariants = variantPlan.CourseVariants
  76. }
  77. if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
  78. result.Release = &struct {
  79. ID string `json:"id"`
  80. ConfigLabel string `json:"configLabel"`
  81. ManifestURL string `json:"manifestUrl"`
  82. ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
  83. RouteCode *string `json:"routeCode,omitempty"`
  84. }{
  85. ID: *event.CurrentReleasePubID,
  86. ConfigLabel: *event.ConfigLabel,
  87. ManifestURL: *event.ManifestURL,
  88. ManifestChecksumSha256: event.ManifestChecksum,
  89. RouteCode: event.RouteCode,
  90. }
  91. }
  92. result.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
  93. result.Runtime = buildRuntimeSummaryFromEvent(event)
  94. if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
  95. return nil, err
  96. } else {
  97. result.Preview = preview
  98. }
  99. result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
  100. if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
  101. return nil, err
  102. } else if enrichedPresentation != nil {
  103. result.CurrentPresentation = enrichedPresentation
  104. }
  105. result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
  106. if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
  107. return nil, err
  108. } else if enrichedBundle != nil {
  109. result.CurrentContentBundle = enrichedBundle
  110. }
  111. canLaunch, launchReason := evaluateEventLaunchReadiness(event)
  112. result.Play.CanLaunch = canLaunch
  113. if canLaunch {
  114. result.Play.LaunchSource = GuestLaunchSource
  115. result.Play.PrimaryAction = "start"
  116. result.Play.Reason = "guest can start default experience"
  117. return result, nil
  118. }
  119. result.Play.PrimaryAction = "unavailable"
  120. result.Play.Reason = launchReason
  121. return result, nil
  122. }
  123. func (s *PublicExperienceService) LaunchEvent(ctx context.Context, input PublicLaunchEventInput) (*LaunchEventResult, error) {
  124. input.EventPublicID = strings.TrimSpace(input.EventPublicID)
  125. input.ReleaseID = strings.TrimSpace(input.ReleaseID)
  126. input.VariantID = strings.TrimSpace(input.VariantID)
  127. input.DeviceKey = strings.TrimSpace(input.DeviceKey)
  128. if input.EventPublicID == "" || input.DeviceKey == "" {
  129. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id and deviceKey are required")
  130. }
  131. if err := validateClientType(input.ClientType); err != nil {
  132. return nil, err
  133. }
  134. event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
  135. if err != nil {
  136. return nil, err
  137. }
  138. if err := ensurePublicExperienceEvent(event); err != nil {
  139. return nil, err
  140. }
  141. if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
  142. return nil, launchReadinessError(reason)
  143. }
  144. if input.ReleaseID != "" && event.CurrentReleasePubID != nil && input.ReleaseID != *event.CurrentReleasePubID {
  145. return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
  146. }
  147. variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
  148. variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
  149. if err != nil {
  150. return nil, err
  151. }
  152. routeCode := event.RouteCode
  153. var assignmentMode *string
  154. var variantID *string
  155. var variantName *string
  156. if variant != nil {
  157. resultMode := variant.AssignmentMode
  158. assignmentMode = &resultMode
  159. variantID = &variant.ID
  160. variantName = &variant.Name
  161. if variant.RouteCode != nil {
  162. routeCode = variant.RouteCode
  163. }
  164. }
  165. tx, err := s.store.Begin(ctx)
  166. if err != nil {
  167. return nil, err
  168. }
  169. defer tx.Rollback(ctx)
  170. guestUser, err := s.findOrCreateGuestUser(ctx, tx, input.ClientType, input.DeviceKey)
  171. if err != nil {
  172. return nil, err
  173. }
  174. if err := s.store.TouchUserLogin(ctx, tx, guestUser.ID); err != nil {
  175. return nil, err
  176. }
  177. sessionPublicID, err := security.GeneratePublicID("sess")
  178. if err != nil {
  179. return nil, err
  180. }
  181. sessionToken, err := security.GenerateToken(32)
  182. if err != nil {
  183. return nil, err
  184. }
  185. sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
  186. session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
  187. SessionPublicID: sessionPublicID,
  188. UserID: guestUser.ID,
  189. EventID: event.ID,
  190. EventReleaseID: *event.CurrentReleaseID,
  191. DeviceKey: input.DeviceKey,
  192. ClientType: input.ClientType,
  193. AssignmentMode: assignmentMode,
  194. VariantID: variantID,
  195. VariantName: variantName,
  196. RouteCode: routeCode,
  197. SessionTokenHash: security.HashText(sessionToken),
  198. SessionTokenExpiresAt: sessionTokenExpiresAt,
  199. })
  200. if err != nil {
  201. return nil, err
  202. }
  203. if err := tx.Commit(ctx); err != nil {
  204. return nil, err
  205. }
  206. result := &LaunchEventResult{}
  207. result.Event.ID = event.PublicID
  208. result.Event.DisplayName = event.DisplayName
  209. result.Launch.Source = GuestLaunchSource
  210. result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
  211. result.Launch.Variant = variant
  212. result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
  213. result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
  214. if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
  215. return nil, err
  216. } else if enrichedPresentation != nil {
  217. result.Launch.Presentation = enrichedPresentation
  218. }
  219. result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
  220. if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
  221. return nil, err
  222. } else if enrichedBundle != nil {
  223. result.Launch.ContentBundle = enrichedBundle
  224. }
  225. result.Launch.Config.ConfigURL = *event.ManifestURL
  226. result.Launch.Config.ConfigLabel = *event.ConfigLabel
  227. result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
  228. result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
  229. result.Launch.Config.RouteCode = routeCode
  230. result.Launch.Business.Source = GuestLaunchSource
  231. result.Launch.Business.EventID = event.PublicID
  232. result.Launch.Business.SessionID = session.SessionPublicID
  233. result.Launch.Business.SessionToken = sessionToken
  234. result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
  235. result.Launch.Business.RouteCode = routeCode
  236. result.Launch.Business.IsGuest = true
  237. return result, nil
  238. }
  239. func (s *PublicExperienceService) findOrCreateGuestUser(ctx context.Context, tx postgres.Tx, clientType, deviceKey string) (*postgres.User, error) {
  240. providerSubject := clientType + ":" + deviceKey
  241. user, err := s.store.FindUserByProviderSubject(ctx, tx, GuestIdentityProvider, providerSubject)
  242. if err != nil {
  243. return nil, err
  244. }
  245. if user != nil {
  246. return user, nil
  247. }
  248. userPublicID, err := security.GeneratePublicID("usr")
  249. if err != nil {
  250. return nil, err
  251. }
  252. user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
  253. PublicID: userPublicID,
  254. Status: "active",
  255. })
  256. if err != nil {
  257. return nil, err
  258. }
  259. if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
  260. UserID: user.ID,
  261. IdentityType: GuestIdentityType,
  262. Provider: GuestIdentityProvider,
  263. ProviderSubj: providerSubject,
  264. ProfileJSON: "{}",
  265. }); err != nil {
  266. return nil, err
  267. }
  268. return user, nil
  269. }
  270. func ensurePublicExperienceEvent(event *postgres.Event) error {
  271. if event == nil {
  272. return apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  273. }
  274. if !event.IsDefaultExperience {
  275. return apperr.New(http.StatusForbidden, "event_not_public", "event is not available in guest mode")
  276. }
  277. return nil
  278. }