推进活动系统最小成品闭环与游客体验
This commit is contained in:
303
backend/internal/service/admin_asset_service.go
Normal file
303
backend/internal/service/admin_asset_service.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/assets"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type AdminAssetService struct {
|
||||
store *postgres.Store
|
||||
assetBaseURL string
|
||||
assetPublisher *assets.OSSUtilPublisher
|
||||
}
|
||||
|
||||
type ManagedAssetSummary struct {
|
||||
ID string `json:"id"`
|
||||
AssetType string `json:"assetType"`
|
||||
AssetCode string `json:"assetCode"`
|
||||
Version string `json:"version"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
SourceMode string `json:"sourceMode"`
|
||||
StorageProvider string `json:"storageProvider"`
|
||||
ObjectKey *string `json:"objectKey,omitempty"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
FileName *string `json:"fileName,omitempty"`
|
||||
ContentType *string `json:"contentType,omitempty"`
|
||||
FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"`
|
||||
ChecksumSHA256 *string `json:"checksumSha256,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RegisterLinkAssetInput struct {
|
||||
AssetType string `json:"assetType"`
|
||||
AssetCode string `json:"assetCode"`
|
||||
Version string `json:"version"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
FileName *string `json:"fileName,omitempty"`
|
||||
ContentType *string `json:"contentType,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UploadAssetFileInput struct {
|
||||
AssetType string
|
||||
AssetCode string
|
||||
Version string
|
||||
Title *string
|
||||
ObjectDir *string
|
||||
FileName string
|
||||
ContentType string
|
||||
FileSize int64
|
||||
Checksum string
|
||||
TempPath string
|
||||
Status string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
func NewAdminAssetService(store *postgres.Store, assetBaseURL string, assetPublisher *assets.OSSUtilPublisher) *AdminAssetService {
|
||||
return &AdminAssetService{
|
||||
store: store,
|
||||
assetBaseURL: strings.TrimRight(strings.TrimSpace(assetBaseURL), "/"),
|
||||
assetPublisher: assetPublisher,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetSummary, error) {
|
||||
items, err := s.store.ListManagedAssets(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ManagedAssetSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, buildManagedAssetSummary(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) GetManagedAsset(ctx context.Context, assetPublicID string) (*ManagedAssetSummary, error) {
|
||||
record, err := s.store.GetManagedAssetByPublicID(ctx, strings.TrimSpace(assetPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "asset_not_found", "asset not found")
|
||||
}
|
||||
summary := buildManagedAssetSummary(*record)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) RegisterExternalLink(ctx context.Context, input RegisterLinkAssetInput) (*ManagedAssetSummary, error) {
|
||||
if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicURL := strings.TrimSpace(input.PublicURL)
|
||||
if publicURL == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "publicUrl is required")
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("asset")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
|
||||
PublicID: publicID,
|
||||
AssetType: normalizeCode(input.AssetType),
|
||||
AssetCode: normalizeCode(input.AssetCode),
|
||||
Version: strings.TrimSpace(input.Version),
|
||||
Title: assetTrimStringPtr(input.Title),
|
||||
SourceMode: "external_link",
|
||||
StorageProvider: "external",
|
||||
ObjectKey: nil,
|
||||
PublicURL: publicURL,
|
||||
FileName: assetTrimStringPtr(input.FileName),
|
||||
ContentType: assetTrimStringPtr(input.ContentType),
|
||||
FileSizeBytes: nil,
|
||||
ChecksumSHA256: nil,
|
||||
Status: normalizeManagedAssetStatus(input.Status),
|
||||
MetadataJSONB: normalizeJSONMap(input.Metadata),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary := buildManagedAssetSummary(*record)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) UploadAssetFile(ctx context.Context, input UploadAssetFileInput) (*ManagedAssetSummary, error) {
|
||||
if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.assetPublisher.Enabled() {
|
||||
return nil, apperr.New(http.StatusFailedDependency, "asset_publisher_not_configured", "asset publisher is not configured")
|
||||
}
|
||||
if strings.TrimSpace(input.TempPath) == "" || strings.TrimSpace(input.FileName) == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "upload file is required")
|
||||
}
|
||||
|
||||
objectDir := s.defaultObjectDir(input.AssetType, input.AssetCode, input.Version, input.ObjectDir)
|
||||
publicURL := s.assetBaseURL + "/" + strings.TrimLeft(path.Join(objectDir, sanitizeFileName(input.FileName)), "/")
|
||||
|
||||
if err := s.assetPublisher.UploadFile(ctx, publicURL, input.TempPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("asset")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objectKey := strings.TrimPrefix(strings.TrimPrefix(publicURL, s.assetBaseURL), "/")
|
||||
fileName := sanitizeFileName(input.FileName)
|
||||
contentType := detectContentType(fileName, input.ContentType)
|
||||
checksum := strings.TrimSpace(input.Checksum)
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
|
||||
PublicID: publicID,
|
||||
AssetType: normalizeCode(input.AssetType),
|
||||
AssetCode: normalizeCode(input.AssetCode),
|
||||
Version: strings.TrimSpace(input.Version),
|
||||
Title: assetTrimStringPtr(input.Title),
|
||||
SourceMode: "uploaded",
|
||||
StorageProvider: "oss",
|
||||
ObjectKey: stringPtr(objectKey),
|
||||
PublicURL: publicURL,
|
||||
FileName: stringPtr(fileName),
|
||||
ContentType: stringPtr(contentType),
|
||||
FileSizeBytes: &input.FileSize,
|
||||
ChecksumSHA256: stringPtr(checksum),
|
||||
Status: normalizeManagedAssetStatus(input.Status),
|
||||
MetadataJSONB: normalizeJSONMap(input.Metadata),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary := buildManagedAssetSummary(*record)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) defaultObjectDir(assetType, assetCode, version string, preferred *string) string {
|
||||
if preferred != nil && strings.TrimSpace(*preferred) != "" {
|
||||
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(*preferred), "\\", "/"), "/")
|
||||
}
|
||||
return path.Join("uploads", normalizeCode(assetType), normalizeCode(assetCode), strings.TrimSpace(version))
|
||||
}
|
||||
|
||||
func buildManagedAssetSummary(record postgres.ManagedAssetRecord) ManagedAssetSummary {
|
||||
return ManagedAssetSummary{
|
||||
ID: record.PublicID,
|
||||
AssetType: record.AssetType,
|
||||
AssetCode: record.AssetCode,
|
||||
Version: record.Version,
|
||||
Title: record.Title,
|
||||
SourceMode: record.SourceMode,
|
||||
StorageProvider: record.StorageProvider,
|
||||
ObjectKey: record.ObjectKey,
|
||||
PublicURL: record.PublicURL,
|
||||
FileName: record.FileName,
|
||||
ContentType: record.ContentType,
|
||||
FileSizeBytes: record.FileSizeBytes,
|
||||
ChecksumSHA256: record.ChecksumSHA256,
|
||||
Status: record.Status,
|
||||
Metadata: normalizeJSONMap(record.MetadataJSONB),
|
||||
}
|
||||
}
|
||||
|
||||
func validateManagedAssetInput(assetType, assetCode, version string) error {
|
||||
if normalizeCode(assetType) == "" || normalizeCode(assetCode) == "" || strings.TrimSpace(version) == "" {
|
||||
return apperr.New(http.StatusBadRequest, "invalid_params", "assetType, assetCode and version are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeManagedAssetStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "active":
|
||||
return "active"
|
||||
case "draft", "disabled", "archived":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCode(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
value = strings.ReplaceAll(value, " ", "-")
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
name = strings.ReplaceAll(name, " ", "-")
|
||||
return name
|
||||
}
|
||||
|
||||
func detectContentType(fileName, provided string) string {
|
||||
if strings.TrimSpace(provided) != "" {
|
||||
return strings.TrimSpace(provided)
|
||||
}
|
||||
if ext := filepath.Ext(fileName); ext != "" {
|
||||
if guessed := mime.TypeByExtension(ext); guessed != "" {
|
||||
return guessed
|
||||
}
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func assetTrimStringPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func normalizeJSONMap(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ type EventPlayResult struct {
|
||||
} `json:"release,omitempty"`
|
||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
|
||||
Preview *MapPreviewView `json:"preview,omitempty"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
Play struct {
|
||||
@@ -104,6 +105,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
|
||||
result.Runtime = buildRuntimeSummaryFromEvent(event)
|
||||
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
result.Preview = preview
|
||||
}
|
||||
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
|
||||
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -32,6 +32,7 @@ type EventDetailResult struct {
|
||||
} `json:"release,omitempty"`
|
||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
|
||||
Preview *MapPreviewView `json:"preview,omitempty"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
}
|
||||
@@ -71,6 +72,7 @@ type LaunchEventResult struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
IsGuest bool `json:"isGuest,omitempty"`
|
||||
} `json:"business"`
|
||||
} `json:"launch"`
|
||||
}
|
||||
@@ -117,6 +119,11 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
|
||||
result.Runtime = buildRuntimeSummaryFromEvent(event)
|
||||
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
result.Preview = preview
|
||||
}
|
||||
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
|
||||
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
|
||||
return nil, err
|
||||
@@ -245,5 +252,6 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
|
||||
result.Launch.Business.SessionToken = sessionToken
|
||||
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||
result.Launch.Business.RouteCode = routeCode
|
||||
result.Launch.Business.IsGuest = false
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type CardResult struct {
|
||||
TimeWindow string `json:"timeWindow"`
|
||||
CTAText string `json:"ctaText"`
|
||||
IsDefaultExperience bool `json:"isDefaultExperience"`
|
||||
ShowInEventList bool `json:"showInEventList"`
|
||||
EventType *string `json:"eventType,omitempty"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
@@ -153,6 +154,7 @@ func mapCards(cards []postgres.Card) []CardResult {
|
||||
TimeWindow: deriveCardTimeWindow(card),
|
||||
CTAText: deriveCardCTAText(card, statusCode),
|
||||
IsDefaultExperience: card.IsDefaultExperience,
|
||||
ShowInEventList: card.ShowInEventList,
|
||||
EventType: deriveCardEventType(card),
|
||||
CurrentPresentation: buildCardPresentationSummary(card),
|
||||
CurrentContentBundle: buildCardContentBundleSummary(card),
|
||||
|
||||
300
backend/internal/service/map_experience_service.go
Normal file
300
backend/internal/service/map_experience_service.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type MapExperienceService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
type ListExperienceMapsInput struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ExperienceMapSummary struct {
|
||||
PlaceID string `json:"placeId"`
|
||||
PlaceName string `json:"placeName"`
|
||||
MapID string `json:"mapId"`
|
||||
MapName string `json:"mapName"`
|
||||
CoverURL *string `json:"coverUrl,omitempty"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
DefaultExperienceCount int `json:"defaultExperienceCount"`
|
||||
DefaultExperienceEventIDs []string `json:"defaultExperienceEventIds"`
|
||||
}
|
||||
|
||||
type ExperienceMapDetail struct {
|
||||
PlaceID string `json:"placeId"`
|
||||
PlaceName string `json:"placeName"`
|
||||
MapID string `json:"mapId"`
|
||||
MapName string `json:"mapName"`
|
||||
CoverURL *string `json:"coverUrl,omitempty"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
TileBaseURL *string `json:"tileBaseUrl,omitempty"`
|
||||
TileMetaURL *string `json:"tileMetaUrl,omitempty"`
|
||||
DefaultExperienceCount int `json:"defaultExperienceCount"`
|
||||
DefaultExperiences []ExperienceEventSummary `json:"defaultExperiences"`
|
||||
}
|
||||
|
||||
type ExperienceEventSummary struct {
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
Subtitle *string `json:"subtitle,omitempty"`
|
||||
EventType *string `json:"eventType,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StatusCode string `json:"statusCode"`
|
||||
CTAText string `json:"ctaText"`
|
||||
IsDefaultExperience bool `json:"isDefaultExperience"`
|
||||
ShowInEventList bool `json:"showInEventList"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
}
|
||||
|
||||
func NewMapExperienceService(store *postgres.Store) *MapExperienceService {
|
||||
return &MapExperienceService{store: store}
|
||||
}
|
||||
|
||||
func (s *MapExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
|
||||
rows, err := s.store.ListMapExperienceRows(ctx, input.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapExperienceSummaries(rows), nil
|
||||
}
|
||||
|
||||
func (s *MapExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
|
||||
mapPublicID = strings.TrimSpace(mapPublicID)
|
||||
if mapPublicID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_map_id", "map id is required")
|
||||
}
|
||||
rows, err := s.store.ListMapExperienceRowsByMapPublicID(ctx, mapPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
|
||||
}
|
||||
return buildMapExperienceDetail(rows), nil
|
||||
}
|
||||
|
||||
func mapExperienceSummaries(rows []postgres.MapExperienceRow) []ExperienceMapSummary {
|
||||
ordered := make([]string, 0, len(rows))
|
||||
index := make(map[string]*ExperienceMapSummary)
|
||||
for _, row := range rows {
|
||||
item, ok := index[row.MapAssetPublicID]
|
||||
if !ok {
|
||||
summary := &ExperienceMapSummary{
|
||||
PlaceID: row.PlacePublicID,
|
||||
PlaceName: row.PlaceName,
|
||||
MapID: row.MapAssetPublicID,
|
||||
MapName: row.MapAssetName,
|
||||
CoverURL: row.MapCoverURL,
|
||||
Summary: normalizeOptionalText(row.MapSummary),
|
||||
DefaultExperienceEventIDs: []string{},
|
||||
}
|
||||
index[row.MapAssetPublicID] = summary
|
||||
ordered = append(ordered, row.MapAssetPublicID)
|
||||
item = summary
|
||||
}
|
||||
if row.EventPublicID != nil && row.EventIsDefaultExperience {
|
||||
if !containsString(item.DefaultExperienceEventIDs, *row.EventPublicID) {
|
||||
item.DefaultExperienceEventIDs = append(item.DefaultExperienceEventIDs, *row.EventPublicID)
|
||||
item.DefaultExperienceCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ExperienceMapSummary, 0, len(ordered))
|
||||
for _, id := range ordered {
|
||||
result = append(result, *index[id])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildMapExperienceDetail(rows []postgres.MapExperienceRow) *ExperienceMapDetail {
|
||||
first := rows[0]
|
||||
result := &ExperienceMapDetail{
|
||||
PlaceID: first.PlacePublicID,
|
||||
PlaceName: first.PlaceName,
|
||||
MapID: first.MapAssetPublicID,
|
||||
MapName: first.MapAssetName,
|
||||
CoverURL: first.MapCoverURL,
|
||||
Summary: normalizeOptionalText(first.MapSummary),
|
||||
TileBaseURL: first.TileBaseURL,
|
||||
TileMetaURL: first.TileMetaURL,
|
||||
DefaultExperiences: make([]ExperienceEventSummary, 0, 4),
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, row := range rows {
|
||||
if row.EventPublicID == nil || !row.EventIsDefaultExperience {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[*row.EventPublicID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[*row.EventPublicID] = struct{}{}
|
||||
result.DefaultExperiences = append(result.DefaultExperiences, buildExperienceEventSummary(row))
|
||||
}
|
||||
result.DefaultExperienceCount = len(result.DefaultExperiences)
|
||||
return result
|
||||
}
|
||||
|
||||
func buildExperienceEventSummary(row postgres.MapExperienceRow) ExperienceEventSummary {
|
||||
statusCode, statusText := deriveExperienceEventStatus(row)
|
||||
return ExperienceEventSummary{
|
||||
EventID: valueOrEmpty(row.EventPublicID),
|
||||
Title: fallbackText(row.EventDisplayName, "未命名活动"),
|
||||
Subtitle: normalizeOptionalText(row.EventSummary),
|
||||
EventType: deriveExperienceEventType(row),
|
||||
Status: statusText,
|
||||
StatusCode: statusCode,
|
||||
CTAText: deriveExperienceEventCTA(statusCode, row.EventIsDefaultExperience),
|
||||
IsDefaultExperience: row.EventIsDefaultExperience,
|
||||
ShowInEventList: row.EventShowInEventList,
|
||||
CurrentPresentation: buildPresentationSummaryFromMapExperienceRow(row),
|
||||
CurrentContentBundle: buildContentBundleSummaryFromMapExperienceRow(row),
|
||||
}
|
||||
}
|
||||
|
||||
func deriveExperienceEventStatus(row postgres.MapExperienceRow) (string, string) {
|
||||
if row.EventStatus == nil {
|
||||
return "pending", "状态待确认"
|
||||
}
|
||||
switch strings.TrimSpace(*row.EventStatus) {
|
||||
case "active":
|
||||
if row.EventReleasePayloadJSON == nil || strings.TrimSpace(*row.EventReleasePayloadJSON) == "" {
|
||||
return "upcoming", "即将开始"
|
||||
}
|
||||
if row.EventPresentationID == nil || row.EventContentBundleID == nil {
|
||||
return "upcoming", "即将开始"
|
||||
}
|
||||
return "running", "进行中"
|
||||
case "archived", "disabled", "inactive":
|
||||
return "ended", "已结束"
|
||||
default:
|
||||
return "pending", "状态待确认"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveExperienceEventCTA(statusCode string, isDefault bool) string {
|
||||
if isDefault {
|
||||
return "进入体验"
|
||||
}
|
||||
switch statusCode {
|
||||
case "running":
|
||||
return "进入活动"
|
||||
case "ended":
|
||||
return "查看回顾"
|
||||
default:
|
||||
return "查看详情"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveExperienceEventType(row postgres.MapExperienceRow) *string {
|
||||
if row.EventReleasePayloadJSON != nil {
|
||||
payload, err := decodeJSONObject(*row.EventReleasePayloadJSON)
|
||||
if err == nil {
|
||||
if game, ok := payload["game"].(map[string]any); ok {
|
||||
if rawMode, ok := game["mode"].(string); ok {
|
||||
switch strings.TrimSpace(rawMode) {
|
||||
case "classic-sequential":
|
||||
text := "顺序赛"
|
||||
return &text
|
||||
case "score-o":
|
||||
text := "积分赛"
|
||||
return &text
|
||||
}
|
||||
}
|
||||
}
|
||||
if plan := resolveVariantPlan(row.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
|
||||
text := "多赛道"
|
||||
return &text
|
||||
}
|
||||
}
|
||||
}
|
||||
if row.EventIsDefaultExperience {
|
||||
text := "体验活动"
|
||||
return &text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPresentationSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *PresentationSummaryView {
|
||||
if row.EventPresentationID == nil {
|
||||
return nil
|
||||
}
|
||||
summary := &PresentationSummaryView{
|
||||
PresentationID: *row.EventPresentationID,
|
||||
Name: row.EventPresentationName,
|
||||
PresentationType: row.EventPresentationType,
|
||||
}
|
||||
if row.EventPresentationSchema != nil && strings.TrimSpace(*row.EventPresentationSchema) != "" {
|
||||
if schema, err := decodeJSONObject(*row.EventPresentationSchema); err == nil {
|
||||
summary.TemplateKey = readStringField(schema, "templateKey")
|
||||
summary.Version = readStringField(schema, "version")
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func buildContentBundleSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *ContentBundleSummaryView {
|
||||
if row.EventContentBundleID == nil {
|
||||
return nil
|
||||
}
|
||||
summary := &ContentBundleSummaryView{
|
||||
ContentBundleID: *row.EventContentBundleID,
|
||||
Name: row.EventContentBundleName,
|
||||
EntryURL: row.EventContentEntryURL,
|
||||
AssetRootURL: row.EventContentAssetRootURL,
|
||||
}
|
||||
if row.EventContentMetadataJSON != nil && strings.TrimSpace(*row.EventContentMetadataJSON) != "" {
|
||||
if metadata, err := decodeJSONObject(*row.EventContentMetadataJSON); err == nil {
|
||||
summary.BundleType = readStringField(metadata, "bundleType")
|
||||
summary.Version = readStringField(metadata, "version")
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func normalizeOptionalText(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func fallbackText(value *string, fallback string) string {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return fallback
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, item := range values {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
395
backend/internal/service/ops_auth_service.go
Normal file
395
backend/internal/service/ops_auth_service.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/jwtx"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type OpsAuthSettings struct {
|
||||
AppEnv string
|
||||
RefreshTTL time.Duration
|
||||
SMSCodeTTL time.Duration
|
||||
SMSCodeCooldown time.Duration
|
||||
SMSProvider string
|
||||
DevSMSCode string
|
||||
}
|
||||
|
||||
type OpsAuthService struct {
|
||||
cfg OpsAuthSettings
|
||||
store *postgres.Store
|
||||
jwtManager *jwtx.Manager
|
||||
}
|
||||
|
||||
type OpsSendSMSCodeInput struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Mobile string `json:"mobile"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
Scene string `json:"scene"`
|
||||
}
|
||||
|
||||
type OpsRegisterInput struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Mobile string `json:"mobile"`
|
||||
Code string `json:"code"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
type OpsLoginSMSInput struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Mobile string `json:"mobile"`
|
||||
Code string `json:"code"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
|
||||
type OpsRefreshTokenInput struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
|
||||
type OpsLogoutInput struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type OpsAuthUser struct {
|
||||
ID string `json:"id"`
|
||||
PublicID string `json:"publicId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
RoleCode string `json:"roleCode"`
|
||||
}
|
||||
|
||||
type OpsAuthResult struct {
|
||||
User OpsAuthUser `json:"user"`
|
||||
Tokens AuthTokens `json:"tokens"`
|
||||
NewUser bool `json:"newUser"`
|
||||
DevLoginBypass bool `json:"devLoginBypass,omitempty"`
|
||||
}
|
||||
|
||||
func NewOpsAuthService(cfg OpsAuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *OpsAuthService {
|
||||
return &OpsAuthService{cfg: cfg, store: store, jwtManager: jwtManager}
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) SendSMSCode(ctx context.Context, input OpsSendSMSCodeInput) (*SendSMSCodeResult, error) {
|
||||
input.CountryCode = normalizeCountryCode(input.CountryCode)
|
||||
input.Mobile = normalizeMobile(input.Mobile)
|
||||
input.Scene = normalizeOpsScene(input.Scene)
|
||||
if input.Mobile == "" || strings.TrimSpace(input.DeviceKey) == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
|
||||
}
|
||||
|
||||
latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, "ops", input.Scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if latest != nil && latest.CooldownUntil.After(now) {
|
||||
return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
|
||||
}
|
||||
|
||||
code := s.cfg.DevSMSCode
|
||||
if code == "" {
|
||||
code, err = security.GenerateNumericCode(6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
expiresAt := now.Add(s.cfg.SMSCodeTTL)
|
||||
cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
|
||||
if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
|
||||
Scene: input.Scene,
|
||||
CountryCode: input.CountryCode,
|
||||
Mobile: input.Mobile,
|
||||
ClientType: "ops",
|
||||
DeviceKey: input.DeviceKey,
|
||||
CodeHash: security.HashText(code),
|
||||
ProviderName: s.cfg.SMSProvider,
|
||||
ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider, "channel": "ops_console"},
|
||||
ExpiresAt: expiresAt,
|
||||
CooldownUntil: cooldownUntil,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SendSMSCodeResult{
|
||||
TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
|
||||
CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
|
||||
}
|
||||
if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
|
||||
result.DevCode = &code
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) Register(ctx context.Context, input OpsRegisterInput) (*OpsAuthResult, error) {
|
||||
input.CountryCode = normalizeCountryCode(input.CountryCode)
|
||||
input.Mobile = normalizeMobile(input.Mobile)
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" || input.DisplayName == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code, deviceKey and displayName are required")
|
||||
}
|
||||
|
||||
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_register")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !consumed {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
|
||||
}
|
||||
|
||||
existing, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, apperr.New(http.StatusConflict, "ops_user_exists", "ops user already exists")
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("ops")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.store.CreateOpsUser(ctx, tx, postgres.CreateOpsUserParams{
|
||||
PublicID: publicID,
|
||||
CountryCode: input.CountryCode,
|
||||
Mobile: input.Mobile,
|
||||
DisplayName: input.DisplayName,
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleCode := "operator"
|
||||
count, err := s.store.CountOpsUsers(ctx)
|
||||
if err == nil && count == 0 {
|
||||
roleCode = "owner"
|
||||
}
|
||||
role, err := s.store.GetOpsRoleByCode(ctx, tx, roleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
return nil, apperr.New(http.StatusInternalServerError, "ops_role_missing", "default ops role is missing")
|
||||
}
|
||||
if err := s.store.AssignOpsRole(ctx, tx, user.ID, role.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) LoginSMS(ctx context.Context, input OpsLoginSMSInput) (*OpsAuthResult, error) {
|
||||
input.CountryCode = normalizeCountryCode(input.CountryCode)
|
||||
input.Mobile = normalizeMobile(input.Mobile)
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||
if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
|
||||
}
|
||||
|
||||
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_login")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !consumed {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
|
||||
}
|
||||
|
||||
user, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return nil, apperr.New(http.StatusForbidden, "ops_user_inactive", "ops user is not active")
|
||||
}
|
||||
if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) Refresh(ctx context.Context, input OpsRefreshTokenInput) (*OpsAuthResult, error) {
|
||||
input.RefreshToken = strings.TrimSpace(input.RefreshToken)
|
||||
if input.RefreshToken == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.GetOpsRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
|
||||
}
|
||||
user, err := s.store.GetOpsUserByID(ctx, tx, record.OpsUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil || user.Status != "active" {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
|
||||
}
|
||||
result, newTokenID, err := s.issueAuthResult(ctx, tx, *user, nullableStringValue(record.DeviceKey), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.RotateOpsRefreshToken(ctx, tx, record.ID, newTokenID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) Logout(ctx context.Context, input OpsLogoutInput) error {
|
||||
if strings.TrimSpace(input.RefreshToken) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.store.RevokeOpsRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) GetMe(ctx context.Context, opsUserID string) (*OpsAuthUser, error) {
|
||||
user, err := s.store.GetOpsUserByID(ctx, s.store.Pool(), opsUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
|
||||
}
|
||||
role, err := s.store.GetPrimaryOpsRole(ctx, s.store.Pool(), user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := buildOpsAuthUser(*user, role)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) issueAuthResult(ctx context.Context, tx postgres.Tx, user postgres.OpsUser, deviceKey string, newUser bool) (*OpsAuthResult, string, error) {
|
||||
role, err := s.store.GetPrimaryOpsRole(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
roleCode := ""
|
||||
if role != nil {
|
||||
roleCode = role.RoleCode
|
||||
}
|
||||
accessToken, accessExpiresAt, err := s.jwtManager.IssueActorAccessToken(user.ID, user.PublicID, "ops", roleCode)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
refreshToken, err := security.GenerateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
refreshTokenHash := security.HashText(refreshToken)
|
||||
refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
|
||||
refreshID, err := s.store.CreateOpsRefreshToken(ctx, tx, postgres.CreateOpsRefreshTokenParams{
|
||||
OpsUserID: user.ID,
|
||||
DeviceKey: deviceKey,
|
||||
TokenHash: refreshTokenHash,
|
||||
ExpiresAt: refreshExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
result := &OpsAuthResult{
|
||||
User: buildOpsAuthUser(user, role),
|
||||
Tokens: AuthTokens{
|
||||
AccessToken: accessToken,
|
||||
AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
|
||||
RefreshToken: refreshToken,
|
||||
RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
|
||||
},
|
||||
NewUser: newUser,
|
||||
}
|
||||
return result, refreshID, nil
|
||||
}
|
||||
|
||||
func buildOpsAuthUser(user postgres.OpsUser, role *postgres.OpsRole) OpsAuthUser {
|
||||
roleCode := ""
|
||||
if role != nil {
|
||||
roleCode = role.RoleCode
|
||||
}
|
||||
return OpsAuthUser{
|
||||
ID: user.ID,
|
||||
PublicID: user.PublicID,
|
||||
DisplayName: user.DisplayName,
|
||||
Status: user.Status,
|
||||
RoleCode: roleCode,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpsScene(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "ops_register":
|
||||
return "ops_register"
|
||||
default:
|
||||
return "ops_login"
|
||||
}
|
||||
}
|
||||
57
backend/internal/service/ops_summary_service.go
Normal file
57
backend/internal/service/ops_summary_service.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type OpsOverviewSummary struct {
|
||||
ManagedAssets int `json:"managedAssets"`
|
||||
Places int `json:"places"`
|
||||
MapAssets int `json:"mapAssets"`
|
||||
TileReleases int `json:"tileReleases"`
|
||||
CourseSets int `json:"courseSets"`
|
||||
CourseVariants int `json:"courseVariants"`
|
||||
Events int `json:"events"`
|
||||
DefaultEvents int `json:"defaultEvents"`
|
||||
PublishedEvents int `json:"publishedEvents"`
|
||||
ConfigSources int `json:"configSources"`
|
||||
Releases int `json:"releases"`
|
||||
RuntimeBindings int `json:"runtimeBindings"`
|
||||
Presentations int `json:"presentations"`
|
||||
ContentBundles int `json:"contentBundles"`
|
||||
OpsUsers int `json:"opsUsers"`
|
||||
}
|
||||
|
||||
type OpsSummaryService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
func NewOpsSummaryService(store *postgres.Store) *OpsSummaryService {
|
||||
return &OpsSummaryService{store: store}
|
||||
}
|
||||
|
||||
func (s *OpsSummaryService) GetOverview(ctx context.Context) (*OpsOverviewSummary, error) {
|
||||
counts, err := s.store.GetOpsOverviewCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &OpsOverviewSummary{
|
||||
ManagedAssets: counts.ManagedAssets,
|
||||
Places: counts.Places,
|
||||
MapAssets: counts.MapAssets,
|
||||
TileReleases: counts.TileReleases,
|
||||
CourseSets: counts.CourseSets,
|
||||
CourseVariants: counts.CourseVariants,
|
||||
Events: counts.Events,
|
||||
DefaultEvents: counts.DefaultEvents,
|
||||
PublishedEvents: counts.PublishedEvents,
|
||||
ConfigSources: counts.ConfigSources,
|
||||
Releases: counts.Releases,
|
||||
RuntimeBindings: counts.RuntimeBindings,
|
||||
Presentations: counts.Presentations,
|
||||
ContentBundles: counts.ContentBundles,
|
||||
OpsUsers: counts.OpsUsers,
|
||||
}, nil
|
||||
}
|
||||
222
backend/internal/service/preview_contract.go
Normal file
222
backend/internal/service/preview_contract.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
type MapPreviewView struct {
|
||||
Mode string `json:"mode"`
|
||||
BaseTiles *PreviewBaseTiles `json:"baseTiles,omitempty"`
|
||||
Viewport *PreviewViewport `json:"viewport,omitempty"`
|
||||
Variants []PreviewVariantView `json:"variants,omitempty"`
|
||||
SelectedVariantID *string `json:"selectedVariantId,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewBaseTiles struct {
|
||||
TileBaseURL string `json:"tileBaseUrl"`
|
||||
Zoom *int `json:"zoom,omitempty"`
|
||||
TileSize *int `json:"tileSize,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewViewport struct {
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
MinLon *float64 `json:"minLon,omitempty"`
|
||||
MinLat *float64 `json:"minLat,omitempty"`
|
||||
MaxLon *float64 `json:"maxLon,omitempty"`
|
||||
MaxLat *float64 `json:"maxLat,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewVariantView struct {
|
||||
VariantID string `json:"variantId"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
Controls []PreviewControlView `json:"controls,omitempty"`
|
||||
Legs []PreviewLegView `json:"legs,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewControlView struct {
|
||||
ID string `json:"id"`
|
||||
Kind *string `json:"kind,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewLegView struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
func buildPreviewFromPayload(payloadJSON *string) (*MapPreviewView, error) {
|
||||
if payloadJSON == nil || strings.TrimSpace(*payloadJSON) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
payload, err := decodeJSONObject(*payloadJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawPreview, _ := payload["preview"].(map[string]any)
|
||||
if len(rawPreview) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
view := &MapPreviewView{}
|
||||
if mode := readStringField(rawPreview, "mode"); mode != nil {
|
||||
view.Mode = *mode
|
||||
}
|
||||
if view.Mode == "" {
|
||||
view.Mode = "readonly"
|
||||
}
|
||||
|
||||
if rawBaseTiles, ok := rawPreview["baseTiles"].(map[string]any); ok && len(rawBaseTiles) > 0 {
|
||||
baseTiles := &PreviewBaseTiles{}
|
||||
if tileBaseURL := readStringField(rawBaseTiles, "tileBaseUrl"); tileBaseURL != nil {
|
||||
baseTiles.TileBaseURL = *tileBaseURL
|
||||
}
|
||||
baseTiles.Zoom = readIntField(rawBaseTiles, "zoom")
|
||||
baseTiles.TileSize = readIntField(rawBaseTiles, "tileSize")
|
||||
if strings.TrimSpace(baseTiles.TileBaseURL) != "" {
|
||||
view.BaseTiles = baseTiles
|
||||
}
|
||||
}
|
||||
|
||||
if rawViewport, ok := rawPreview["viewport"].(map[string]any); ok && len(rawViewport) > 0 {
|
||||
viewport := &PreviewViewport{
|
||||
Width: readIntField(rawViewport, "width"),
|
||||
Height: readIntField(rawViewport, "height"),
|
||||
MinLon: readFloatField(rawViewport, "minLon"),
|
||||
MinLat: readFloatField(rawViewport, "minLat"),
|
||||
MaxLon: readFloatField(rawViewport, "maxLon"),
|
||||
MaxLat: readFloatField(rawViewport, "maxLat"),
|
||||
}
|
||||
view.Viewport = viewport
|
||||
}
|
||||
|
||||
if selectedVariantID := readStringField(rawPreview, "selectedVariantId"); selectedVariantID != nil {
|
||||
view.SelectedVariantID = selectedVariantID
|
||||
}
|
||||
|
||||
rawVariants, _ := rawPreview["variants"].([]any)
|
||||
if len(rawVariants) > 0 {
|
||||
view.Variants = make([]PreviewVariantView, 0, len(rawVariants))
|
||||
for _, raw := range rawVariants {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
variantID := readStringField(item, "variantId")
|
||||
if variantID == nil || strings.TrimSpace(*variantID) == "" {
|
||||
variantID = readStringField(item, "id")
|
||||
}
|
||||
if variantID == nil || strings.TrimSpace(*variantID) == "" {
|
||||
continue
|
||||
}
|
||||
variant := PreviewVariantView{
|
||||
VariantID: *variantID,
|
||||
Name: readStringField(item, "name"),
|
||||
RouteCode: readStringField(item, "routeCode"),
|
||||
}
|
||||
rawControls, _ := item["controls"].([]any)
|
||||
if len(rawControls) > 0 {
|
||||
variant.Controls = make([]PreviewControlView, 0, len(rawControls))
|
||||
for _, rawControl := range rawControls {
|
||||
controlMap, ok := rawControl.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
controlID := readStringField(controlMap, "id")
|
||||
if controlID == nil || strings.TrimSpace(*controlID) == "" {
|
||||
continue
|
||||
}
|
||||
variant.Controls = append(variant.Controls, PreviewControlView{
|
||||
ID: *controlID,
|
||||
Kind: readStringField(controlMap, "kind"),
|
||||
Lon: readFloatField(controlMap, "lon"),
|
||||
Lat: readFloatField(controlMap, "lat"),
|
||||
Label: readStringField(controlMap, "label"),
|
||||
})
|
||||
}
|
||||
}
|
||||
rawLegs, _ := item["legs"].([]any)
|
||||
if len(rawLegs) > 0 {
|
||||
variant.Legs = make([]PreviewLegView, 0, len(rawLegs))
|
||||
for _, rawLeg := range rawLegs {
|
||||
legMap, ok := rawLeg.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := readStringField(legMap, "from")
|
||||
to := readStringField(legMap, "to")
|
||||
if from == nil || to == nil || strings.TrimSpace(*from) == "" || strings.TrimSpace(*to) == "" {
|
||||
continue
|
||||
}
|
||||
variant.Legs = append(variant.Legs, PreviewLegView{
|
||||
From: *from,
|
||||
To: *to,
|
||||
})
|
||||
}
|
||||
}
|
||||
view.Variants = append(view.Variants, variant)
|
||||
}
|
||||
}
|
||||
|
||||
if view.BaseTiles == nil && view.Viewport == nil && len(view.Variants) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func readIntField(object map[string]any, key string) *int {
|
||||
if object == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := object[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
result := v
|
||||
return &result
|
||||
case int32:
|
||||
result := int(v)
|
||||
return &result
|
||||
case int64:
|
||||
result := int(v)
|
||||
return &result
|
||||
case float64:
|
||||
result := int(v)
|
||||
return &result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readFloatField(object map[string]any, key string) *float64 {
|
||||
if object == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := object[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
result := v
|
||||
return &result
|
||||
case float32:
|
||||
result := float64(v)
|
||||
return &result
|
||||
case int:
|
||||
result := float64(v)
|
||||
return &result
|
||||
case int32:
|
||||
result := float64(v)
|
||||
return &result
|
||||
case int64:
|
||||
result := float64(v)
|
||||
return &result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
303
backend/internal/service/public_experience_service.go
Normal file
303
backend/internal/service/public_experience_service.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
const (
|
||||
GuestLaunchSource = "public-default-experience"
|
||||
GuestIdentityProvider = "guest_device"
|
||||
GuestIdentityType = "guest"
|
||||
)
|
||||
|
||||
type PublicExperienceService struct {
|
||||
store *postgres.Store
|
||||
mapService *MapExperienceService
|
||||
eventService *EventService
|
||||
}
|
||||
|
||||
type PublicEventPlayInput struct {
|
||||
EventPublicID string
|
||||
}
|
||||
|
||||
type PublicLaunchEventInput struct {
|
||||
EventPublicID string `json:"-"`
|
||||
ReleaseID string `json:"releaseId,omitempty"`
|
||||
VariantID string `json:"variantId,omitempty"`
|
||||
ClientType string `json:"clientType"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
|
||||
func NewPublicExperienceService(store *postgres.Store, mapService *MapExperienceService, eventService *EventService) *PublicExperienceService {
|
||||
return &PublicExperienceService{
|
||||
store: store,
|
||||
mapService: mapService,
|
||||
eventService: eventService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
|
||||
return s.mapService.ListMaps(ctx, input)
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
|
||||
return s.mapService.GetMapDetail(ctx, mapPublicID)
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
|
||||
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePublicExperienceEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.eventService.GetEventDetail(ctx, eventPublicID)
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) GetEventPlay(ctx context.Context, input PublicEventPlayInput) (*EventPlayResult, error) {
|
||||
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
|
||||
if input.EventPublicID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
|
||||
}
|
||||
|
||||
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePublicExperienceEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &EventPlayResult{}
|
||||
result.Event.ID = event.PublicID
|
||||
result.Event.Slug = event.Slug
|
||||
result.Event.DisplayName = event.DisplayName
|
||||
result.Event.Summary = event.Summary
|
||||
result.Event.Status = event.Status
|
||||
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
|
||||
result.Play.AssignmentMode = variantPlan.AssignmentMode
|
||||
if len(variantPlan.CourseVariants) > 0 {
|
||||
result.Play.CourseVariants = variantPlan.CourseVariants
|
||||
}
|
||||
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
|
||||
result.Release = &struct {
|
||||
ID string `json:"id"`
|
||||
ConfigLabel string `json:"configLabel"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
}{
|
||||
ID: *event.CurrentReleasePubID,
|
||||
ConfigLabel: *event.ConfigLabel,
|
||||
ManifestURL: *event.ManifestURL,
|
||||
ManifestChecksumSha256: event.ManifestChecksum,
|
||||
RouteCode: event.RouteCode,
|
||||
}
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
|
||||
result.Runtime = buildRuntimeSummaryFromEvent(event)
|
||||
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
result.Preview = preview
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
canLaunch, launchReason := evaluateEventLaunchReadiness(event)
|
||||
result.Play.CanLaunch = canLaunch
|
||||
if canLaunch {
|
||||
result.Play.LaunchSource = GuestLaunchSource
|
||||
result.Play.PrimaryAction = "start"
|
||||
result.Play.Reason = "guest can start default experience"
|
||||
return result, nil
|
||||
}
|
||||
result.Play.PrimaryAction = "unavailable"
|
||||
result.Play.Reason = launchReason
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) LaunchEvent(ctx context.Context, input PublicLaunchEventInput) (*LaunchEventResult, error) {
|
||||
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
|
||||
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
|
||||
input.VariantID = strings.TrimSpace(input.VariantID)
|
||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||
if input.EventPublicID == "" || input.DeviceKey == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id and deviceKey are required")
|
||||
}
|
||||
if err := validateClientType(input.ClientType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePublicExperienceEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
|
||||
return nil, launchReadinessError(reason)
|
||||
}
|
||||
if input.ReleaseID != "" && event.CurrentReleasePubID != nil && input.ReleaseID != *event.CurrentReleasePubID {
|
||||
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
|
||||
}
|
||||
|
||||
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
|
||||
variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routeCode := event.RouteCode
|
||||
var assignmentMode *string
|
||||
var variantID *string
|
||||
var variantName *string
|
||||
if variant != nil {
|
||||
resultMode := variant.AssignmentMode
|
||||
assignmentMode = &resultMode
|
||||
variantID = &variant.ID
|
||||
variantName = &variant.Name
|
||||
if variant.RouteCode != nil {
|
||||
routeCode = variant.RouteCode
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
guestUser, err := s.findOrCreateGuestUser(ctx, tx, input.ClientType, input.DeviceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.TouchUserLogin(ctx, tx, guestUser.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionPublicID, err := security.GeneratePublicID("sess")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionToken, err := security.GenerateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
|
||||
|
||||
session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
|
||||
SessionPublicID: sessionPublicID,
|
||||
UserID: guestUser.ID,
|
||||
EventID: event.ID,
|
||||
EventReleaseID: *event.CurrentReleaseID,
|
||||
DeviceKey: input.DeviceKey,
|
||||
ClientType: input.ClientType,
|
||||
AssignmentMode: assignmentMode,
|
||||
VariantID: variantID,
|
||||
VariantName: variantName,
|
||||
RouteCode: routeCode,
|
||||
SessionTokenHash: security.HashText(sessionToken),
|
||||
SessionTokenExpiresAt: sessionTokenExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &LaunchEventResult{}
|
||||
result.Event.ID = event.PublicID
|
||||
result.Event.DisplayName = event.DisplayName
|
||||
result.Launch.Source = GuestLaunchSource
|
||||
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
|
||||
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
|
||||
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
|
||||
result.Launch.Config.RouteCode = routeCode
|
||||
result.Launch.Business.Source = GuestLaunchSource
|
||||
result.Launch.Business.EventID = event.PublicID
|
||||
result.Launch.Business.SessionID = session.SessionPublicID
|
||||
result.Launch.Business.SessionToken = sessionToken
|
||||
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||
result.Launch.Business.RouteCode = routeCode
|
||||
result.Launch.Business.IsGuest = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) findOrCreateGuestUser(ctx context.Context, tx postgres.Tx, clientType, deviceKey string) (*postgres.User, error) {
|
||||
providerSubject := clientType + ":" + deviceKey
|
||||
user, err := s.store.FindUserByProviderSubject(ctx, tx, GuestIdentityProvider, providerSubject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != nil {
|
||||
return user, nil
|
||||
}
|
||||
userPublicID, err := security.GeneratePublicID("usr")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
|
||||
PublicID: userPublicID,
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
|
||||
UserID: user.ID,
|
||||
IdentityType: GuestIdentityType,
|
||||
Provider: GuestIdentityProvider,
|
||||
ProviderSubj: providerSubject,
|
||||
ProfileJSON: "{}",
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func ensurePublicExperienceEvent(event *postgres.Event) error {
|
||||
if event == nil {
|
||||
return apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
if !event.IsDefaultExperience {
|
||||
return apperr.New(http.StatusForbidden, "event_not_public", "event is not available in guest mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user