home_service.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "strings"
  6. "time"
  7. "cmr-backend/internal/apperr"
  8. "cmr-backend/internal/store/postgres"
  9. )
  10. type HomeService struct {
  11. store *postgres.Store
  12. }
  13. type ListCardsInput struct {
  14. ChannelCode string
  15. ChannelType string
  16. PlatformAppID string
  17. TenantCode string
  18. Slot string
  19. Limit int
  20. }
  21. type CardResult struct {
  22. ID string `json:"id"`
  23. Type string `json:"type"`
  24. Title string `json:"title"`
  25. Subtitle *string `json:"subtitle,omitempty"`
  26. Summary *string `json:"summary,omitempty"`
  27. CoverURL *string `json:"coverUrl,omitempty"`
  28. DisplaySlot string `json:"displaySlot"`
  29. DisplayPriority int `json:"displayPriority"`
  30. Status string `json:"status"`
  31. StatusCode string `json:"statusCode"`
  32. TimeWindow string `json:"timeWindow"`
  33. CTAText string `json:"ctaText"`
  34. IsDefaultExperience bool `json:"isDefaultExperience"`
  35. EventType *string `json:"eventType,omitempty"`
  36. CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
  37. CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
  38. Event *struct {
  39. ID string `json:"id"`
  40. DisplayName string `json:"displayName"`
  41. Summary *string `json:"summary,omitempty"`
  42. Status *string `json:"status,omitempty"`
  43. } `json:"event,omitempty"`
  44. HTMLURL *string `json:"htmlUrl,omitempty"`
  45. }
  46. type HomeResult struct {
  47. Tenant struct {
  48. ID string `json:"id"`
  49. Code string `json:"code"`
  50. Name string `json:"name"`
  51. } `json:"tenant"`
  52. Channel struct {
  53. ID string `json:"id"`
  54. Code string `json:"code"`
  55. Type string `json:"type"`
  56. PlatformAppID *string `json:"platformAppId,omitempty"`
  57. DisplayName string `json:"displayName"`
  58. Status string `json:"status"`
  59. IsDefault bool `json:"isDefault"`
  60. } `json:"channel"`
  61. Cards []CardResult `json:"cards"`
  62. }
  63. func NewHomeService(store *postgres.Store) *HomeService {
  64. return &HomeService{store: store}
  65. }
  66. func (s *HomeService) ListCards(ctx context.Context, input ListCardsInput) ([]CardResult, error) {
  67. entry, err := s.resolveEntry(ctx, input)
  68. if err != nil {
  69. return nil, err
  70. }
  71. cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
  72. if err != nil {
  73. return nil, err
  74. }
  75. return mapCards(cards), nil
  76. }
  77. func (s *HomeService) GetHome(ctx context.Context, input ListCardsInput) (*HomeResult, error) {
  78. entry, err := s.resolveEntry(ctx, input)
  79. if err != nil {
  80. return nil, err
  81. }
  82. cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
  83. if err != nil {
  84. return nil, err
  85. }
  86. result := &HomeResult{
  87. Cards: mapCards(cards),
  88. }
  89. result.Tenant.ID = entry.TenantID
  90. result.Tenant.Code = entry.TenantCode
  91. result.Tenant.Name = entry.TenantName
  92. result.Channel.ID = entry.ID
  93. result.Channel.Code = entry.ChannelCode
  94. result.Channel.Type = entry.ChannelType
  95. result.Channel.PlatformAppID = entry.PlatformAppID
  96. result.Channel.DisplayName = entry.DisplayName
  97. result.Channel.Status = entry.Status
  98. result.Channel.IsDefault = entry.IsDefault
  99. return result, nil
  100. }
  101. func (s *HomeService) resolveEntry(ctx context.Context, input ListCardsInput) (*postgres.EntryChannel, error) {
  102. entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
  103. ChannelCode: strings.TrimSpace(input.ChannelCode),
  104. ChannelType: strings.TrimSpace(input.ChannelType),
  105. PlatformAppID: strings.TrimSpace(input.PlatformAppID),
  106. TenantCode: strings.TrimSpace(input.TenantCode),
  107. })
  108. if err != nil {
  109. return nil, err
  110. }
  111. if entry == nil {
  112. return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
  113. }
  114. return entry, nil
  115. }
  116. func normalizeSlot(slot string) string {
  117. slot = strings.TrimSpace(slot)
  118. if slot == "" {
  119. return "home_primary"
  120. }
  121. return slot
  122. }
  123. func mapCards(cards []postgres.Card) []CardResult {
  124. results := make([]CardResult, 0, len(cards))
  125. for _, card := range cards {
  126. statusCode, statusText := deriveCardStatus(card)
  127. item := CardResult{
  128. ID: card.PublicID,
  129. Type: card.CardType,
  130. Title: fallbackCardTitle(card.Title),
  131. Subtitle: card.Subtitle,
  132. Summary: fallbackCardSummary(card.EventSummary),
  133. CoverURL: card.CoverURL,
  134. DisplaySlot: card.DisplaySlot,
  135. DisplayPriority: card.DisplayPriority,
  136. Status: statusText,
  137. StatusCode: statusCode,
  138. TimeWindow: deriveCardTimeWindow(card),
  139. CTAText: deriveCardCTAText(card, statusCode),
  140. IsDefaultExperience: card.IsDefaultExperience,
  141. EventType: deriveCardEventType(card),
  142. CurrentPresentation: buildCardPresentationSummary(card),
  143. CurrentContentBundle: buildCardContentBundleSummary(card),
  144. HTMLURL: card.HTMLURL,
  145. }
  146. if card.EventPublicID != nil || card.EventDisplayName != nil {
  147. item.Event = &struct {
  148. ID string `json:"id"`
  149. DisplayName string `json:"displayName"`
  150. Summary *string `json:"summary,omitempty"`
  151. Status *string `json:"status,omitempty"`
  152. }{
  153. Summary: card.EventSummary,
  154. Status: card.EventStatus,
  155. }
  156. if card.EventPublicID != nil {
  157. item.Event.ID = *card.EventPublicID
  158. }
  159. if card.EventDisplayName != nil {
  160. item.Event.DisplayName = *card.EventDisplayName
  161. }
  162. }
  163. results = append(results, item)
  164. }
  165. return results
  166. }
  167. func fallbackCardTitle(title string) string {
  168. title = strings.TrimSpace(title)
  169. if title == "" {
  170. return "未命名活动"
  171. }
  172. return title
  173. }
  174. func fallbackCardSummary(summary *string) *string {
  175. if summary != nil && strings.TrimSpace(*summary) != "" {
  176. return summary
  177. }
  178. text := "当前暂无活动摘要"
  179. return &text
  180. }
  181. func deriveCardStatus(card postgres.Card) (string, string) {
  182. if card.EventStatus == nil {
  183. return "pending", "状态待确认"
  184. }
  185. switch strings.TrimSpace(*card.EventStatus) {
  186. case "active":
  187. if card.EventCurrentReleasePubID == nil {
  188. return "upcoming", "即将开始"
  189. }
  190. if card.EventRuntimeBindingID == nil || card.EventPresentationID == nil || card.EventContentBundleID == nil {
  191. return "upcoming", "即将开始"
  192. }
  193. return "running", "进行中"
  194. case "archived", "disabled", "inactive":
  195. return "ended", "已结束"
  196. default:
  197. return "pending", "状态待确认"
  198. }
  199. }
  200. func deriveCardTimeWindow(card postgres.Card) string {
  201. if card.StartsAt == nil && card.EndsAt == nil {
  202. return "时间待公布"
  203. }
  204. const layout = "01-02 15:04"
  205. switch {
  206. case card.StartsAt != nil && card.EndsAt != nil:
  207. return card.StartsAt.Local().Format(layout) + " - " + card.EndsAt.Local().Format(layout)
  208. case card.StartsAt != nil:
  209. return "开始于 " + card.StartsAt.Local().Format(layout)
  210. default:
  211. return "截止至 " + card.EndsAt.Local().Format(layout)
  212. }
  213. }
  214. func deriveCardCTAText(card postgres.Card, statusCode string) string {
  215. if card.IsDefaultExperience {
  216. return "进入体验"
  217. }
  218. switch statusCode {
  219. case "running":
  220. return "进入活动"
  221. case "ended":
  222. return "查看回顾"
  223. default:
  224. return "查看详情"
  225. }
  226. }
  227. func deriveCardEventType(card postgres.Card) *string {
  228. if card.EventReleasePayloadJSON != nil {
  229. payload, err := decodeJSONObject(*card.EventReleasePayloadJSON)
  230. if err == nil {
  231. if game, ok := payload["game"].(map[string]any); ok {
  232. if rawMode, ok := game["mode"].(string); ok {
  233. switch strings.TrimSpace(rawMode) {
  234. case "classic-sequential":
  235. text := "顺序赛"
  236. return &text
  237. case "score-o":
  238. text := "积分赛"
  239. return &text
  240. }
  241. }
  242. }
  243. if plan := resolveVariantPlan(card.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
  244. text := "多赛道"
  245. return &text
  246. }
  247. }
  248. }
  249. if card.IsDefaultExperience {
  250. text := "体验活动"
  251. return &text
  252. }
  253. return nil
  254. }
  255. func buildCardPresentationSummary(card postgres.Card) *PresentationSummaryView {
  256. if card.EventPresentationID == nil {
  257. return nil
  258. }
  259. summary := &PresentationSummaryView{
  260. PresentationID: *card.EventPresentationID,
  261. Name: card.EventPresentationName,
  262. PresentationType: card.EventPresentationType,
  263. }
  264. if card.EventPresentationSchemaJSON != nil && strings.TrimSpace(*card.EventPresentationSchemaJSON) != "" {
  265. if schema, err := decodeJSONObject(*card.EventPresentationSchemaJSON); err == nil {
  266. summary.TemplateKey = readStringField(schema, "templateKey")
  267. summary.Version = readStringField(schema, "version")
  268. }
  269. }
  270. return summary
  271. }
  272. func buildCardContentBundleSummary(card postgres.Card) *ContentBundleSummaryView {
  273. if card.EventContentBundleID == nil {
  274. return nil
  275. }
  276. summary := &ContentBundleSummaryView{
  277. ContentBundleID: *card.EventContentBundleID,
  278. Name: card.EventContentBundleName,
  279. EntryURL: card.EventContentEntryURL,
  280. AssetRootURL: card.EventContentAssetRootURL,
  281. }
  282. if card.EventContentMetadataJSON != nil && strings.TrimSpace(*card.EventContentMetadataJSON) != "" {
  283. if metadata, err := decodeJSONObject(*card.EventContentMetadataJSON); err == nil {
  284. summary.BundleType = readStringField(metadata, "bundleType")
  285. summary.Version = readStringField(metadata, "version")
  286. }
  287. }
  288. return summary
  289. }