推进活动系统最小成品闭环与游客体验

This commit is contained in:
2026-04-07 19:05:18 +08:00
parent 1a6008449e
commit 6cd16f08dd
102 changed files with 16087 additions and 3556 deletions

View File

@@ -0,0 +1,303 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
const (
GuestLaunchSource = "public-default-experience"
GuestIdentityProvider = "guest_device"
GuestIdentityType = "guest"
)
type PublicExperienceService struct {
store *postgres.Store
mapService *MapExperienceService
eventService *EventService
}
type PublicEventPlayInput struct {
EventPublicID string
}
type PublicLaunchEventInput struct {
EventPublicID string `json:"-"`
ReleaseID string `json:"releaseId,omitempty"`
VariantID string `json:"variantId,omitempty"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
func NewPublicExperienceService(store *postgres.Store, mapService *MapExperienceService, eventService *EventService) *PublicExperienceService {
return &PublicExperienceService{
store: store,
mapService: mapService,
eventService: eventService,
}
}
func (s *PublicExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
return s.mapService.ListMaps(ctx, input)
}
func (s *PublicExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
return s.mapService.GetMapDetail(ctx, mapPublicID)
}
func (s *PublicExperienceService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if err := ensurePublicExperienceEvent(event); err != nil {
return nil, err
}
return s.eventService.GetEventDetail(ctx, eventPublicID)
}
func (s *PublicExperienceService) GetEventPlay(ctx context.Context, input PublicEventPlayInput) (*EventPlayResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
if input.EventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if err := ensurePublicExperienceEvent(event); err != nil {
return nil, err
}
result := &EventPlayResult{}
result.Event.ID = event.PublicID
result.Event.Slug = event.Slug
result.Event.DisplayName = event.DisplayName
result.Event.Summary = event.Summary
result.Event.Status = event.Status
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
result.Play.AssignmentMode = variantPlan.AssignmentMode
if len(variantPlan.CourseVariants) > 0 {
result.Play.CourseVariants = variantPlan.CourseVariants
}
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
result.Release = &struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}{
ID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
result.Runtime = buildRuntimeSummaryFromEvent(event)
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
return nil, err
} else {
result.Preview = preview
}
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
canLaunch, launchReason := evaluateEventLaunchReadiness(event)
result.Play.CanLaunch = canLaunch
if canLaunch {
result.Play.LaunchSource = GuestLaunchSource
result.Play.PrimaryAction = "start"
result.Play.Reason = "guest can start default experience"
return result, nil
}
result.Play.PrimaryAction = "unavailable"
result.Play.Reason = launchReason
return result, nil
}
func (s *PublicExperienceService) LaunchEvent(ctx context.Context, input PublicLaunchEventInput) (*LaunchEventResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
input.VariantID = strings.TrimSpace(input.VariantID)
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
if input.EventPublicID == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id and deviceKey are required")
}
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if err := ensurePublicExperienceEvent(event); err != nil {
return nil, err
}
if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
return nil, launchReadinessError(reason)
}
if input.ReleaseID != "" && event.CurrentReleasePubID != nil && input.ReleaseID != *event.CurrentReleasePubID {
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
}
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
if err != nil {
return nil, err
}
routeCode := event.RouteCode
var assignmentMode *string
var variantID *string
var variantName *string
if variant != nil {
resultMode := variant.AssignmentMode
assignmentMode = &resultMode
variantID = &variant.ID
variantName = &variant.Name
if variant.RouteCode != nil {
routeCode = variant.RouteCode
}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
guestUser, err := s.findOrCreateGuestUser(ctx, tx, input.ClientType, input.DeviceKey)
if err != nil {
return nil, err
}
if err := s.store.TouchUserLogin(ctx, tx, guestUser.ID); err != nil {
return nil, err
}
sessionPublicID, err := security.GeneratePublicID("sess")
if err != nil {
return nil, err
}
sessionToken, err := security.GenerateToken(32)
if err != nil {
return nil, err
}
sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
SessionPublicID: sessionPublicID,
UserID: guestUser.ID,
EventID: event.ID,
EventReleaseID: *event.CurrentReleaseID,
DeviceKey: input.DeviceKey,
ClientType: input.ClientType,
AssignmentMode: assignmentMode,
VariantID: variantID,
VariantName: variantName,
RouteCode: routeCode,
SessionTokenHash: security.HashText(sessionToken),
SessionTokenExpiresAt: sessionTokenExpiresAt,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := &LaunchEventResult{}
result.Event.ID = event.PublicID
result.Event.DisplayName = event.DisplayName
result.Launch.Source = GuestLaunchSource
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
result.Launch.Variant = variant
result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.Launch.Presentation = enrichedPresentation
}
result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.Launch.ContentBundle = enrichedBundle
}
result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
result.Launch.Config.RouteCode = routeCode
result.Launch.Business.Source = GuestLaunchSource
result.Launch.Business.EventID = event.PublicID
result.Launch.Business.SessionID = session.SessionPublicID
result.Launch.Business.SessionToken = sessionToken
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Launch.Business.RouteCode = routeCode
result.Launch.Business.IsGuest = true
return result, nil
}
func (s *PublicExperienceService) findOrCreateGuestUser(ctx context.Context, tx postgres.Tx, clientType, deviceKey string) (*postgres.User, error) {
providerSubject := clientType + ":" + deviceKey
user, err := s.store.FindUserByProviderSubject(ctx, tx, GuestIdentityProvider, providerSubject)
if err != nil {
return nil, err
}
if user != nil {
return user, nil
}
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: GuestIdentityType,
Provider: GuestIdentityProvider,
ProviderSubj: providerSubject,
ProfileJSON: "{}",
}); err != nil {
return nil, err
}
return user, nil
}
func ensurePublicExperienceEvent(event *postgres.Event) error {
if event == nil {
return apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
if !event.IsDefaultExperience {
return apperr.New(http.StatusForbidden, "event_not_public", "event is not available in guest mode")
}
return nil
}