完善活动运营域与联调标准化

This commit is contained in:
2026-04-03 13:11:41 +08:00
parent 0e28f70bad
commit 129ea935db
56 changed files with 11004 additions and 196 deletions

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -27,18 +28,25 @@ type AdminEventSummary struct {
}
type AdminEventReleaseRef struct {
ID string `json:"id"`
ConfigLabel *string `json:"configLabel,omitempty"`
ManifestURL *string `json:"manifestUrl,omitempty"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
ID string `json:"id"`
ConfigLabel *string `json:"configLabel,omitempty"`
ManifestURL *string `json:"manifestUrl,omitempty"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type AdminEventDetail struct {
Event AdminEventSummary `json:"event"`
LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
SourceCount int `json:"sourceCount"`
CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
Event AdminEventSummary `json:"event"`
LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
SourceCount int `json:"sourceCount"`
CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
PresentationCount int `json:"presentationCount"`
ContentBundleCount int `json:"contentBundleCount"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
CurrentRuntime *RuntimeSummaryView `json:"currentRuntime,omitempty"`
}
type CreateAdminEventInput struct {
@@ -76,6 +84,84 @@ type SaveAdminEventSourceInput struct {
Notes *string `json:"notes,omitempty"`
}
type AdminEventPresentationView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
Code string `json:"code"`
Name string `json:"name"`
PresentationType string `json:"presentationType"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
TemplateKey *string `json:"templateKey,omitempty"`
Version *string `json:"version,omitempty"`
SourceType *string `json:"sourceType,omitempty"`
SchemaURL *string `json:"schemaUrl,omitempty"`
Schema map[string]any `json:"schema"`
}
type CreateAdminEventPresentationInput struct {
Code string `json:"code"`
Name string `json:"name"`
PresentationType string `json:"presentationType"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
Schema map[string]any `json:"schema,omitempty"`
}
type ImportAdminEventPresentationInput struct {
Title string `json:"title"`
TemplateKey string `json:"templateKey"`
SourceType string `json:"sourceType"`
SchemaURL string `json:"schemaUrl"`
Version string `json:"version"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
}
type AdminContentBundleView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
BundleType *string `json:"bundleType,omitempty"`
Version *string `json:"version,omitempty"`
SourceType *string `json:"sourceType,omitempty"`
ManifestURL *string `json:"manifestUrl,omitempty"`
AssetManifest any `json:"assetManifest,omitempty"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
Metadata map[string]any `json:"metadata"`
}
type CreateAdminContentBundleInput struct {
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ImportAdminContentBundleInput struct {
Title string `json:"title"`
BundleType string `json:"bundleType"`
SourceType string `json:"sourceType"`
ManifestURL string `json:"manifestUrl"`
Version string `json:"version"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
AssetManifest map[string]any `json:"assetManifest,omitempty"`
}
type UpdateAdminEventDefaultsInput struct {
PresentationID *string `json:"presentationId,omitempty"`
ContentBundleID *string `json:"contentBundleId,omitempty"`
RuntimeBindingID *string `json:"runtimeBindingId,omitempty"`
}
type AdminAssembledSource struct {
Refs map[string]any `json:"refs"`
Runtime map[string]any `json:"runtime"`
@@ -240,10 +326,20 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st
if err != nil {
return nil, err
}
presentations, err := s.store.ListEventPresentationsByEventID(ctx, record.ID, 200)
if err != nil {
return nil, err
}
contentBundles, err := s.store.ListContentBundlesByEventID(ctx, record.ID, 200)
if err != nil {
return nil, err
}
result := &AdminEventDetail{
Event: buildAdminEventSummary(*record),
SourceCount: len(allSources),
Event: buildAdminEventSummary(*record),
SourceCount: len(allSources),
PresentationCount: len(presentations),
ContentBundleCount: len(contentBundles),
}
if len(sources) > 0 {
latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
@@ -253,9 +349,427 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st
result.LatestSource = latest
result.CurrentSource = buildAdminAssembledSource(latest.Source)
}
result.CurrentPresentation = buildPresentationSummaryFromEventRecord(record)
result.CurrentContentBundle = buildContentBundleSummaryFromEventRecord(record)
result.CurrentRuntime = buildRuntimeSummaryFromAdminEventRecord(record)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, record.CurrentPresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, record.CurrentContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
return result, nil
}
func (s *AdminEventService) ListEventPresentations(ctx context.Context, eventPublicID string, limit int) ([]AdminEventPresentationView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
items, err := s.store.ListEventPresentationsByEventID(ctx, eventRecord.ID, limit)
if err != nil {
return nil, err
}
result := make([]AdminEventPresentationView, 0, len(items))
for _, item := range items {
view, err := buildAdminEventPresentationView(item)
if err != nil {
return nil, err
}
result = append(result, view)
}
return result, nil
}
func (s *AdminEventService) CreateEventPresentation(ctx context.Context, eventPublicID string, input CreateAdminEventPresentationInput) (*AdminEventPresentationView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
input.PresentationType = normalizePresentationType(input.PresentationType)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("pres")
if err != nil {
return nil, err
}
schema := input.Schema
if schema == nil {
schema = map[string]any{}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: input.Code,
Name: input.Name,
PresentationType: input.PresentationType,
Status: normalizeEventCatalogStatus(input.Status),
IsDefault: input.IsDefault,
SchemaJSON: mustMarshalJSONObject(schema),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminEventPresentationView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) ImportEventPresentation(ctx context.Context, eventPublicID string, input ImportAdminEventPresentationInput) (*AdminEventPresentationView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.Title = strings.TrimSpace(input.Title)
input.TemplateKey = strings.TrimSpace(input.TemplateKey)
input.SourceType = strings.TrimSpace(input.SourceType)
input.SchemaURL = strings.TrimSpace(input.SchemaURL)
input.Version = strings.TrimSpace(input.Version)
if input.Title == "" || input.TemplateKey == "" || input.SourceType == "" || input.SchemaURL == "" || input.Version == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, templateKey, sourceType, schemaUrl and version are required")
}
publicID, err := security.GeneratePublicID("pres")
if err != nil {
return nil, err
}
code := generateImportedPresentationCode(input.Title, publicID)
status := normalizeEventCatalogStatus(input.Status)
if strings.TrimSpace(input.Status) == "" {
status = "active"
}
schema := map[string]any{
"templateKey": input.TemplateKey,
"sourceType": input.SourceType,
"schemaUrl": input.SchemaURL,
"version": input.Version,
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: code,
Name: input.Title,
PresentationType: "generic",
Status: status,
IsDefault: input.IsDefault,
SchemaJSON: mustMarshalJSONObject(schema),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminEventPresentationView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) GetEventPresentation(ctx context.Context, presentationPublicID string) (*AdminEventPresentationView, error) {
record, err := s.store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(presentationPublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
view, err := buildAdminEventPresentationView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) ListContentBundles(ctx context.Context, eventPublicID string, limit int) ([]AdminContentBundleView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
items, err := s.store.ListContentBundlesByEventID(ctx, eventRecord.ID, limit)
if err != nil {
return nil, err
}
result := make([]AdminContentBundleView, 0, len(items))
for _, item := range items {
view, err := buildAdminContentBundleView(item)
if err != nil {
return nil, err
}
result = append(result, view)
}
return result, nil
}
func (s *AdminEventService) CreateContentBundle(ctx context.Context, eventPublicID string, input CreateAdminContentBundleInput) (*AdminContentBundleView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("bundle")
if err != nil {
return nil, err
}
metadata := input.Metadata
if metadata == nil {
metadata = map[string]any{}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: input.Code,
Name: input.Name,
Status: normalizeEventCatalogStatus(input.Status),
IsDefault: input.IsDefault,
EntryURL: trimStringPtr(input.EntryURL),
AssetRootURL: trimStringPtr(input.AssetRootURL),
MetadataJSON: mustMarshalJSONObject(metadata),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminContentBundleView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) ImportContentBundle(ctx context.Context, eventPublicID string, input ImportAdminContentBundleInput) (*AdminContentBundleView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.Title = strings.TrimSpace(input.Title)
input.BundleType = strings.TrimSpace(input.BundleType)
input.SourceType = strings.TrimSpace(input.SourceType)
input.ManifestURL = strings.TrimSpace(input.ManifestURL)
input.Version = strings.TrimSpace(input.Version)
if input.Title == "" || input.BundleType == "" || input.SourceType == "" || input.ManifestURL == "" || input.Version == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, bundleType, sourceType, manifestUrl and version are required")
}
publicID, err := security.GeneratePublicID("bundle")
if err != nil {
return nil, err
}
code := generateImportedBundleCode(input.Title, publicID)
assetManifest := input.AssetManifest
if assetManifest == nil {
assetManifest = map[string]any{
"manifestUrl": input.ManifestURL,
"sourceType": input.SourceType,
}
}
metadata := map[string]any{
"bundleType": input.BundleType,
"sourceType": input.SourceType,
"manifestUrl": input.ManifestURL,
"version": input.Version,
"assetManifest": assetManifest,
}
status := normalizeEventCatalogStatus(input.Status)
if strings.TrimSpace(input.Status) == "" {
status = "active"
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: code,
Name: input.Title,
Status: status,
IsDefault: input.IsDefault,
EntryURL: nil,
AssetRootURL: nil,
MetadataJSON: mustMarshalJSONObject(metadata),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminContentBundleView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) GetContentBundle(ctx context.Context, contentBundlePublicID string) (*AdminContentBundleView, error) {
record, err := s.store.GetContentBundleByPublicID(ctx, strings.TrimSpace(contentBundlePublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
view, err := buildAdminContentBundleView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) UpdateEventDefaults(ctx context.Context, eventPublicID string, input UpdateAdminEventDefaultsInput) (*AdminEventDetail, error) {
record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
var presentationID *string
updatePresentation := false
if input.PresentationID != nil {
updatePresentation = true
trimmed := strings.TrimSpace(*input.PresentationID)
if trimmed != "" {
presentation, err := s.store.GetEventPresentationByPublicID(ctx, trimmed)
if err != nil {
return nil, err
}
if presentation == nil {
return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
if presentation.EventID != record.ID {
return nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to event")
}
presentationID = &presentation.ID
}
}
var contentBundleID *string
updateContent := false
if input.ContentBundleID != nil {
updateContent = true
trimmed := strings.TrimSpace(*input.ContentBundleID)
if trimmed != "" {
contentBundle, err := s.store.GetContentBundleByPublicID(ctx, trimmed)
if err != nil {
return nil, err
}
if contentBundle == nil {
return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
if contentBundle.EventID != record.ID {
return nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to event")
}
contentBundleID = &contentBundle.ID
}
}
var runtimeBindingID *string
updateRuntime := false
if input.RuntimeBindingID != nil {
updateRuntime = true
trimmed := strings.TrimSpace(*input.RuntimeBindingID)
if trimmed != "" {
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, trimmed)
if err != nil {
return nil, err
}
if runtimeBinding == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != record.ID {
return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to event")
}
runtimeBindingID = &runtimeBinding.ID
}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetEventDefaultBindings(ctx, tx, postgres.SetEventDefaultBindingsParams{
EventID: record.ID,
PresentationID: presentationID,
ContentBundleID: contentBundleID,
RuntimeBindingID: runtimeBindingID,
UpdatePresentation: updatePresentation,
UpdateContent: updateContent,
UpdateRuntime: updateRuntime,
}); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetEventDetail(ctx, eventPublicID)
}
func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
@@ -441,11 +955,160 @@ func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
ManifestURL: item.ManifestURL,
ManifestChecksumSha256: item.ManifestChecksum,
RouteCode: item.RouteCode,
Presentation: buildPresentationSummaryFromEventRecord(&item),
ContentBundle: buildContentBundleSummaryFromEventRecord(&item),
}
}
return summary
}
func buildPresentationSummaryFromEventRecord(item *postgres.AdminEventRecord) *PresentationSummaryView {
if item == nil || item.CurrentPresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *item.CurrentPresentationID,
Name: item.CurrentPresentationName,
PresentationType: item.CurrentPresentationType,
}
}
func buildContentBundleSummaryFromEventRecord(item *postgres.AdminEventRecord) *ContentBundleSummaryView {
if item == nil || item.CurrentContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *item.CurrentContentBundleID,
Name: item.CurrentContentBundleName,
EntryURL: item.CurrentContentEntryURL,
AssetRootURL: item.CurrentContentAssetRootURL,
}
}
func buildRuntimeSummaryFromAdminEventRecord(item *postgres.AdminEventRecord) *RuntimeSummaryView {
if item == nil ||
item.CurrentRuntimeBindingID == nil ||
item.CurrentPlaceID == nil ||
item.CurrentMapAssetID == nil ||
item.CurrentTileReleaseID == nil ||
item.CurrentCourseSetID == nil ||
item.CurrentCourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *item.CurrentRuntimeBindingID,
PlaceID: *item.CurrentPlaceID,
MapID: *item.CurrentMapAssetID,
TileReleaseID: *item.CurrentTileReleaseID,
CourseSetID: *item.CurrentCourseSetID,
CourseVariantID: *item.CurrentCourseVariantID,
CourseVariantName: item.CurrentCourseVariantName,
RouteCode: item.CurrentRuntimeRouteCode,
}
}
func buildAdminEventPresentationView(item postgres.EventPresentation) (AdminEventPresentationView, error) {
schema, err := decodeJSONObject(item.SchemaJSON)
if err != nil {
return AdminEventPresentationView{}, err
}
return AdminEventPresentationView{
ID: item.PublicID,
EventID: item.EventPublicID,
Code: item.Code,
Name: item.Name,
PresentationType: item.PresentationType,
Status: item.Status,
IsDefault: item.IsDefault,
TemplateKey: readStringField(schema, "templateKey"),
Version: readStringField(schema, "version"),
SourceType: readStringField(schema, "sourceType"),
SchemaURL: readStringField(schema, "schemaUrl"),
Schema: schema,
}, nil
}
func buildAdminContentBundleView(item postgres.ContentBundle) (AdminContentBundleView, error) {
metadata, err := decodeJSONObject(item.MetadataJSON)
if err != nil {
return AdminContentBundleView{}, err
}
return AdminContentBundleView{
ID: item.PublicID,
EventID: item.EventPublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
IsDefault: item.IsDefault,
BundleType: readStringField(metadata, "bundleType"),
Version: readStringField(metadata, "version"),
SourceType: readStringField(metadata, "sourceType"),
ManifestURL: readStringField(metadata, "manifestUrl"),
AssetManifest: metadata["assetManifest"],
EntryURL: item.EntryURL,
AssetRootURL: item.AssetRootURL,
Metadata: metadata,
}, nil
}
func generateImportedBundleCode(title, publicID string) string {
var builder strings.Builder
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
if builder.Len() == 0 {
continue
}
last := builder.String()[builder.Len()-1]
if last != '-' {
builder.WriteByte('-')
}
}
}
code := strings.Trim(builder.String(), "-")
if code == "" {
code = "bundle"
}
suffix := publicID
if len(suffix) > 8 {
suffix = suffix[len(suffix)-8:]
}
return code + "-" + suffix
}
func generateImportedPresentationCode(title, publicID string) string {
var builder strings.Builder
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
if builder.Len() == 0 {
continue
}
last := builder.String()[builder.Len()-1]
if last != '-' {
builder.WriteByte('-')
}
}
}
code := strings.Trim(builder.String(), "-")
if code == "" {
code = "presentation"
}
suffix := publicID
if len(suffix) > 8 {
suffix = suffix[len(suffix)-8:]
}
return code + "-" + suffix
}
func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
result := &AdminAssembledSource{}
if refs, ok := source["refs"].(map[string]any); ok {
@@ -474,6 +1137,26 @@ func normalizeEventCatalogStatus(value string) string {
}
}
func normalizePresentationType(value string) string {
switch strings.TrimSpace(value) {
case "card":
return "card"
case "detail":
return "detail"
case "h5":
return "h5"
case "result":
return "result"
default:
return "generic"
}
}
func mustMarshalJSONObject(value map[string]any) string {
raw, _ := json.Marshal(value)
return string(raw)
}
func mergeJSONObject(target map[string]any, overrides map[string]any) {
for key, value := range overrides {
if valueMap, ok := value.(map[string]any); ok {

View File

@@ -15,15 +15,18 @@ type AdminPipelineService struct {
}
type AdminReleaseView struct {
ID string `json:"id"`
ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"`
PublishedAt string `json:"publishedAt"`
ID string `json:"id"`
ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"`
PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type AdminEventPipelineView struct {
@@ -38,6 +41,16 @@ type AdminRollbackReleaseInput struct {
ReleaseID string `json:"releaseId"`
}
type AdminBindReleaseRuntimeInput struct {
RuntimeBindingID string `json:"runtimeBindingId"`
}
type AdminPublishBuildInput struct {
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService {
return &AdminPipelineService{
store: store,
@@ -77,7 +90,18 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic
}
releases := make([]AdminReleaseView, 0, len(releaseRecords))
for _, item := range releaseRecords {
releases = append(releases, buildAdminReleaseView(item))
view := buildAdminReleaseView(item)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, item.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, item.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
releases = append(releases, view)
}
result := &AdminEventPipelineView{
@@ -94,6 +118,19 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
Status: "published",
Runtime: buildRuntimeSummaryFromEvent(event),
Presentation: buildPresentationSummaryFromEvent(event),
ContentBundle: buildContentBundleSummaryFromEvent(event),
}
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentRelease.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentRelease.ContentBundle = enrichedBundle
}
}
return result, nil
@@ -107,8 +144,84 @@ func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*E
return s.configService.GetEventConfigBuild(ctx, buildID)
}
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID})
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string, input AdminPublishBuildInput) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{
BuildID: buildID,
RuntimeBindingID: input.RuntimeBindingID,
PresentationID: input.PresentationID,
ContentBundleID: input.ContentBundleID,
})
}
func (s *AdminPipelineService) GetRelease(ctx context.Context, releasePublicID string) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*release)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, release.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, release.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
return &view, nil
}
func (s *AdminPipelineService) BindReleaseRuntime(ctx context.Context, releasePublicID string, input AdminBindReleaseRuntimeInput) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
input.RuntimeBindingID = strings.TrimSpace(input.RuntimeBindingID)
if input.RuntimeBindingID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "runtimeBindingId is required")
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, input.RuntimeBindingID)
if err != nil {
return nil, err
}
if runtimeBinding == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != release.EventID {
return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to release event")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetEventReleaseRuntimeBinding(ctx, tx, release.ID, &runtimeBinding.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
updated, err := s.store.GetEventReleaseByPublicID(ctx, release.PublicID)
if err != nil {
return nil, err
}
if updated == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*updated)
return &view, nil
}
func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) {
@@ -167,6 +280,9 @@ func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView {
BuildID: item.BuildID,
Status: item.Status,
PublishedAt: item.PublishedAt.Format(timeRFC3339),
Runtime: buildRuntimeSummaryFromRelease(&item),
Presentation: buildPresentationSummaryFromRelease(&item),
ContentBundle: buildContentBundleSummaryFromRelease(&item),
}
}

