home_service.go 9.7 KB

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