推进活动系统最小成品闭环与游客体验

This commit is contained in:
2026-04-07 19:05:18 +08:00
parent 1a6008449e
commit 6cd16f08dd
102 changed files with 16087 additions and 3556 deletions

View File

@@ -44,6 +44,7 @@ type CreateAdminPlaceInput struct {
type AdminMapAssetSummary struct {
ID string `json:"id"`
PlaceID string `json:"placeId"`
PlaceName *string `json:"placeName,omitempty"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
@@ -61,9 +62,10 @@ type AdminTileReleaseBrief struct {
}
type AdminMapAssetDetail struct {
MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileReleases []AdminTileReleaseView `json:"tileReleases"`
CourseSets []AdminCourseSetBrief `json:"courseSets"`
MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileReleases []AdminTileReleaseView `json:"tileReleases"`
CourseSets []AdminCourseSetBrief `json:"courseSets"`
LinkedEvents []AdminMapLinkedEventBrief `json:"linkedEvents"`
}
type CreateAdminMapAssetInput struct {
@@ -76,6 +78,31 @@ type CreateAdminMapAssetInput struct {
Status string `json:"status"`
}
type UpdateAdminMapAssetInput struct {
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"`
}
type AdminMapLinkedEventBrief struct {
EventID string `json:"eventId"`
Title string `json:"title"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
IsDefaultExperience bool `json:"isDefaultExperience"`
ShowInEventList bool `json:"showInEventList"`
CurrentReleaseID *string `json:"currentReleaseId,omitempty"`
ConfigLabel *string `json:"configLabel,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
CurrentPresentationID *string `json:"currentPresentationId,omitempty"`
CurrentPresentation *string `json:"currentPresentation,omitempty"`
CurrentContentBundleID *string `json:"currentContentBundleId,omitempty"`
CurrentContentBundle *string `json:"currentContentBundle,omitempty"`
}
type AdminTileReleaseView struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
@@ -202,6 +229,66 @@ type CreateAdminRuntimeBindingInput struct {
Notes *string `json:"notes,omitempty"`
}
type ImportAdminTileReleaseInput struct {
PlaceCode string `json:"placeCode"`
PlaceName string `json:"placeName"`
PlaceRegion *string `json:"placeRegion,omitempty"`
PlaceCoverURL *string `json:"placeCoverUrl,omitempty"`
PlaceDescription *string `json:"placeDescription,omitempty"`
PlaceCenterPoint map[string]any `json:"placeCenterPoint,omitempty"`
MapAssetCode string `json:"mapAssetCode"`
MapAssetName string `json:"mapAssetName"`
MapType string `json:"mapType"`
MapCoverURL *string `json:"mapCoverUrl,omitempty"`
MapDescription *string `json:"mapDescription,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 ImportAdminTileReleaseResult struct {
Place AdminPlaceSummary `json:"place"`
MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileRelease AdminTileReleaseView `json:"tileRelease"`
}
type ImportAdminCourseRouteInput struct {
Name string `json:"name"`
RouteCode string `json:"routeCode"`
FileURL string `json:"fileUrl"`
SourceType string `json:"sourceType"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ImportAdminCourseSetBatchInput struct {
PlaceCode string `json:"placeCode"`
PlaceName string `json:"placeName"`
MapAssetCode string `json:"mapAssetCode"`
MapAssetName string `json:"mapAssetName"`
MapType string `json:"mapType"`
CourseSetCode string `json:"courseSetCode"`
CourseSetName string `json:"courseSetName"`
Mode string `json:"mode"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
DefaultRouteCode *string `json:"defaultRouteCode,omitempty"`
Routes []ImportAdminCourseRouteInput `json:"routes"`
}
type ImportAdminCourseSetBatchResult struct {
Place AdminPlaceSummary `json:"place"`
MapAsset AdminMapAssetSummary `json:"mapAsset"`
CourseSet AdminCourseSetBrief `json:"courseSet"`
Variants []AdminCourseVariantView `json:"variants"`
}
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
return &AdminProductionService{store: store}
}
@@ -218,6 +305,22 @@ func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]A
return result, nil
}
func (s *AdminProductionService) ListMapAssets(ctx context.Context, limit int) ([]AdminMapAssetSummary, error) {
items, err := s.store.ListMapAssets(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminMapAssetSummary, 0, len(items))
for _, item := range items {
summary, err := s.buildAdminMapAssetSummary(ctx, item)
if err != nil {
return nil, err
}
result = append(result, summary)
}
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)
@@ -362,10 +465,15 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
if err != nil {
return nil, err
}
linkedEvents, err := s.store.ListMapAssetLinkedEvents(ctx, item.ID, 100)
if err != nil {
return nil, err
}
result := &AdminMapAssetDetail{
MapAsset: summary,
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
LinkedEvents: make([]AdminMapLinkedEventBrief, 0, len(linkedEvents)),
}
for _, release := range tileReleases {
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
@@ -377,9 +485,64 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
}
result.CourseSets = append(result.CourseSets, brief)
}
for _, linked := range linkedEvents {
result.LinkedEvents = append(result.LinkedEvents, buildAdminMapLinkedEventBrief(linked))
}
return result, nil
}
func (s *AdminProductionService) UpdateMapAsset(ctx context.Context, mapAssetPublicID string, input UpdateAdminMapAssetInput) (*AdminMapAssetSummary, 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")
}
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
input.MapType = strings.TrimSpace(input.MapType)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
if input.MapType == "" {
input.MapType = "standard"
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
updated, err := s.store.UpdateMapAsset(ctx, tx, postgres.UpdateMapAssetParams{
MapAssetID: item.ID,
Code: input.Code,
Name: input.Name,
MapType: input.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
}
refreshed, err := s.store.GetMapAssetByPublicID(ctx, updated.PublicID)
if err != nil {
return nil, err
}
if refreshed == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
result, err := s.buildAdminMapAssetSummary(ctx, *refreshed)
if err != nil {
return nil, err
}
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 {
@@ -748,6 +911,293 @@ func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input
return &result, nil
}
func (s *AdminProductionService) ImportTileRelease(ctx context.Context, input ImportAdminTileReleaseInput) (*ImportAdminTileReleaseResult, error) {
input.PlaceCode = strings.TrimSpace(input.PlaceCode)
input.PlaceName = strings.TrimSpace(input.PlaceName)
input.MapAssetCode = strings.TrimSpace(input.MapAssetCode)
input.MapAssetName = strings.TrimSpace(input.MapAssetName)
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
input.MetaURL = strings.TrimSpace(input.MetaURL)
if input.PlaceCode == "" || input.PlaceName == "" || input.MapAssetCode == "" || input.MapAssetName == "" || input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "placeCode, placeName, mapAssetCode, mapAssetName, versionCode, tileBaseUrl and metaUrl are required")
}
place, err := s.store.GetPlaceByCode(ctx, input.PlaceCode)
if err != nil {
return nil, err
}
if place == nil {
created, err := s.CreatePlace(ctx, CreateAdminPlaceInput{
Code: input.PlaceCode,
Name: input.PlaceName,
Region: trimStringPtr(input.PlaceRegion),
CoverURL: trimStringPtr(input.PlaceCoverURL),
Description: trimStringPtr(input.PlaceDescription),
CenterPoint: input.PlaceCenterPoint,
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
place, err = s.store.GetPlaceByPublicID(ctx, created.ID)
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.GetMapAssetByCode(ctx, input.MapAssetCode)
if err != nil {
return nil, err
}
if mapAsset == nil {
created, err := s.CreateMapAsset(ctx, place.PublicID, CreateAdminMapAssetInput{
Code: input.MapAssetCode,
Name: input.MapAssetName,
MapType: strings.TrimSpace(input.MapType),
CoverURL: trimStringPtr(input.MapCoverURL),
Description: trimStringPtr(input.MapDescription),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
mapAsset, err = s.store.GetMapAssetByPublicID(ctx, created.ID)
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")
}
release, err := s.store.GetTileReleaseByMapAssetIDAndVersionCode(ctx, mapAsset.ID, input.VersionCode)
if err != nil {
return nil, err
}
if release == nil {
created, err := s.CreateTileRelease(ctx, mapAsset.PublicID, CreateAdminTileReleaseInput{
VersionCode: input.VersionCode,
Status: normalizeReleaseStatus(input.Status),
TileBaseURL: input.TileBaseURL,
MetaURL: input.MetaURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
Metadata: input.Metadata,
SetAsCurrent: input.SetAsCurrent,
})
if err != nil {
return nil, err
}
release, err = s.store.GetTileReleaseByPublicID(ctx, created.ID)
if err != nil {
return nil, err
}
} else if input.SetAsCurrent {
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
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
}
mapAsset, err = s.store.GetMapAssetByPublicID(ctx, mapAsset.PublicID)
if err != nil {
return nil, err
}
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "tile_release_not_found", "tile release not found")
}
placeSummary := buildAdminPlaceSummary(*place)
mapSummary, err := s.buildAdminMapAssetSummary(ctx, *mapAsset)
if err != nil {
return nil, err
}
return &ImportAdminTileReleaseResult{
Place: placeSummary,
MapAsset: mapSummary,
TileRelease: buildAdminTileReleaseView(*release),
}, nil
}
func (s *AdminProductionService) ImportCourseSetKMLBatch(ctx context.Context, input ImportAdminCourseSetBatchInput) (*ImportAdminCourseSetBatchResult, error) {
input.PlaceCode = strings.TrimSpace(input.PlaceCode)
input.PlaceName = strings.TrimSpace(input.PlaceName)
input.MapAssetCode = strings.TrimSpace(input.MapAssetCode)
input.MapAssetName = strings.TrimSpace(input.MapAssetName)
input.CourseSetCode = strings.TrimSpace(input.CourseSetCode)
input.CourseSetName = strings.TrimSpace(input.CourseSetName)
input.Mode = strings.TrimSpace(input.Mode)
if input.PlaceCode == "" || input.PlaceName == "" || input.MapAssetCode == "" || input.MapAssetName == "" || input.CourseSetCode == "" || input.CourseSetName == "" || input.Mode == "" || len(input.Routes) == 0 {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "placeCode, placeName, mapAssetCode, mapAssetName, courseSetCode, courseSetName, mode and routes are required")
}
place, err := s.store.GetPlaceByCode(ctx, input.PlaceCode)
if err != nil {
return nil, err
}
if place == nil {
created, err := s.CreatePlace(ctx, CreateAdminPlaceInput{
Code: input.PlaceCode,
Name: input.PlaceName,
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
place, err = s.store.GetPlaceByPublicID(ctx, created.ID)
if err != nil {
return nil, err
}
}
mapAsset, err := s.store.GetMapAssetByCode(ctx, input.MapAssetCode)
if err != nil {
return nil, err
}
if mapAsset == nil {
created, err := s.CreateMapAsset(ctx, place.PublicID, CreateAdminMapAssetInput{
Code: input.MapAssetCode,
Name: input.MapAssetName,
MapType: strings.TrimSpace(input.MapType),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
mapAsset, err = s.store.GetMapAssetByPublicID(ctx, created.ID)
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")
}
courseSet, err := s.store.GetCourseSetByCode(ctx, input.CourseSetCode)
if err != nil {
return nil, err
}
if courseSet == nil {
created, err := s.CreateCourseSet(ctx, mapAsset.PublicID, CreateAdminCourseSetInput{
Code: input.CourseSetCode,
Mode: input.Mode,
Name: input.CourseSetName,
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
courseSet, err = s.store.GetCourseSetByPublicID(ctx, created.ID)
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")
}
defaultRouteCode := ""
if input.DefaultRouteCode != nil {
defaultRouteCode = strings.TrimSpace(*input.DefaultRouteCode)
}
for _, route := range input.Routes {
route.Name = strings.TrimSpace(route.Name)
route.RouteCode = strings.TrimSpace(route.RouteCode)
route.FileURL = strings.TrimSpace(route.FileURL)
sourceType := strings.TrimSpace(route.SourceType)
if sourceType == "" {
sourceType = "kml"
}
if route.Name == "" || route.RouteCode == "" || route.FileURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "route name, routeCode and fileUrl are required")
}
existing, err := s.store.GetCourseVariantByCourseSetIDAndRouteCode(ctx, courseSet.ID, route.RouteCode)
if err != nil {
return nil, err
}
if existing != nil {
if defaultRouteCode != "" && route.RouteCode == defaultRouteCode {
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, existing.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
}
continue
}
source, err := s.CreateCourseSource(ctx, CreateAdminCourseSourceInput{
SourceType: sourceType,
FileURL: route.FileURL,
ImportStatus: "imported",
Metadata: route.Metadata,
})
if err != nil {
return nil, err
}
isDefault := defaultRouteCode != "" && route.RouteCode == defaultRouteCode
_, err = s.CreateCourseVariant(ctx, courseSet.PublicID, CreateAdminCourseVariantInput{
SourceID: &source.ID,
Name: route.Name,
RouteCode: &route.RouteCode,
Mode: input.Mode,
ControlCount: route.ControlCount,
Difficulty: trimStringPtr(route.Difficulty),
Status: normalizeCatalogStatus(route.Status),
IsDefault: isDefault,
Metadata: route.Metadata,
})
if err != nil {
return nil, err
}
}
courseSet, err = s.store.GetCourseSetByPublicID(ctx, courseSet.PublicID)
if err != nil {
return nil, err
}
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, courseSet.ID)
if err != nil {
return nil, err
}
views := make([]AdminCourseVariantView, 0, len(variants))
for _, variant := range variants {
views = append(views, buildAdminCourseVariantView(variant))
}
placeSummary := buildAdminPlaceSummary(*place)
mapSummary, err := s.buildAdminMapAssetSummary(ctx, *mapAsset)
if err != nil {
return nil, err
}
courseBrief, err := s.buildAdminCourseSetBrief(ctx, *courseSet)
if err != nil {
return nil, err
}
return &ImportAdminCourseSetBatchResult{
Place: placeSummary,
MapAsset: mapSummary,
CourseSet: courseBrief,
Variants: views,
}, nil
}
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
if err != nil {
@@ -764,6 +1214,7 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
result := AdminMapAssetSummary{
ID: item.PublicID,
PlaceID: item.PlaceID,
PlaceName: item.PlaceName,
LegacyMapID: item.LegacyMapPublicID,
Code: item.Code,
Name: item.Name,
@@ -791,6 +1242,24 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
return result, nil
}
func buildAdminMapLinkedEventBrief(item postgres.MapAssetLinkedEvent) AdminMapLinkedEventBrief {
return AdminMapLinkedEventBrief{
EventID: item.EventPublicID,
Title: item.DisplayName,
Summary: item.Summary,
Status: item.Status,
IsDefaultExperience: item.IsDefaultExperience,
ShowInEventList: item.ShowInEventList,
CurrentReleaseID: item.CurrentReleasePublicID,
ConfigLabel: item.ConfigLabel,
RouteCode: item.RouteCode,
CurrentPresentationID: item.CurrentPresentationID,
CurrentPresentation: item.CurrentPresentationName,
CurrentContentBundleID: item.CurrentContentBundleID,
CurrentContentBundle: item.CurrentContentBundleName,
}
}
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
result := AdminCourseSetBrief{
ID: item.PublicID,