View File

@@ -0,0 +1,935 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminProductionService struct {
store *postgres.Store
}
type AdminPlaceSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Region *string `json:"region,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
CenterPoint map[string]any `json:"centerPoint,omitempty"`
Status string `json:"status"`
}
type AdminPlaceDetail struct {
Place AdminPlaceSummary `json:"place"`
MapAssets []AdminMapAssetSummary `json:"mapAssets"`
}
type CreateAdminPlaceInput struct {
Code string `json:"code"`
Name string `json:"name"`
Region *string `json:"region,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
CenterPoint map[string]any `json:"centerPoint,omitempty"`
Status string `json:"status"`
}
type AdminMapAssetSummary struct {
ID string `json:"id"`
PlaceID string `json:"placeId"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
MapType string `json:"mapType"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
CurrentTileRelease *AdminTileReleaseBrief `json:"currentTileRelease,omitempty"`
}
type AdminTileReleaseBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminMapAssetDetail struct {
MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileReleases []AdminTileReleaseView `json:"tileReleases"`
CourseSets []AdminCourseSetBrief `json:"courseSets"`
}
type CreateAdminMapAssetInput struct {
Code string `json:"code"`
Name string `json:"name"`
MapType string `json:"mapType"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
}
type AdminTileReleaseView struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
TileBaseURL string `json:"tileBaseUrl"`
MetaURL string `json:"metaUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
}
type CreateAdminTileReleaseInput struct {
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
TileBaseURL string `json:"tileBaseUrl"`
MetaURL string `json:"metaUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminCourseSourceSummary struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
SourceType string `json:"sourceType"`
FileURL string `json:"fileUrl"`
Checksum *string `json:"checksum,omitempty"`
ParserVersion *string `json:"parserVersion,omitempty"`
ImportStatus string `json:"importStatus"`
Metadata map[string]any `json:"metadata,omitempty"`
ImportedAt time.Time `json:"importedAt"`
}
type CreateAdminCourseSourceInput struct {
LegacyPlayfieldID *string `json:"legacyPlayfieldId,omitempty"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
SourceType string `json:"sourceType"`
FileURL string `json:"fileUrl"`
Checksum *string `json:"checksum,omitempty"`
ParserVersion *string `json:"parserVersion,omitempty"`
ImportStatus string `json:"importStatus"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminCourseSetBrief struct {
ID string `json:"id"`
Code string `json:"code"`
Mode string `json:"mode"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
CurrentVariant *AdminCourseVariantBrief `json:"currentVariant,omitempty"`
}
type AdminCourseVariantBrief struct {
ID string `json:"id"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Status string `json:"status"`
}
type AdminCourseSetDetail struct {
CourseSet AdminCourseSetBrief `json:"courseSet"`
Variants []AdminCourseVariantView `json:"variants"`
}
type CreateAdminCourseSetInput struct {
Code string `json:"code"`
Mode string `json:"mode"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
}
type AdminCourseVariantView struct {
ID string `json:"id"`
SourceID *string `json:"sourceId,omitempty"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Mode string `json:"mode"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
ConfigPatch map[string]any `json:"configPatch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type CreateAdminCourseVariantInput struct {
SourceID *string `json:"sourceId,omitempty"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Mode string `json:"mode"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
ConfigPatch map[string]any `json:"configPatch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminRuntimeBindingSummary struct {
ID string `json:"id"`
EventID string `json:"eventId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
}
type CreateAdminRuntimeBindingInput struct {
EventID string `json:"eventId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
}
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
return &AdminProductionService{store: store}
}
func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]AdminPlaceSummary, error) {
items, err := s.store.ListPlaces(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminPlaceSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminPlaceSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("place")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreatePlace(ctx, tx, postgres.CreatePlaceParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Region: trimStringPtr(input.Region),
CoverURL: trimStringPtr(input.CoverURL),
Description: trimStringPtr(input.Description),
CenterPoint: input.CenterPoint,
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := buildAdminPlaceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) GetPlaceDetail(ctx context.Context, placePublicID string) (*AdminPlaceDetail, error) {
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
mapAssets, err := s.store.ListMapAssetsByPlaceID(ctx, place.ID)
if err != nil {
return nil, err
}
result := &AdminPlaceDetail{
Place: buildAdminPlaceSummary(*place),
MapAssets: make([]AdminMapAssetSummary, 0, len(mapAssets)),
}
for _, item := range mapAssets {
summary, err := s.buildAdminMapAssetSummary(ctx, item)
if err != nil {
return nil, err
}
result.MapAssets = append(result.MapAssets, summary)
}
return result, nil
}
func (s *AdminProductionService) CreateMapAsset(ctx context.Context, placePublicID string, input CreateAdminMapAssetInput) (*AdminMapAssetSummary, error) {
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
mapType := strings.TrimSpace(input.MapType)
if mapType == "" {
mapType = "standard"
}
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
var legacyMapID *string
if input.LegacyMapID != nil && strings.TrimSpace(*input.LegacyMapID) != "" {
legacyMap, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(*input.LegacyMapID))
if err != nil {
return nil, err
}
if legacyMap == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_map_not_found", "legacy map not found")
}
legacyMapID = &legacyMap.ID
}
publicID, err := security.GeneratePublicID("mapasset")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateMapAsset(ctx, tx, postgres.CreateMapAssetParams{
PublicID: publicID,
PlaceID: place.ID,
LegacyMapID: legacyMapID,
Code: input.Code,
Name: input.Name,
MapType: mapType,
CoverURL: trimStringPtr(input.CoverURL),
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result, err := s.buildAdminMapAssetSummary(ctx, *item)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAssetPublicID string) (*AdminMapAssetDetail, error) {
item, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
summary, err := s.buildAdminMapAssetSummary(ctx, *item)
if err != nil {
return nil, err
}
tileReleases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
if err != nil {
return nil, err
}
courseSets, err := s.store.ListCourseSetsByMapAssetID(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminMapAssetDetail{
MapAsset: summary,
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
}
for _, release := range tileReleases {
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
}
for _, courseSet := range courseSets {
brief, err := s.buildAdminCourseSetBrief(ctx, courseSet)
if err != nil {
return nil, err
}
result.CourseSets = append(result.CourseSets, brief)
}
return result, nil
}
func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if mapAsset == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
input.MetaURL = strings.TrimSpace(input.MetaURL)
if input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, tileBaseUrl and metaUrl are required")
}
var legacyVersionID *string
if input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyVersionID) != "" {
if mapAsset.LegacyMapPublicID == nil || strings.TrimSpace(*mapAsset.LegacyMapPublicID) == "" {
return nil, apperr.New(http.StatusBadRequest, "legacy_map_missing", "map asset has no linked legacy map")
}
legacyVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, *mapAsset.LegacyMapPublicID, strings.TrimSpace(*input.LegacyVersionID))
if err != nil {
return nil, err
}
if legacyVersion == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_tile_version_not_found", "legacy map version not found")
}
legacyVersionID = &legacyVersion.ID
}
publicID, err := security.GeneratePublicID("tile")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
publishedAt := time.Now()
release, err := s.store.CreateTileRelease(ctx, tx, postgres.CreateTileReleaseParams{
PublicID: publicID,
MapAssetID: mapAsset.ID,
LegacyMapVersionID: legacyVersionID,
VersionCode: input.VersionCode,
Status: normalizeReleaseStatus(input.Status),
TileBaseURL: input.TileBaseURL,
MetaURL: input.MetaURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
MetadataJSON: input.Metadata,
PublishedAt: &publishedAt,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetMapAssetCurrentTileRelease(ctx, tx, mapAsset.ID, release.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminTileReleaseView(*release)
return &view, nil
}
func (s *AdminProductionService) ListCourseSources(ctx context.Context, limit int) ([]AdminCourseSourceSummary, error) {
items, err := s.store.ListCourseSources(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminCourseSourceSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminCourseSourceSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreateCourseSource(ctx context.Context, input CreateAdminCourseSourceInput) (*AdminCourseSourceSummary, error) {
sourceType := strings.TrimSpace(input.SourceType)
fileURL := strings.TrimSpace(input.FileURL)
if sourceType == "" || fileURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "sourceType and fileUrl are required")
}
var legacyPlayfieldVersionID *string
if input.LegacyPlayfieldID != nil && input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyPlayfieldID) != "" && strings.TrimSpace(*input.LegacyVersionID) != "" {
version, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, strings.TrimSpace(*input.LegacyPlayfieldID), strings.TrimSpace(*input.LegacyVersionID))
if err != nil {
return nil, err
}
if version == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_playfield_version_not_found", "legacy playfield version not found")
}
legacyPlayfieldVersionID = &version.ID
}
publicID, err := security.GeneratePublicID("csrc")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateCourseSource(ctx, tx, postgres.CreateCourseSourceParams{
PublicID: publicID,
LegacyPlayfieldVersionID: legacyPlayfieldVersionID,
SourceType: sourceType,
FileURL: fileURL,
Checksum: trimStringPtr(input.Checksum),
ParserVersion: trimStringPtr(input.ParserVersion),
ImportStatus: normalizeCourseSourceStatus(input.ImportStatus),
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := buildAdminCourseSourceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) GetCourseSource(ctx context.Context, sourcePublicID string) (*AdminCourseSourceSummary, error) {
item, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(sourcePublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
}
result := buildAdminCourseSourceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) CreateCourseSet(ctx context.Context, mapAssetPublicID string, input CreateAdminCourseSetInput) (*AdminCourseSetBrief, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if mapAsset == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Mode = strings.TrimSpace(input.Mode)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Mode == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code, mode and name are required")
}
publicID, err := security.GeneratePublicID("cset")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateCourseSet(ctx, tx, postgres.CreateCourseSetParams{
PublicID: publicID,
PlaceID: mapAsset.PlaceID,
MapAssetID: mapAsset.ID,
Code: input.Code,
Mode: input.Mode,
Name: input.Name,
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
if err != nil {
return nil, err
}
return &brief, nil
}
func (s *AdminProductionService) GetCourseSetDetail(ctx context.Context, courseSetPublicID string) (*AdminCourseSetDetail, error) {
item, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
}
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
if err != nil {
return nil, err
}
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminCourseSetDetail{
CourseSet: brief,
Variants: make([]AdminCourseVariantView, 0, len(variants)),
}
for _, variant := range variants {
result.Variants = append(result.Variants, buildAdminCourseVariantView(variant))
}
return result, nil
}
func (s *AdminProductionService) CreateCourseVariant(ctx context.Context, courseSetPublicID string, input CreateAdminCourseVariantInput) (*AdminCourseVariantView, error) {
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
if err != nil {
return nil, err
}
if courseSet == nil {
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
}
input.Name = strings.TrimSpace(input.Name)
input.Mode = strings.TrimSpace(input.Mode)
if input.Name == "" || input.Mode == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "name and mode are required")
}
var sourceID *string
if input.SourceID != nil && strings.TrimSpace(*input.SourceID) != "" {
source, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(*input.SourceID))
if err != nil {
return nil, err
}
if source == nil {
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
}
sourceID = &source.ID
}
publicID, err := security.GeneratePublicID("cvar")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateCourseVariant(ctx, tx, postgres.CreateCourseVariantParams{
PublicID: publicID,
CourseSetID: courseSet.ID,
SourceID: sourceID,
Name: input.Name,
RouteCode: trimStringPtr(input.RouteCode),
Mode: input.Mode,
ControlCount: input.ControlCount,
Difficulty: trimStringPtr(input.Difficulty),
Status: normalizeCatalogStatus(input.Status),
IsDefault: input.IsDefault,
ConfigPatch: input.ConfigPatch,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.IsDefault {
if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, item.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminCourseVariantView(*item)
return &view, nil
}
func (s *AdminProductionService) ListRuntimeBindings(ctx context.Context, limit int) ([]AdminRuntimeBindingSummary, error) {
items, err := s.store.ListMapRuntimeBindings(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminRuntimeBindingSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminRuntimeBindingSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input CreateAdminRuntimeBindingInput) (*AdminRuntimeBindingSummary, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(input.EventID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(input.PlaceID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(input.MapAssetID))
if err != nil {
return nil, err
}
if mapAsset == nil || mapAsset.PlaceID != place.ID {
return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place")
}
tileRelease, err := s.store.GetTileReleaseByPublicID(ctx, strings.TrimSpace(input.TileReleaseID))
if err != nil {
return nil, err
}
if tileRelease == nil || tileRelease.MapAssetID != mapAsset.ID {
return nil, apperr.New(http.StatusBadRequest, "tile_release_mismatch", "tile release does not belong to map asset")
}
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(input.CourseSetID))
if err != nil {
return nil, err
}
if courseSet == nil || courseSet.PlaceID != place.ID || courseSet.MapAssetID != mapAsset.ID {
return nil, apperr.New(http.StatusBadRequest, "course_set_mismatch", "course set does not match place/map asset")
}
courseVariant, err := s.store.GetCourseVariantByPublicID(ctx, strings.TrimSpace(input.CourseVariantID))
if err != nil {
return nil, err
}
if courseVariant == nil || courseVariant.CourseSetID != courseSet.ID {
return nil, apperr.New(http.StatusBadRequest, "course_variant_mismatch", "course variant does not belong to course set")
}
publicID, err := security.GeneratePublicID("rtbind")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateMapRuntimeBinding(ctx, tx, postgres.CreateMapRuntimeBindingParams{
PublicID: publicID,
EventID: eventRecord.ID,
PlaceID: place.ID,
MapAssetID: mapAsset.ID,
TileReleaseID: tileRelease.ID,
CourseSetID: courseSet.ID,
CourseVariantID: courseVariant.ID,
Status: normalizeRuntimeBindingStatus(input.Status),
Notes: trimStringPtr(input.Notes),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
created, err := s.store.GetMapRuntimeBindingByPublicID(ctx, item.PublicID)
if err != nil {
return nil, err
}
if created == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
result := buildAdminRuntimeBindingSummary(*created)
return &result, nil
}
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
result := buildAdminRuntimeBindingSummary(*item)
return &result, nil
}
func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context, item postgres.MapAsset) (AdminMapAssetSummary, error) {
result := AdminMapAssetSummary{
ID: item.PublicID,
PlaceID: item.PlaceID,
LegacyMapID: item.LegacyMapPublicID,
Code: item.Code,
Name: item.Name,
MapType: item.MapType,
CoverURL: item.CoverURL,
Description: item.Description,
Status: item.Status,
}
if item.CurrentTileReleaseID != nil {
releases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
if err != nil {
return result, err
}
for _, release := range releases {
if release.ID == *item.CurrentTileReleaseID {
result.CurrentTileRelease = &AdminTileReleaseBrief{
ID: release.PublicID,
VersionCode: release.VersionCode,
Status: release.Status,
}
break
}
}
}
return result, nil
}
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
result := AdminCourseSetBrief{
ID: item.PublicID,
Code: item.Code,
Mode: item.Mode,
Name: item.Name,
Description: item.Description,
Status: item.Status,
}
if item.CurrentVariantID != nil {
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
if err != nil {
return result, err
}
for _, variant := range variants {
if variant.ID == *item.CurrentVariantID {
result.CurrentVariant = &AdminCourseVariantBrief{
ID: variant.PublicID,
Name: variant.Name,
RouteCode: variant.RouteCode,
Status: variant.Status,
}
break
}
}
}
return result, nil
}
func buildAdminPlaceSummary(item postgres.Place) AdminPlaceSummary {
return AdminPlaceSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Region: item.Region,
CoverURL: item.CoverURL,
Description: item.Description,
CenterPoint: decodeJSONMap(item.CenterPoint),
Status: item.Status,
}
}
func buildAdminTileReleaseView(item postgres.TileRelease) AdminTileReleaseView {
return AdminTileReleaseView{
ID: item.PublicID,
LegacyVersionID: item.LegacyMapVersionPub,
VersionCode: item.VersionCode,
Status: item.Status,
TileBaseURL: item.TileBaseURL,
MetaURL: item.MetaURL,
PublishedAssetRoot: item.PublishedAssetRoot,
Metadata: decodeJSONMap(item.MetadataJSON),
PublishedAt: item.PublishedAt,
}
}
func buildAdminCourseSourceSummary(item postgres.CourseSource) AdminCourseSourceSummary {
return AdminCourseSourceSummary{
ID: item.PublicID,
LegacyVersionID: item.LegacyPlayfieldVersionPub,
SourceType: item.SourceType,
FileURL: item.FileURL,
Checksum: item.Checksum,
ParserVersion: item.ParserVersion,
ImportStatus: item.ImportStatus,
Metadata: decodeJSONMap(item.MetadataJSON),
ImportedAt: item.ImportedAt,
}
}
func buildAdminCourseVariantView(item postgres.CourseVariant) AdminCourseVariantView {
return AdminCourseVariantView{
ID: item.PublicID,
SourceID: item.SourcePublicID,
Name: item.Name,
RouteCode: item.RouteCode,
Mode: item.Mode,
ControlCount: item.ControlCount,
Difficulty: item.Difficulty,
Status: item.Status,
IsDefault: item.IsDefault,
ConfigPatch: decodeJSONMap(item.ConfigPatch),
Metadata: decodeJSONMap(item.MetadataJSON),
}
}
func buildAdminRuntimeBindingSummary(item postgres.MapRuntimeBinding) AdminRuntimeBindingSummary {
return AdminRuntimeBindingSummary{
ID: item.PublicID,
EventID: item.EventPublicID,
PlaceID: item.PlacePublicID,
MapAssetID: item.MapAssetPublicID,
TileReleaseID: item.TileReleasePublicID,
CourseSetID: item.CourseSetPublicID,
CourseVariantID: item.CourseVariantPublicID,
Status: item.Status,
Notes: item.Notes,
}
}
func normalizeCourseSourceStatus(value string) string {
switch strings.TrimSpace(value) {
case "draft":
return "draft"
case "parsed":
return "parsed"
case "failed":
return "failed"
case "archived":
return "archived"
default:
return "imported"
}
}
func normalizeRuntimeBindingStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "disabled":
return "disabled"
case "archived":
return "archived"
default:
return "draft"
}
}
func normalizeReleaseStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "published":
return "published"
case "retired":
return "retired"
case "archived":
return "archived"
default:
return "draft"
}
}

