完善活动运营域与联调标准化
This commit is contained in:
935
backend/internal/service/admin_production_service.go
Normal file
935
backend/internal/service/admin_production_service.go
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user