package service import ( "context" "net/http" "strings" "time" "cmr-backend/internal/apperr" "cmr-backend/internal/store/postgres" ) type HomeService struct { store *postgres.Store } type ListCardsInput struct { ChannelCode string ChannelType string PlatformAppID string TenantCode string Slot string Limit int } type CardResult struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Subtitle *string `json:"subtitle,omitempty"` Summary *string `json:"summary,omitempty"` CoverURL *string `json:"coverUrl,omitempty"` DisplaySlot string `json:"displaySlot"` DisplayPriority int `json:"displayPriority"` Status string `json:"status"` StatusCode string `json:"statusCode"` TimeWindow string `json:"timeWindow"` CTAText string `json:"ctaText"` IsDefaultExperience bool `json:"isDefaultExperience"` ShowInEventList bool `json:"showInEventList"` EventType *string `json:"eventType,omitempty"` CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` Event *struct { ID string `json:"id"` DisplayName string `json:"displayName"` Summary *string `json:"summary,omitempty"` Status *string `json:"status,omitempty"` } `json:"event,omitempty"` HTMLURL *string `json:"htmlUrl,omitempty"` } type HomeResult struct { Tenant struct { ID string `json:"id"` Code string `json:"code"` Name string `json:"name"` } `json:"tenant"` Channel struct { ID string `json:"id"` Code string `json:"code"` Type string `json:"type"` PlatformAppID *string `json:"platformAppId,omitempty"` DisplayName string `json:"displayName"` Status string `json:"status"` IsDefault bool `json:"isDefault"` } `json:"channel"` Cards []CardResult `json:"cards"` } func NewHomeService(store *postgres.Store) *HomeService { return &HomeService{store: store} } func (s *HomeService) ListCards(ctx context.Context, input ListCardsInput) ([]CardResult, error) { entry, err := s.resolveEntry(ctx, input) if err != nil { return nil, err } cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit) if err != nil { return nil, err } return mapCards(cards), nil } func (s *HomeService) GetHome(ctx context.Context, input ListCardsInput) (*HomeResult, error) { entry, err := s.resolveEntry(ctx, input) if err != nil { return nil, err } cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit) if err != nil { return nil, err } result := &HomeResult{ Cards: mapCards(cards), } result.Tenant.ID = entry.TenantID result.Tenant.Code = entry.TenantCode result.Tenant.Name = entry.TenantName result.Channel.ID = entry.ID result.Channel.Code = entry.ChannelCode result.Channel.Type = entry.ChannelType result.Channel.PlatformAppID = entry.PlatformAppID result.Channel.DisplayName = entry.DisplayName result.Channel.Status = entry.Status result.Channel.IsDefault = entry.IsDefault return result, nil } func (s *HomeService) resolveEntry(ctx context.Context, input ListCardsInput) (*postgres.EntryChannel, error) { entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{ ChannelCode: strings.TrimSpace(input.ChannelCode), ChannelType: strings.TrimSpace(input.ChannelType), PlatformAppID: strings.TrimSpace(input.PlatformAppID), TenantCode: strings.TrimSpace(input.TenantCode), }) if err != nil { return nil, err } if entry == nil { return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found") } return entry, nil } func normalizeSlot(slot string) string { slot = strings.TrimSpace(slot) if slot == "" { return "home_primary" } return slot } func mapCards(cards []postgres.Card) []CardResult { results := make([]CardResult, 0, len(cards)) for _, card := range cards { statusCode, statusText := deriveCardStatus(card) item := CardResult{ ID: card.PublicID, Type: card.CardType, Title: fallbackCardTitle(card.Title), Subtitle: card.Subtitle, Summary: fallbackCardSummary(card.EventSummary), CoverURL: card.CoverURL, DisplaySlot: card.DisplaySlot, DisplayPriority: card.DisplayPriority, Status: statusText, StatusCode: statusCode, TimeWindow: deriveCardTimeWindow(card), CTAText: deriveCardCTAText(card, statusCode), IsDefaultExperience: card.IsDefaultExperience, ShowInEventList: card.ShowInEventList, EventType: deriveCardEventType(card), CurrentPresentation: buildCardPresentationSummary(card), CurrentContentBundle: buildCardContentBundleSummary(card), HTMLURL: card.HTMLURL, } if card.EventPublicID != nil || card.EventDisplayName != nil { item.Event = &struct { ID string `json:"id"` DisplayName string `json:"displayName"` Summary *string `json:"summary,omitempty"` Status *string `json:"status,omitempty"` }{ Summary: card.EventSummary, Status: card.EventStatus, } if card.EventPublicID != nil { item.Event.ID = *card.EventPublicID } if card.EventDisplayName != nil { item.Event.DisplayName = *card.EventDisplayName } } results = append(results, item) } return results } func fallbackCardTitle(title string) string { title = strings.TrimSpace(title) if title == "" { return "未命名活动" } return title } func fallbackCardSummary(summary *string) *string { if summary != nil && strings.TrimSpace(*summary) != "" { return summary } text := "当前暂无活动摘要" return &text } func deriveCardStatus(card postgres.Card) (string, string) { if card.EventStatus == nil { return "pending", "状态待确认" } switch strings.TrimSpace(*card.EventStatus) { case "active": if card.EventCurrentReleasePubID == nil { return "upcoming", "即将开始" } if card.EventRuntimeBindingID == nil || card.EventPresentationID == nil || card.EventContentBundleID == nil { return "upcoming", "即将开始" } return "running", "进行中" case "archived", "disabled", "inactive": return "ended", "已结束" default: return "pending", "状态待确认" } } func deriveCardTimeWindow(card postgres.Card) string { if card.StartsAt == nil && card.EndsAt == nil { return "时间待公布" } const layout = "01-02 15:04" switch { case card.StartsAt != nil && card.EndsAt != nil: return card.StartsAt.Local().Format(layout) + " - " + card.EndsAt.Local().Format(layout) case card.StartsAt != nil: return "开始于 " + card.StartsAt.Local().Format(layout) default: return "截止至 " + card.EndsAt.Local().Format(layout) } } func deriveCardCTAText(card postgres.Card, statusCode string) string { if card.IsDefaultExperience { return "进入体验" } switch statusCode { case "running": return "进入活动" case "ended": return "查看回顾" default: return "查看详情" } } func deriveCardEventType(card postgres.Card) *string { if card.EventReleasePayloadJSON != nil { payload, err := decodeJSONObject(*card.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(card.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual { text := "多赛道" return &text } } } if card.IsDefaultExperience { text := "体验活动" return &text } return nil } func buildCardPresentationSummary(card postgres.Card) *PresentationSummaryView { if card.EventPresentationID == nil { return nil } summary := &PresentationSummaryView{ PresentationID: *card.EventPresentationID, Name: card.EventPresentationName, PresentationType: card.EventPresentationType, } if card.EventPresentationSchemaJSON != nil && strings.TrimSpace(*card.EventPresentationSchemaJSON) != "" { if schema, err := decodeJSONObject(*card.EventPresentationSchemaJSON); err == nil { summary.TemplateKey = readStringField(schema, "templateKey") summary.Version = readStringField(schema, "version") } } return summary } func buildCardContentBundleSummary(card postgres.Card) *ContentBundleSummaryView { if card.EventContentBundleID == nil { return nil } summary := &ContentBundleSummaryView{ ContentBundleID: *card.EventContentBundleID, Name: card.EventContentBundleName, EntryURL: card.EventContentEntryURL, AssetRootURL: card.EventContentAssetRootURL, } if card.EventContentMetadataJSON != nil && strings.TrimSpace(*card.EventContentMetadataJSON) != "" { if metadata, err := decodeJSONObject(*card.EventContentMetadataJSON); err == nil { summary.BundleType = readStringField(metadata, "bundleType") summary.Version = readStringField(metadata, "version") } } return summary }