View File

@@ -58,10 +58,13 @@ type EventConfigBuildView struct {
}
type PublishedReleaseView struct {
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type ImportLocalEventConfigInput struct {
@@ -75,7 +78,10 @@ type BuildPreviewInput struct {
}
type PublishBuildInput struct {
BuildID string `json:"buildId"`
BuildID string `json:"buildId"`
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
@@ -306,6 +312,19 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
runtimeBindingID, runtimeSummary, err := s.resolvePublishRuntimeBinding(ctx, event.ID, input.RuntimeBindingID)
if err != nil {
return nil, err
}
presentationID, presentationSummary, err := s.resolvePublishPresentation(ctx, event.ID, input.PresentationID)
if err != nil {
return nil, err
}
contentBundleID, contentBundleSummary, err := s.resolvePublishContentBundle(ctx, event.ID, input.ContentBundleID)
if err != nil {
return nil, err
}
manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
@@ -355,6 +374,9 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
ManifestChecksum: &checksum,
RouteCode: routeCode,
BuildID: &buildRecord.ID,
RuntimeBindingID: runtimeBindingID,
PresentationID: presentationID,
ContentBundleID: contentBundleID,
Status: "published",
PayloadJSON: buildRecord.ManifestJSON,
})
@@ -386,11 +408,160 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
ManifestChecksumSha256: releaseRecord.ManifestChecksum,
RouteCode: releaseRecord.RouteCode,
},
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
Runtime: runtimeSummary,
Presentation: presentationSummary,
ContentBundle: contentBundleSummary,
}, nil
}
func (s *ConfigService) resolvePublishRuntimeBinding(ctx context.Context, eventID string, runtimeBindingPublicID string) (*string, *RuntimeSummaryView, error) {
runtimeBindingPublicID = strings.TrimSpace(runtimeBindingPublicID)
if runtimeBindingPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults == nil || defaults.RuntimeBindingID == nil || defaults.RuntimeBindingPublicID == nil || defaults.PlacePublicID == nil || defaults.MapAssetPublicID == nil || defaults.TileReleasePublicID == nil || defaults.CourseSetPublicID == nil || defaults.CourseVariantPublicID == nil {
return nil, nil, nil
}
return defaults.RuntimeBindingID, &RuntimeSummaryView{
RuntimeBindingID: *defaults.RuntimeBindingPublicID,
PlaceID: *defaults.PlacePublicID,
PlaceName: defaults.PlaceName,
MapID: *defaults.MapAssetPublicID,
MapName: defaults.MapAssetName,
TileReleaseID: *defaults.TileReleasePublicID,
CourseSetID: *defaults.CourseSetPublicID,
CourseVariantID: *defaults.CourseVariantPublicID,
CourseVariantName: defaults.CourseVariantName,
RouteCode: defaults.RuntimeRouteCode,
}, nil
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, runtimeBindingPublicID)
if err != nil {
return nil, nil, err
}
if runtimeBinding == nil {
return nil, nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to build event")
}
return &runtimeBinding.ID, &RuntimeSummaryView{
RuntimeBindingID: runtimeBinding.PublicID,
PlaceID: runtimeBinding.PlacePublicID,
MapID: runtimeBinding.MapAssetPublicID,
TileReleaseID: runtimeBinding.TileReleasePublicID,
CourseSetID: runtimeBinding.CourseSetPublicID,
CourseVariantID: runtimeBinding.CourseVariantPublicID,
RouteCode: nil,
}, nil
}
func (s *ConfigService) resolvePublishPresentation(ctx context.Context, eventID string, presentationPublicID string) (*string, *PresentationSummaryView, error) {
presentationPublicID = strings.TrimSpace(presentationPublicID)
if presentationPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.PresentationID != nil && defaults.PresentationPublicID != nil {
record, err := s.store.GetEventPresentationByPublicID(ctx, *defaults.PresentationPublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.PresentationID, summary, nil
}
}
record, err := s.store.GetDefaultEventPresentationByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetEventPresentationByPublicID(ctx, presentationPublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to build event")
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) resolvePublishContentBundle(ctx context.Context, eventID string, contentBundlePublicID string) (*string, *ContentBundleSummaryView, error) {
contentBundlePublicID = strings.TrimSpace(contentBundlePublicID)
if contentBundlePublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.ContentBundleID != nil && defaults.ContentBundlePublicID != nil {
record, err := s.store.GetContentBundleByPublicID(ctx, *defaults.ContentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.ContentBundleID, summary, nil
}
}
record, err := s.store.GetDefaultContentBundleByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetContentBundleByPublicID(ctx, contentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to build event")
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {

View File

@@ -33,8 +33,11 @@ type EventPlayResult struct {
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Play struct {
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
Play struct {
AssignmentMode *string `json:"assignmentMode,omitempty"`
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
CanLaunch bool `json:"canLaunch"`
@@ -100,6 +103,19 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])

View File

@@ -30,7 +30,10 @@ type EventDetailResult struct {
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
}
type LaunchEventInput struct {
@@ -48,9 +51,12 @@ type LaunchEventResult struct {
DisplayName string `json:"displayName"`
} `json:"event"`
Launch struct {
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Variant *VariantBindingView `json:"variant,omitempty"`
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Variant *VariantBindingView `json:"variant,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
Config struct {
ConfigURL string `json:"configUrl"`
ConfigLabel string `json:"configLabel"`
@@ -110,6 +116,19 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
return result, nil
}
@@ -205,6 +224,19 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
result.Launch.Source = LaunchSourceEventCurrentRelease
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Launch.Variant = variant
result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.Launch.Presentation = enrichedPresentation
}
result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.Launch.ContentBundle = enrichedBundle
}
result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum

View File

@@ -1,6 +1,11 @@
package service
import "cmr-backend/internal/store/postgres"
import (
"context"
"strings"
"cmr-backend/internal/store/postgres"
)
const (
LaunchSourceEventCurrentRelease = "event_current_release"
@@ -18,6 +23,36 @@ type ResolvedReleaseView struct {
RouteCode *string `json:"routeCode,omitempty"`
}
type RuntimeSummaryView struct {
RuntimeBindingID string `json:"runtimeBindingId"`
PlaceID string `json:"placeId"`
PlaceName *string `json:"placeName,omitempty"`
MapID string `json:"mapId"`
MapName *string `json:"mapName,omitempty"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
CourseVariantName *string `json:"courseVariantName,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}
type PresentationSummaryView struct {
PresentationID string `json:"presentationId"`
Name *string `json:"name,omitempty"`
PresentationType *string `json:"presentationType,omitempty"`
TemplateKey *string `json:"templateKey,omitempty"`
Version *string `json:"version,omitempty"`
}
type ContentBundleSummaryView struct {
ContentBundleID string `json:"contentBundleId"`
Name *string `json:"name,omitempty"`
BundleType *string `json:"bundleType,omitempty"`
Version *string `json:"version,omitempty"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
}
func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView {
if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil
@@ -35,6 +70,102 @@ func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *Resolv
}
}
func buildRuntimeSummaryFromEvent(event *postgres.Event) *RuntimeSummaryView {
if event == nil ||
event.RuntimeBindingID == nil ||
event.PlacePublicID == nil ||
event.MapAssetPublicID == nil ||
event.TileReleasePublicID == nil ||
event.CourseSetPublicID == nil ||
event.CourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *event.RuntimeBindingID,
PlaceID: *event.PlacePublicID,
PlaceName: event.PlaceName,
MapID: *event.MapAssetPublicID,
MapName: event.MapAssetName,
TileReleaseID: *event.TileReleasePublicID,
CourseSetID: *event.CourseSetPublicID,
CourseVariantID: *event.CourseVariantID,
CourseVariantName: event.CourseVariantName,
RouteCode: firstNonNilString(event.RuntimeRouteCode, event.RouteCode),
}
}
func buildRuntimeSummaryFromRelease(release *postgres.EventRelease) *RuntimeSummaryView {
if release == nil ||
release.RuntimeBindingID == nil ||
release.PlacePublicID == nil ||
release.MapAssetPublicID == nil ||
release.TileReleaseID == nil ||
release.CourseSetID == nil ||
release.CourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *release.RuntimeBindingID,
PlaceID: *release.PlacePublicID,
PlaceName: release.PlaceName,
MapID: *release.MapAssetPublicID,
MapName: release.MapAssetName,
TileReleaseID: *release.TileReleaseID,
CourseSetID: *release.CourseSetID,
CourseVariantID: *release.CourseVariantID,
CourseVariantName: release.CourseVariantName,
RouteCode: firstNonNilString(release.RuntimeRouteCode, release.RouteCode),
}
}
func buildPresentationSummaryFromEvent(event *postgres.Event) *PresentationSummaryView {
if event == nil || event.PresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *event.PresentationID,
Name: event.PresentationName,
PresentationType: event.PresentationType,
}
}
func buildPresentationSummaryFromRelease(release *postgres.EventRelease) *PresentationSummaryView {
if release == nil || release.PresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *release.PresentationID,
Name: release.PresentationName,
PresentationType: release.PresentationType,
}
}
func buildContentBundleSummaryFromEvent(event *postgres.Event) *ContentBundleSummaryView {
if event == nil || event.ContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *event.ContentBundleID,
Name: event.ContentBundleName,
EntryURL: event.ContentEntryURL,
AssetRootURL: event.ContentAssetRootURL,
}
}
func buildContentBundleSummaryFromRelease(release *postgres.EventRelease) *ContentBundleSummaryView {
if release == nil || release.ContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *release.ContentBundleID,
Name: release.ContentBundleName,
EntryURL: release.ContentEntryURL,
AssetRootURL: release.ContentAssetURL,
}
}
func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView {
if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil {
return nil
@@ -54,3 +185,96 @@ func buildResolvedReleaseFromSession(session *postgres.Session, source string) *
}
return view
}
func loadPresentationSummaryByPublicID(ctx context.Context, store *postgres.Store, publicID *string) (*PresentationSummaryView, error) {
if store == nil || publicID == nil || strings.TrimSpace(*publicID) == "" {
return nil, nil
}
record, err := store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(*publicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return buildPresentationSummaryFromRecord(record)
}
func loadContentBundleSummaryByPublicID(ctx context.Context, store *postgres.Store, publicID *string) (*ContentBundleSummaryView, error) {
if store == nil || publicID == nil || strings.TrimSpace(*publicID) == "" {
return nil, nil
}
record, err := store.GetContentBundleByPublicID(ctx, strings.TrimSpace(*publicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return buildContentBundleSummaryFromRecord(record)
}
func buildPresentationSummaryFromRecord(record *postgres.EventPresentation) (*PresentationSummaryView, error) {
if record == nil {
return nil, nil
}
summary := &PresentationSummaryView{
PresentationID: record.PublicID,
Name: &record.Name,
PresentationType: &record.PresentationType,
}
schema, err := decodeJSONObject(record.SchemaJSON)
if err != nil {
return nil, err
}
summary.TemplateKey = readStringField(schema, "templateKey")
summary.Version = readStringField(schema, "version")
return summary, nil
}
func buildContentBundleSummaryFromRecord(record *postgres.ContentBundle) (*ContentBundleSummaryView, error) {
if record == nil {
return nil, nil
}
summary := &ContentBundleSummaryView{
ContentBundleID: record.PublicID,
Name: &record.Name,
EntryURL: record.EntryURL,
AssetRootURL: record.AssetRootURL,
}
metadata, err := decodeJSONObject(record.MetadataJSON)
if err != nil {
return nil, err
}
summary.BundleType = readStringField(metadata, "bundleType")
summary.Version = readStringField(metadata, "version")
return summary, nil
}
func readStringField(object map[string]any, key string) *string {
if object == nil {
return nil
}
value, ok := object[key]
if !ok {
return nil
}
text, ok := value.(string)
if !ok {
return nil
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
return &text
}
func firstNonNilString(values ...*string) *string {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}