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

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

@@ -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"
}
}