map_experience_service.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "strings"
  6. "cmr-backend/internal/apperr"
  7. "cmr-backend/internal/store/postgres"
  8. )
  9. type MapExperienceService struct {
  10. store *postgres.Store
  11. }
  12. type ListExperienceMapsInput struct {
  13. Limit int
  14. }
  15. type ExperienceMapSummary struct {
  16. PlaceID string `json:"placeId"`
  17. PlaceName string `json:"placeName"`
  18. MapID string `json:"mapId"`
  19. MapName string `json:"mapName"`
  20. CoverURL *string `json:"coverUrl,omitempty"`
  21. Summary *string `json:"summary,omitempty"`
  22. DefaultExperienceCount int `json:"defaultExperienceCount"`
  23. DefaultExperienceEventIDs []string `json:"defaultExperienceEventIds"`
  24. }
  25. type ExperienceMapDetail struct {
  26. PlaceID string `json:"placeId"`
  27. PlaceName string `json:"placeName"`
  28. MapID string `json:"mapId"`
  29. MapName string `json:"mapName"`
  30. CoverURL *string `json:"coverUrl,omitempty"`
  31. Summary *string `json:"summary,omitempty"`
  32. TileBaseURL *string `json:"tileBaseUrl,omitempty"`
  33. TileMetaURL *string `json:"tileMetaUrl,omitempty"`
  34. DefaultExperienceCount int `json:"defaultExperienceCount"`
  35. DefaultExperiences []ExperienceEventSummary `json:"defaultExperiences"`
  36. }
  37. type ExperienceEventSummary struct {
  38. EventID string `json:"eventId"`
  39. Title string `json:"title"`
  40. Subtitle *string `json:"subtitle,omitempty"`
  41. EventType *string `json:"eventType,omitempty"`
  42. Status string `json:"status"`
  43. StatusCode string `json:"statusCode"`
  44. CTAText string `json:"ctaText"`
  45. IsDefaultExperience bool `json:"isDefaultExperience"`
  46. ShowInEventList bool `json:"showInEventList"`
  47. CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
  48. CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
  49. }
  50. func NewMapExperienceService(store *postgres.Store) *MapExperienceService {
  51. return &MapExperienceService{store: store}
  52. }
  53. func (s *MapExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
  54. rows, err := s.store.ListMapExperienceRows(ctx, input.Limit)
  55. if err != nil {
  56. return nil, err
  57. }
  58. return mapExperienceSummaries(rows), nil
  59. }
  60. func (s *MapExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
  61. mapPublicID = strings.TrimSpace(mapPublicID)
  62. if mapPublicID == "" {
  63. return nil, apperr.New(http.StatusBadRequest, "invalid_map_id", "map id is required")
  64. }
  65. rows, err := s.store.ListMapExperienceRowsByMapPublicID(ctx, mapPublicID)
  66. if err != nil {
  67. return nil, err
  68. }
  69. if len(rows) == 0 {
  70. return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
  71. }
  72. return buildMapExperienceDetail(rows), nil
  73. }
  74. func mapExperienceSummaries(rows []postgres.MapExperienceRow) []ExperienceMapSummary {
  75. ordered := make([]string, 0, len(rows))
  76. index := make(map[string]*ExperienceMapSummary)
  77. for _, row := range rows {
  78. item, ok := index[row.MapAssetPublicID]
  79. if !ok {
  80. summary := &ExperienceMapSummary{
  81. PlaceID: row.PlacePublicID,
  82. PlaceName: row.PlaceName,
  83. MapID: row.MapAssetPublicID,
  84. MapName: row.MapAssetName,
  85. CoverURL: row.MapCoverURL,
  86. Summary: normalizeOptionalText(row.MapSummary),
  87. DefaultExperienceEventIDs: []string{},
  88. }
  89. index[row.MapAssetPublicID] = summary
  90. ordered = append(ordered, row.MapAssetPublicID)
  91. item = summary
  92. }
  93. if row.EventPublicID != nil && row.EventIsDefaultExperience {
  94. if !containsString(item.DefaultExperienceEventIDs, *row.EventPublicID) {
  95. item.DefaultExperienceEventIDs = append(item.DefaultExperienceEventIDs, *row.EventPublicID)
  96. item.DefaultExperienceCount++
  97. }
  98. }
  99. }
  100. result := make([]ExperienceMapSummary, 0, len(ordered))
  101. for _, id := range ordered {
  102. result = append(result, *index[id])
  103. }
  104. return result
  105. }
  106. func buildMapExperienceDetail(rows []postgres.MapExperienceRow) *ExperienceMapDetail {
  107. first := rows[0]
  108. result := &ExperienceMapDetail{
  109. PlaceID: first.PlacePublicID,
  110. PlaceName: first.PlaceName,
  111. MapID: first.MapAssetPublicID,
  112. MapName: first.MapAssetName,
  113. CoverURL: first.MapCoverURL,
  114. Summary: normalizeOptionalText(first.MapSummary),
  115. TileBaseURL: first.TileBaseURL,
  116. TileMetaURL: first.TileMetaURL,
  117. DefaultExperiences: make([]ExperienceEventSummary, 0, 4),
  118. }
  119. seen := make(map[string]struct{})
  120. for _, row := range rows {
  121. if row.EventPublicID == nil || !row.EventIsDefaultExperience {
  122. continue
  123. }
  124. if _, ok := seen[*row.EventPublicID]; ok {
  125. continue
  126. }
  127. seen[*row.EventPublicID] = struct{}{}
  128. result.DefaultExperiences = append(result.DefaultExperiences, buildExperienceEventSummary(row))
  129. }
  130. result.DefaultExperienceCount = len(result.DefaultExperiences)
  131. return result
  132. }
  133. func buildExperienceEventSummary(row postgres.MapExperienceRow) ExperienceEventSummary {
  134. statusCode, statusText := deriveExperienceEventStatus(row)
  135. return ExperienceEventSummary{
  136. EventID: valueOrEmpty(row.EventPublicID),
  137. Title: fallbackText(row.EventDisplayName, "未命名活动"),
  138. Subtitle: normalizeOptionalText(row.EventSummary),
  139. EventType: deriveExperienceEventType(row),
  140. Status: statusText,
  141. StatusCode: statusCode,
  142. CTAText: deriveExperienceEventCTA(statusCode, row.EventIsDefaultExperience),
  143. IsDefaultExperience: row.EventIsDefaultExperience,
  144. ShowInEventList: row.EventShowInEventList,
  145. CurrentPresentation: buildPresentationSummaryFromMapExperienceRow(row),
  146. CurrentContentBundle: buildContentBundleSummaryFromMapExperienceRow(row),
  147. }
  148. }
  149. func deriveExperienceEventStatus(row postgres.MapExperienceRow) (string, string) {
  150. if row.EventStatus == nil {
  151. return "pending", "状态待确认"
  152. }
  153. switch strings.TrimSpace(*row.EventStatus) {
  154. case "active":
  155. if row.EventReleasePayloadJSON == nil || strings.TrimSpace(*row.EventReleasePayloadJSON) == "" {
  156. return "upcoming", "即将开始"
  157. }
  158. if row.EventPresentationID == nil || row.EventContentBundleID == nil {
  159. return "upcoming", "即将开始"
  160. }
  161. return "running", "进行中"
  162. case "archived", "disabled", "inactive":
  163. return "ended", "已结束"
  164. default:
  165. return "pending", "状态待确认"
  166. }
  167. }
  168. func deriveExperienceEventCTA(statusCode string, isDefault bool) string {
  169. if isDefault {
  170. return "进入体验"
  171. }
  172. switch statusCode {
  173. case "running":
  174. return "进入活动"
  175. case "ended":
  176. return "查看回顾"
  177. default:
  178. return "查看详情"
  179. }
  180. }
  181. func deriveExperienceEventType(row postgres.MapExperienceRow) *string {
  182. if row.EventReleasePayloadJSON != nil {
  183. payload, err := decodeJSONObject(*row.EventReleasePayloadJSON)
  184. if err == nil {
  185. if game, ok := payload["game"].(map[string]any); ok {
  186. if rawMode, ok := game["mode"].(string); ok {
  187. switch strings.TrimSpace(rawMode) {
  188. case "classic-sequential":
  189. text := "顺序赛"
  190. return &text
  191. case "score-o":
  192. text := "积分赛"
  193. return &text
  194. }
  195. }
  196. }
  197. if plan := resolveVariantPlan(row.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
  198. text := "多赛道"
  199. return &text
  200. }
  201. }
  202. }
  203. if row.EventIsDefaultExperience {
  204. text := "体验活动"
  205. return &text
  206. }
  207. return nil
  208. }
  209. func buildPresentationSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *PresentationSummaryView {
  210. if row.EventPresentationID == nil {
  211. return nil
  212. }
  213. summary := &PresentationSummaryView{
  214. PresentationID: *row.EventPresentationID,
  215. Name: row.EventPresentationName,
  216. PresentationType: row.EventPresentationType,
  217. }
  218. if row.EventPresentationSchema != nil && strings.TrimSpace(*row.EventPresentationSchema) != "" {
  219. if schema, err := decodeJSONObject(*row.EventPresentationSchema); err == nil {
  220. summary.TemplateKey = readStringField(schema, "templateKey")
  221. summary.Version = readStringField(schema, "version")
  222. }
  223. }
  224. return summary
  225. }
  226. func buildContentBundleSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *ContentBundleSummaryView {
  227. if row.EventContentBundleID == nil {
  228. return nil
  229. }
  230. summary := &ContentBundleSummaryView{
  231. ContentBundleID: *row.EventContentBundleID,
  232. Name: row.EventContentBundleName,
  233. EntryURL: row.EventContentEntryURL,
  234. AssetRootURL: row.EventContentAssetRootURL,
  235. }
  236. if row.EventContentMetadataJSON != nil && strings.TrimSpace(*row.EventContentMetadataJSON) != "" {
  237. if metadata, err := decodeJSONObject(*row.EventContentMetadataJSON); err == nil {
  238. summary.BundleType = readStringField(metadata, "bundleType")
  239. summary.Version = readStringField(metadata, "version")
  240. }
  241. }
  242. return summary
  243. }
  244. func normalizeOptionalText(value *string) *string {
  245. if value == nil {
  246. return nil
  247. }
  248. trimmed := strings.TrimSpace(*value)
  249. if trimmed == "" {
  250. return nil
  251. }
  252. return &trimmed
  253. }
  254. func fallbackText(value *string, fallback string) string {
  255. if value == nil {
  256. return fallback
  257. }
  258. trimmed := strings.TrimSpace(*value)
  259. if trimmed == "" {
  260. return fallback
  261. }
  262. return trimmed
  263. }
  264. func valueOrEmpty(value *string) string {
  265. if value == nil {
  266. return ""
  267. }
  268. return *value
  269. }
  270. func containsString(values []string, target string) bool {
  271. for _, item := range values {
  272. if item == target {
  273. return true
  274. }
  275. }
  276. return false
  277. }