package service import ( "context" "net/http" "strings" "cmr-backend/internal/apperr" "cmr-backend/internal/store/postgres" ) type MapExperienceService struct { store *postgres.Store } type ListExperienceMapsInput struct { Limit int } type ExperienceMapSummary struct { PlaceID string `json:"placeId"` PlaceName string `json:"placeName"` MapID string `json:"mapId"` MapName string `json:"mapName"` CoverURL *string `json:"coverUrl,omitempty"` Summary *string `json:"summary,omitempty"` DefaultExperienceCount int `json:"defaultExperienceCount"` DefaultExperienceEventIDs []string `json:"defaultExperienceEventIds"` } type ExperienceMapDetail struct { PlaceID string `json:"placeId"` PlaceName string `json:"placeName"` MapID string `json:"mapId"` MapName string `json:"mapName"` CoverURL *string `json:"coverUrl,omitempty"` Summary *string `json:"summary,omitempty"` TileBaseURL *string `json:"tileBaseUrl,omitempty"` TileMetaURL *string `json:"tileMetaUrl,omitempty"` DefaultExperienceCount int `json:"defaultExperienceCount"` DefaultExperiences []ExperienceEventSummary `json:"defaultExperiences"` } type ExperienceEventSummary struct { EventID string `json:"eventId"` Title string `json:"title"` Subtitle *string `json:"subtitle,omitempty"` EventType *string `json:"eventType,omitempty"` Status string `json:"status"` StatusCode string `json:"statusCode"` CTAText string `json:"ctaText"` IsDefaultExperience bool `json:"isDefaultExperience"` ShowInEventList bool `json:"showInEventList"` CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` } func NewMapExperienceService(store *postgres.Store) *MapExperienceService { return &MapExperienceService{store: store} } func (s *MapExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) { rows, err := s.store.ListMapExperienceRows(ctx, input.Limit) if err != nil { return nil, err } return mapExperienceSummaries(rows), nil } func (s *MapExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) { mapPublicID = strings.TrimSpace(mapPublicID) if mapPublicID == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_map_id", "map id is required") } rows, err := s.store.ListMapExperienceRowsByMapPublicID(ctx, mapPublicID) if err != nil { return nil, err } if len(rows) == 0 { return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found") } return buildMapExperienceDetail(rows), nil } func mapExperienceSummaries(rows []postgres.MapExperienceRow) []ExperienceMapSummary { ordered := make([]string, 0, len(rows)) index := make(map[string]*ExperienceMapSummary) for _, row := range rows { item, ok := index[row.MapAssetPublicID] if !ok { summary := &ExperienceMapSummary{ PlaceID: row.PlacePublicID, PlaceName: row.PlaceName, MapID: row.MapAssetPublicID, MapName: row.MapAssetName, CoverURL: row.MapCoverURL, Summary: normalizeOptionalText(row.MapSummary), DefaultExperienceEventIDs: []string{}, } index[row.MapAssetPublicID] = summary ordered = append(ordered, row.MapAssetPublicID) item = summary } if row.EventPublicID != nil && row.EventIsDefaultExperience { if !containsString(item.DefaultExperienceEventIDs, *row.EventPublicID) { item.DefaultExperienceEventIDs = append(item.DefaultExperienceEventIDs, *row.EventPublicID) item.DefaultExperienceCount++ } } } result := make([]ExperienceMapSummary, 0, len(ordered)) for _, id := range ordered { result = append(result, *index[id]) } return result } func buildMapExperienceDetail(rows []postgres.MapExperienceRow) *ExperienceMapDetail { first := rows[0] result := &ExperienceMapDetail{ PlaceID: first.PlacePublicID, PlaceName: first.PlaceName, MapID: first.MapAssetPublicID, MapName: first.MapAssetName, CoverURL: first.MapCoverURL, Summary: normalizeOptionalText(first.MapSummary), TileBaseURL: first.TileBaseURL, TileMetaURL: first.TileMetaURL, DefaultExperiences: make([]ExperienceEventSummary, 0, 4), } seen := make(map[string]struct{}) for _, row := range rows { if row.EventPublicID == nil || !row.EventIsDefaultExperience { continue } if _, ok := seen[*row.EventPublicID]; ok { continue } seen[*row.EventPublicID] = struct{}{} result.DefaultExperiences = append(result.DefaultExperiences, buildExperienceEventSummary(row)) } result.DefaultExperienceCount = len(result.DefaultExperiences) return result } func buildExperienceEventSummary(row postgres.MapExperienceRow) ExperienceEventSummary { statusCode, statusText := deriveExperienceEventStatus(row) return ExperienceEventSummary{ EventID: valueOrEmpty(row.EventPublicID), Title: fallbackText(row.EventDisplayName, "未命名活动"), Subtitle: normalizeOptionalText(row.EventSummary), EventType: deriveExperienceEventType(row), Status: statusText, StatusCode: statusCode, CTAText: deriveExperienceEventCTA(statusCode, row.EventIsDefaultExperience), IsDefaultExperience: row.EventIsDefaultExperience, ShowInEventList: row.EventShowInEventList, CurrentPresentation: buildPresentationSummaryFromMapExperienceRow(row), CurrentContentBundle: buildContentBundleSummaryFromMapExperienceRow(row), } } func deriveExperienceEventStatus(row postgres.MapExperienceRow) (string, string) { if row.EventStatus == nil { return "pending", "状态待确认" } switch strings.TrimSpace(*row.EventStatus) { case "active": if row.EventReleasePayloadJSON == nil || strings.TrimSpace(*row.EventReleasePayloadJSON) == "" { return "upcoming", "即将开始" } if row.EventPresentationID == nil || row.EventContentBundleID == nil { return "upcoming", "即将开始" } return "running", "进行中" case "archived", "disabled", "inactive": return "ended", "已结束" default: return "pending", "状态待确认" } } func deriveExperienceEventCTA(statusCode string, isDefault bool) string { if isDefault { return "进入体验" } switch statusCode { case "running": return "进入活动" case "ended": return "查看回顾" default: return "查看详情" } } func deriveExperienceEventType(row postgres.MapExperienceRow) *string { if row.EventReleasePayloadJSON != nil { payload, err := decodeJSONObject(*row.EventReleasePayloadJSON) if err == nil { if game, ok := payload["game"].(map[string]any); ok { if rawMode, ok := game["mode"].(string); ok { switch strings.TrimSpace(rawMode) { case "classic-sequential": text := "顺序赛" return &text case "score-o": text := "积分赛" return &text } } } if plan := resolveVariantPlan(row.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual { text := "多赛道" return &text } } } if row.EventIsDefaultExperience { text := "体验活动" return &text } return nil } func buildPresentationSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *PresentationSummaryView { if row.EventPresentationID == nil { return nil } summary := &PresentationSummaryView{ PresentationID: *row.EventPresentationID, Name: row.EventPresentationName, PresentationType: row.EventPresentationType, } if row.EventPresentationSchema != nil && strings.TrimSpace(*row.EventPresentationSchema) != "" { if schema, err := decodeJSONObject(*row.EventPresentationSchema); err == nil { summary.TemplateKey = readStringField(schema, "templateKey") summary.Version = readStringField(schema, "version") } } return summary } func buildContentBundleSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *ContentBundleSummaryView { if row.EventContentBundleID == nil { return nil } summary := &ContentBundleSummaryView{ ContentBundleID: *row.EventContentBundleID, Name: row.EventContentBundleName, EntryURL: row.EventContentEntryURL, AssetRootURL: row.EventContentAssetRootURL, } if row.EventContentMetadataJSON != nil && strings.TrimSpace(*row.EventContentMetadataJSON) != "" { if metadata, err := decodeJSONObject(*row.EventContentMetadataJSON); err == nil { summary.BundleType = readStringField(metadata, "bundleType") summary.Version = readStringField(metadata, "version") } } return summary } func normalizeOptionalText(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } func fallbackText(value *string, fallback string) string { if value == nil { return fallback } trimmed := strings.TrimSpace(*value) if trimmed == "" { return fallback } return trimmed } func valueOrEmpty(value *string) string { if value == nil { return "" } return *value } func containsString(values []string, target string) bool { for _, item := range values { if item == target { return true } } return false }