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

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

@@ -26,6 +26,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
store := postgres.NewStore(pool)
jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL)
wechatMiniClient := wechatmini.NewClient(cfg.WechatMiniAppID, cfg.WechatMiniSecret, cfg.WechatMiniDevPrefix)
assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
authService := service.NewAuthService(service.AuthSettings{
AppEnv: cfg.AppEnv,
RefreshTTL: cfg.RefreshTTL,
@@ -37,21 +38,32 @@ func New(ctx context.Context, cfg Config) (*App, error) {
}, store, jwtManager)
entryService := service.NewEntryService(store)
entryHomeService := service.NewEntryHomeService(store)
adminAssetService := service.NewAdminAssetService(store, cfg.AssetBaseURL, assetPublisher)
adminResourceService := service.NewAdminResourceService(store)
adminProductionService := service.NewAdminProductionService(store)
adminEventService := service.NewAdminEventService(store)
opsAuthService := service.NewOpsAuthService(service.OpsAuthSettings{
AppEnv: cfg.AppEnv,
RefreshTTL: cfg.RefreshTTL,
SMSCodeTTL: cfg.SMSCodeTTL,
SMSCodeCooldown: cfg.SMSCodeCooldown,
SMSProvider: cfg.SMSProvider,
DevSMSCode: cfg.DevSMSCode,
}, store, jwtManager)
opsSummaryService := service.NewOpsSummaryService(store)
eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(store)
assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
adminPipelineService := service.NewAdminPipelineService(store, configService)
homeService := service.NewHomeService(store)
mapExperienceService := service.NewMapExperienceService(store)
publicExperienceService := service.NewPublicExperienceService(store, mapExperienceService, eventService)
profileService := service.NewProfileService(store)
resultService := service.NewResultService(store)
sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(store)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, opsAuthService, opsSummaryService, entryService, entryHomeService, adminAssetService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, publicExperienceService, configService, homeService, mapExperienceService, profileService, resultService, sessionService, devService, meService)
return &App{
router: router,

View File

@@ -0,0 +1,132 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AdminAssetHandler struct {
service *service.AdminAssetService
}
func NewAdminAssetHandler(service *service.AdminAssetService) *AdminAssetHandler {
return &AdminAssetHandler{service: service}
}
func (h *AdminAssetHandler) ListAssets(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListManagedAssets(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminAssetHandler) GetAsset(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetManagedAsset(r.Context(), r.PathValue("assetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminAssetHandler) RegisterLink(w http.ResponseWriter, r *http.Request) {
var req service.RegisterLinkAssetInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.RegisterExternalLink(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminAssetHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(64 << 20); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_multipart", "invalid multipart form: "+err.Error()))
return
}
file, header, err := r.FormFile("file")
if err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "file_required", "multipart file field 'file' is required"))
return
}
defer file.Close()
tmpFile, err := os.CreateTemp("", "cmr-upload-*"+header.Filename)
if err != nil {
httpx.WriteError(w, err)
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
if err != nil {
tmpFile.Close()
httpx.WriteError(w, err)
return
}
if err := tmpFile.Close(); err != nil {
httpx.WriteError(w, err)
return
}
input := service.UploadAssetFileInput{
AssetType: r.FormValue("assetType"),
AssetCode: r.FormValue("assetCode"),
Version: r.FormValue("version"),
Title: stringPtrOrNil(r.FormValue("title")),
ObjectDir: stringPtrOrNil(r.FormValue("objectDir")),
FileName: header.Filename,
ContentType: header.Header.Get("Content-Type"),
FileSize: written,
Checksum: hex.EncodeToString(hash.Sum(nil)),
TempPath: tmpPath,
Status: r.FormValue("status"),
Metadata: parseMetadataJSON(r.FormValue("metadataJson")),
}
result, err := h.service.UploadAssetFile(r.Context(), input)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"uploadedBytes": written,
"checksumSha256": input.Checksum,
},
})
}
func stringPtrOrNil(value string) *string {
if value == "" {
return nil
}
return &value
}
func parseMetadataJSON(raw string) map[string]any {
if raw == "" {
return nil
}
var payload map[string]any
_ = json.Unmarshal([]byte(raw), &payload)
return payload
}

View File

@@ -25,6 +25,15 @@ func (h *AdminProductionHandler) ListPlaces(w http.ResponseWriter, r *http.Reque
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ListMapAssets(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListMapAssets(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreatePlace(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlaceInput
if err := httpx.DecodeJSON(r, &req); err != nil {
@@ -71,6 +80,20 @@ func (h *AdminProductionHandler) GetMapAsset(w http.ResponseWriter, r *http.Requ
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) UpdateMapAsset(w http.ResponseWriter, r *http.Request) {
var req service.UpdateAdminMapAssetInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.UpdateMapAsset(r.Context(), r.PathValue("mapAssetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateTileRelease(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminTileReleaseInput
if err := httpx.DecodeJSON(r, &req); err != nil {
@@ -177,6 +200,34 @@ func (h *AdminProductionHandler) CreateRuntimeBinding(w http.ResponseWriter, r *
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ImportTileRelease(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminTileReleaseInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.ImportTileRelease(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ImportCourseSetKMLBatch(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminCourseSetBatchInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.ImportCourseSetKMLBatch(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetRuntimeBinding(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRuntimeBinding(r.Context(), r.PathValue("runtimeBindingPublicID"))
if err != nil {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type MapExperienceHandler struct {
service *service.MapExperienceService
}
func NewMapExperienceHandler(service *service.MapExperienceService) *MapExperienceHandler {
return &MapExperienceHandler{service: service}
}
func (h *MapExperienceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListMaps(r.Context(), service.ListExperienceMapsInput{Limit: limit})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *MapExperienceHandler) GetMapDetail(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapAssetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,101 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type OpsAuthHandler struct {
service *service.OpsAuthService
}
func NewOpsAuthHandler(service *service.OpsAuthService) *OpsAuthHandler {
return &OpsAuthHandler{service: service}
}
func (h *OpsAuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) {
var req service.OpsSendSMSCodeInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.service.SendSMSCode(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *OpsAuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req service.OpsRegisterInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.service.Register(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *OpsAuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) {
var req service.OpsLoginSMSInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.service.LoginSMS(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *OpsAuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var req service.OpsRefreshTokenInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.service.Refresh(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *OpsAuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
var req service.OpsLogoutInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
if err := h.service.Logout(r.Context(), req); err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"loggedOut": true}})
}
func (h *OpsAuthHandler) Me(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetOpsAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing ops auth context"))
return
}
result, err := h.service.GetMe(r.Context(), auth.OpsUserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,25 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type OpsSummaryHandler struct {
service *service.OpsSummaryService
}
func NewOpsSummaryHandler(service *service.OpsSummaryService) *OpsSummaryHandler {
return &OpsSummaryHandler{service: service}
}
func (h *OpsSummaryHandler) GetOverview(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetOverview(r.Context())
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type PublicExperienceHandler struct {
service *service.PublicExperienceService
}
func NewPublicExperienceHandler(service *service.PublicExperienceService) *PublicExperienceHandler {
return &PublicExperienceHandler{service: service}
}
func (h *PublicExperienceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListMaps(r.Context(), service.ListExperienceMapsInput{Limit: limit})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *PublicExperienceHandler) GetMapDetail(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapAssetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *PublicExperienceHandler) GetEventDetail(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetEventDetail(r.Context(), r.PathValue("eventPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *PublicExperienceHandler) GetEventPlay(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetEventPlay(r.Context(), service.PublicEventPlayInput{
EventPublicID: r.PathValue("eventPublicID"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *PublicExperienceHandler) Launch(w http.ResponseWriter, r *http.Request) {
var req service.PublicLaunchEventInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
req.EventPublicID = r.PathValue("eventPublicID")
result, err := h.service.LaunchEvent(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,162 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"sync"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
)
type RegionOptionsHandler struct {
client *http.Client
mu sync.Mutex
cache []regionProvince
}
type regionProvince struct {
Code string `json:"code"`
Name string `json:"name"`
Cities []regionCity `json:"cities"`
}
type regionCity struct {
Code string `json:"code"`
Name string `json:"name"`
}
type remoteProvince struct {
Code string `json:"code"`
Name string `json:"name"`
}
type remoteCity struct {
Code string `json:"code"`
Name string `json:"name"`
Province string `json:"province"`
}
func NewRegionOptionsHandler() *RegionOptionsHandler {
return &RegionOptionsHandler{
client: &http.Client{Timeout: 12 * time.Second},
}
}
func (h *RegionOptionsHandler) Get(w http.ResponseWriter, r *http.Request) {
items, err := h.load(r.Context())
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": items})
}
func (h *RegionOptionsHandler) load(ctx context.Context) ([]regionProvince, error) {
h.mu.Lock()
if len(h.cache) > 0 {
cached := h.cache
h.mu.Unlock()
return cached, nil
}
h.mu.Unlock()
// Data source:
// https://github.com/uiwjs/province-city-china
// Using province + city JSON only, then reducing to the province/city structure
// needed by ops workbench location management.
provinces, err := h.fetchProvinces(ctx, "https://unpkg.com/province-city-china/dist/province.json")
if err != nil {
return nil, err
}
cities, err := h.fetchCities(ctx, "https://unpkg.com/province-city-china/dist/city.json")
if err != nil {
return nil, err
}
cityMap := make(map[string][]regionCity)
for _, item := range cities {
if item.Province == "" || item.Code == "" {
continue
}
fullCode := item.Province + item.Code + "00"
cityMap[item.Province] = append(cityMap[item.Province], regionCity{
Code: fullCode,
Name: item.Name,
})
}
for key := range cityMap {
sort.Slice(cityMap[key], func(i, j int) bool { return cityMap[key][i].Code < cityMap[key][j].Code })
}
items := make([]regionProvince, 0, len(provinces))
for _, item := range provinces {
if len(item.Code) < 2 {
continue
}
provinceCode := item.Code[:2]
province := regionProvince{
Code: item.Code,
Name: item.Name,
}
if entries := cityMap[provinceCode]; len(entries) > 0 {
province.Cities = entries
} else {
// 直辖市 / 特殊地区没有单独的地级市列表时,退化成自身即可。
province.Cities = []regionCity{{
Code: item.Code,
Name: item.Name,
}}
}
items = append(items, province)
}
h.mu.Lock()
h.cache = items
h.mu.Unlock()
return items, nil
}
func (h *RegionOptionsHandler) fetchProvinces(ctx context.Context, url string) ([]remoteProvince, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
}
resp, err := h.client.Do(req)
if err != nil {
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("省级数据拉取失败: %d", resp.StatusCode))
}
var items []remoteProvince
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "省级数据格式无效")
}
return items, nil
}
func (h *RegionOptionsHandler) fetchCities(ctx context.Context, url string) ([]remoteCity, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
}
resp, err := h.client.Do(req)
if err != nil {
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("市级数据拉取失败: %d", resp.StatusCode))
}
var items []remoteCity
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "市级数据格式无效")
}
return items, nil
}

View File

@@ -17,6 +17,7 @@ const authKey authContextKey = "auth"
type AuthContext struct {
UserID string
UserPublicID string
RoleCode string
}
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler {
@@ -34,10 +35,15 @@ func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return
}
if claims.ActorType != "" && claims.ActorType != "user" {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return
}
ctx := context.WithValue(r.Context(), authKey, &AuthContext{
UserID: claims.UserID,
UserPublicID: claims.UserPublicID,
RoleCode: claims.RoleCode,
})
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@@ -0,0 +1,77 @@
package middleware
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/platform/jwtx"
)
type opsAuthContextKey string
const opsAuthKey opsAuthContextKey = "ops-auth"
type OpsAuthContext struct {
OpsUserID string
OpsUserPublicID string
RoleCode string
}
func NewOpsAuthMiddleware(jwtManager *jwtx.Manager, appEnv string) func(http.Handler) http.Handler {
devContext := func(r *http.Request) *http.Request {
ctx := context.WithValue(r.Context(), opsAuthKey, &OpsAuthContext{
OpsUserID: "dev-ops-user",
OpsUserPublicID: "ops_dev_console",
RoleCode: "owner",
})
return r.WithContext(ctx)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
if appEnv != "production" {
next.ServeHTTP(w, devContext(r))
return
}
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing bearer token"))
return
}
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
claims, err := jwtManager.ParseAccessToken(token)
if err != nil {
if appEnv != "production" {
next.ServeHTTP(w, devContext(r))
return
}
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return
}
if claims.ActorType != "ops" {
if appEnv != "production" {
next.ServeHTTP(w, devContext(r))
return
}
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid ops access token"))
return
}
ctx := context.WithValue(r.Context(), opsAuthKey, &OpsAuthContext{
OpsUserID: claims.UserID,
OpsUserPublicID: claims.UserPublicID,
RoleCode: claims.RoleCode,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetOpsAuthContext(ctx context.Context) *OpsAuthContext {
auth, _ := ctx.Value(opsAuthKey).(*OpsAuthContext)
return auth
}

View File

@@ -13,16 +13,21 @@ func NewRouter(
appEnv string,
jwtManager *jwtx.Manager,
authService *service.AuthService,
opsAuthService *service.OpsAuthService,
opsSummaryService *service.OpsSummaryService,
entryService *service.EntryService,
entryHomeService *service.EntryHomeService,
adminAssetService *service.AdminAssetService,
adminResourceService *service.AdminResourceService,
adminProductionService *service.AdminProductionService,
adminEventService *service.AdminEventService,
adminPipelineService *service.AdminPipelineService,
eventService *service.EventService,
eventPlayService *service.EventPlayService,
publicExperienceService *service.PublicExperienceService,
configService *service.ConfigService,
homeService *service.HomeService,
mapExperienceService *service.MapExperienceService,
profileService *service.ProfileService,
resultService *service.ResultService,
sessionService *service.SessionService,
@@ -33,27 +38,43 @@ func NewRouter(
healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService)
opsAuthHandler := handlers.NewOpsAuthHandler(opsAuthService)
opsSummaryHandler := handlers.NewOpsSummaryHandler(opsSummaryService)
regionOptionsHandler := handlers.NewRegionOptionsHandler()
entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
adminAssetHandler := handlers.NewAdminAssetHandler(adminAssetService)
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService)
adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
eventHandler := handlers.NewEventHandler(eventService)
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
publicExperienceHandler := handlers.NewPublicExperienceHandler(publicExperienceService)
configHandler := handlers.NewConfigHandler(configService)
homeHandler := handlers.NewHomeHandler(homeService)
mapExperienceHandler := handlers.NewMapExperienceHandler(mapExperienceService)
profileHandler := handlers.NewProfileHandler(profileService)
resultHandler := handlers.NewResultHandler(resultService)
sessionHandler := handlers.NewSessionHandler(sessionService)
devHandler := handlers.NewDevHandler(devService)
opsWorkbenchHandler := handlers.NewOpsWorkbenchHandler()
meHandler := handlers.NewMeHandler(meService)
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
opsAuthMiddleware := middleware.NewOpsAuthMiddleware(jwtManager, appEnv)
mux.HandleFunc("GET /healthz", healthHandler.Get)
mux.HandleFunc("GET /home", homeHandler.GetHome)
mux.HandleFunc("GET /cards", homeHandler.GetCards)
mux.HandleFunc("GET /experience-maps", mapExperienceHandler.ListMaps)
mux.HandleFunc("GET /experience-maps/{mapAssetPublicID}", mapExperienceHandler.GetMapDetail)
mux.HandleFunc("GET /public/experience-maps", publicExperienceHandler.ListMaps)
mux.HandleFunc("GET /public/experience-maps/{mapAssetPublicID}", publicExperienceHandler.GetMapDetail)
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
mux.Handle("GET /admin/assets", authMiddleware(http.HandlerFunc(adminAssetHandler.ListAssets)))
mux.Handle("POST /admin/assets/register-link", authMiddleware(http.HandlerFunc(adminAssetHandler.RegisterLink)))
mux.Handle("POST /admin/assets/upload", authMiddleware(http.HandlerFunc(adminAssetHandler.UploadFile)))
mux.Handle("GET /admin/assets/{assetPublicID}", authMiddleware(http.HandlerFunc(adminAssetHandler.GetAsset)))
mux.Handle("GET /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.ListMaps)))
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap)))
@@ -61,8 +82,10 @@ func NewRouter(
mux.Handle("GET /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
mux.Handle("GET /admin/places/{placePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
mux.Handle("GET /admin/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.ListMapAssets)))
mux.Handle("POST /admin/places/{placePublicID}/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
mux.Handle("GET /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
mux.Handle("PUT /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.UpdateMapAsset)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/tile-releases", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/course-sets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSet)))
mux.Handle("GET /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
@@ -73,6 +96,8 @@ func NewRouter(
mux.Handle("GET /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.ListRuntimeBindings)))
mux.Handle("POST /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateRuntimeBinding)))
mux.Handle("GET /admin/runtime-bindings/{runtimeBindingPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetRuntimeBinding)))
mux.Handle("POST /admin/ops/tile-releases/import", authMiddleware(http.HandlerFunc(adminProductionHandler.ImportTileRelease)))
mux.Handle("POST /admin/ops/course-sets/import-kml-batch", authMiddleware(http.HandlerFunc(adminProductionHandler.ImportCourseSetKMLBatch)))
mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields)))
mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield)))
mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield)))
@@ -104,11 +129,13 @@ func NewRouter(
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
mux.HandleFunc("GET /admin/ops-workbench", opsWorkbenchHandler.Get)
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog)
mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary)
mux.HandleFunc("GET /dev/demo-assets/manifests/{demoKey}", devHandler.DemoGameManifest)
mux.HandleFunc("GET /dev/demo-assets/presentations/{demoKey}", devHandler.DemoPresentationSchema)
mux.HandleFunc("GET /dev/demo-assets/content-manifests/{demoKey}", devHandler.DemoContentManifest)
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
@@ -119,6 +146,9 @@ func NewRouter(
mux.Handle("GET /me/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get)))
mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get)))
mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail)
mux.HandleFunc("GET /public/events/{eventPublicID}", publicExperienceHandler.GetEventDetail)
mux.HandleFunc("GET /public/events/{eventPublicID}/play", publicExperienceHandler.GetEventPlay)
mux.HandleFunc("POST /public/events/{eventPublicID}/launch", publicExperienceHandler.Launch)
mux.Handle("GET /events/{eventPublicID}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get)))
mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources)))
mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch)))
@@ -131,12 +161,50 @@ func NewRouter(
mux.HandleFunc("POST /auth/sms/send", authHandler.SendSMSCode)
mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS)
mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini)
mux.HandleFunc("POST /ops/auth/sms/send", opsAuthHandler.SendSMSCode)
mux.HandleFunc("POST /ops/auth/register", opsAuthHandler.Register)
mux.HandleFunc("POST /ops/auth/login/sms", opsAuthHandler.LoginSMS)
mux.HandleFunc("POST /ops/auth/refresh", opsAuthHandler.Refresh)
mux.HandleFunc("POST /ops/auth/logout", opsAuthHandler.Logout)
mux.Handle("GET /ops/me", opsAuthMiddleware(http.HandlerFunc(opsAuthHandler.Me)))
mux.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile)))
mux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
mux.HandleFunc("POST /auth/logout", authHandler.Logout)
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get)))
mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine)))
mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.ListMine)))
mux.Handle("GET /ops/admin/summary", opsAuthMiddleware(http.HandlerFunc(opsSummaryHandler.GetOverview)))
mux.Handle("GET /ops/admin/region-options", opsAuthMiddleware(http.HandlerFunc(regionOptionsHandler.Get)))
mux.Handle("GET /ops/admin/assets", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.ListAssets)))
mux.Handle("POST /ops/admin/assets/register-link", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.RegisterLink)))
mux.Handle("POST /ops/admin/assets/upload", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.UploadFile)))
mux.Handle("GET /ops/admin/assets/{assetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.GetAsset)))
mux.Handle("GET /ops/admin/places", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /ops/admin/places", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
mux.Handle("GET /ops/admin/places/{placePublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
mux.Handle("GET /ops/admin/map-assets", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListMapAssets)))
mux.Handle("POST /ops/admin/places/{placePublicID}/map-assets", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
mux.Handle("GET /ops/admin/map-assets/{mapAssetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
mux.Handle("PUT /ops/admin/map-assets/{mapAssetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.UpdateMapAsset)))
mux.Handle("POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
mux.Handle("POST /ops/admin/ops/tile-releases/import", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ImportTileRelease)))
mux.Handle("GET /ops/admin/course-sources", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
mux.Handle("GET /ops/admin/course-sources/{sourcePublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSource)))
mux.Handle("GET /ops/admin/course-sets/{courseSetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSet)))
mux.Handle("POST /ops/admin/ops/course-sets/import-kml-batch", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ImportCourseSetKMLBatch)))
mux.Handle("GET /ops/admin/events", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ListEvents)))
mux.Handle("POST /ops/admin/events", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.CreateEvent)))
mux.Handle("GET /ops/admin/events/{eventPublicID}", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
mux.Handle("PUT /ops/admin/events/{eventPublicID}", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent)))
mux.Handle("POST /ops/admin/events/{eventPublicID}/presentations/import", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ImportPresentation)))
mux.Handle("POST /ops/admin/events/{eventPublicID}/content-bundles/import", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ImportContentBundle)))
mux.Handle("POST /ops/admin/events/{eventPublicID}/defaults", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.UpdateEventDefaults)))
mux.Handle("GET /ops/admin/events/{eventPublicID}/pipeline", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline)))
mux.Handle("POST /ops/admin/sources/{sourceID}/build", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
mux.Handle("GET /ops/admin/builds/{buildID}", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild)))
mux.Handle("POST /ops/admin/builds/{buildID}/publish", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
mux.Handle("GET /ops/admin/releases/{releasePublicID}", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetRelease)))
mux.Handle("POST /ops/admin/events/{eventPublicID}/rollback", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
return mux
}

View File

@@ -78,6 +78,38 @@ func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, pay
return nil
}
func (p *OSSUtilPublisher) UploadFile(ctx context.Context, publicURL string, localPath string) error {
if !p.Enabled() {
return fmt.Errorf("asset publisher is not configured")
}
if strings.TrimSpace(localPath) == "" {
return fmt.Errorf("local path is required")
}
objectKey, err := p.objectKeyFromPublicURL(publicURL)
if err != nil {
return err
}
if _, err := os.Stat(p.ossutilPath); err != nil {
return fmt.Errorf("ossutil not found: %w", err)
}
if _, err := os.Stat(p.configFile); err != nil {
return fmt.Errorf("ossutil config not found: %w", err)
}
if _, err := os.Stat(localPath); err != nil {
return fmt.Errorf("upload file not found: %w", err)
}
target := p.bucketRoot + "/" + objectKey
cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", localPath, target, "--config-file", p.configFile)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("upload object %s failed: %w: %s", objectKey, err, strings.TrimSpace(string(output)))
}
return nil
}
func (p *OSSUtilPublisher) objectKeyFromPublicURL(publicURL string) (string, error) {
publicURL = strings.TrimSpace(publicURL)
if publicURL == "" {

View File

@@ -16,6 +16,8 @@ type Manager struct {
type AccessClaims struct {
UserID string `json:"uid"`
UserPublicID string `json:"upub"`
ActorType string `json:"actorType,omitempty"`
RoleCode string `json:"roleCode,omitempty"`
jwt.RegisteredClaims
}
@@ -28,10 +30,16 @@ func NewManager(issuer, secret string, ttl time.Duration) *Manager {
}
func (m *Manager) IssueAccessToken(userID, userPublicID string) (string, time.Time, error) {
return m.IssueActorAccessToken(userID, userPublicID, "user", "")
}
func (m *Manager) IssueActorAccessToken(userID, userPublicID, actorType, roleCode string) (string, time.Time, error) {
expiresAt := time.Now().UTC().Add(m.ttl)
claims := AccessClaims{
UserID: userID,
UserPublicID: userPublicID,
ActorType: actorType,
RoleCode: roleCode,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: userID,

View 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
}

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,

View File

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

View File

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

View File

@@ -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),

View 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
}

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

View 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
}

View 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
}
}

View 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
}

View File

@@ -0,0 +1,135 @@
package postgres
import (
"context"
"github.com/jackc/pgx/v5"
)
type ManagedAssetRecord struct {
ID string
PublicID string
AssetType string
AssetCode string
Version string
Title *string
SourceMode string
StorageProvider string
ObjectKey *string
PublicURL string
FileName *string
ContentType *string
FileSizeBytes *int64
ChecksumSHA256 *string
Status string
MetadataJSONB map[string]any
}
type CreateManagedAssetParams struct {
PublicID string
AssetType string
AssetCode string
Version string
Title *string
SourceMode string
StorageProvider string
ObjectKey *string
PublicURL string
FileName *string
ContentType *string
FileSizeBytes *int64
ChecksumSHA256 *string
Status string
MetadataJSONB map[string]any
}
func (s *Store) CreateManagedAsset(ctx context.Context, tx pgx.Tx, params CreateManagedAssetParams) (*ManagedAssetRecord, error) {
row := tx.QueryRow(ctx, `
INSERT INTO managed_assets (
asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, $14, COALESCE($15, '{}'::jsonb)
)
RETURNING id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
`,
params.PublicID, params.AssetType, params.AssetCode, params.Version, params.Title, params.SourceMode, params.StorageProvider,
params.ObjectKey, params.PublicURL, params.FileName, params.ContentType, params.FileSizeBytes, params.ChecksumSHA256, params.Status, params.MetadataJSONB,
)
return scanManagedAsset(row)
}
func (s *Store) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetRecord, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
FROM managed_assets
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ManagedAssetRecord
for rows.Next() {
record, err := scanManagedAsset(rows)
if err != nil {
return nil, err
}
items = append(items, *record)
}
return items, rows.Err()
}
func (s *Store) GetManagedAssetByPublicID(ctx context.Context, publicID string) (*ManagedAssetRecord, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
FROM managed_assets
WHERE asset_public_id = $1
`, publicID)
return scanManagedAsset(row)
}
type managedAssetScanner interface {
Scan(dest ...any) error
}
func scanManagedAsset(scanner managedAssetScanner) (*ManagedAssetRecord, error) {
var record ManagedAssetRecord
err := scanner.Scan(
&record.ID,
&record.PublicID,
&record.AssetType,
&record.AssetCode,
&record.Version,
&record.Title,
&record.SourceMode,
&record.StorageProvider,
&record.ObjectKey,
&record.PublicURL,
&record.FileName,
&record.ContentType,
&record.FileSizeBytes,
&record.ChecksumSHA256,
&record.Status,
&record.MetadataJSONB,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, err
}
if record.MetadataJSONB == nil {
record.MetadataJSONB = map[string]any{}
}
return &record, nil
}

View File

@@ -16,6 +16,7 @@ type Card struct {
DisplaySlot string
DisplayPriority int
IsDefaultExperience bool
ShowInEventList bool
StartsAt *time.Time
EndsAt *time.Time
EntryChannelID *string
@@ -59,6 +60,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
c.display_slot,
c.display_priority,
c.is_default_experience,
COALESCE(e.show_in_event_list, true),
c.starts_at,
c.ends_at,
c.entry_channel_id,
@@ -117,6 +119,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
&card.DisplaySlot,
&card.DisplayPriority,
&card.IsDefaultExperience,
&card.ShowInEventList,
&card.StartsAt,
&card.EndsAt,
&card.EntryChannelID,

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,8 @@ type Event struct {
DisplayName string
Summary *string
Status string
IsDefaultExperience bool
ShowInEventList bool
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
@@ -113,6 +115,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
e.display_name,
e.summary,
e.status,
e.is_default_experience,
e.show_in_event_list,
e.current_release_id,
er.release_public_id,
er.config_label,
@@ -159,6 +163,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
&event.DisplayName,
&event.Summary,
&event.Status,
&event.IsDefaultExperience,
&event.ShowInEventList,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
@@ -202,6 +208,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
e.display_name,
e.summary,
e.status,
e.is_default_experience,
e.show_in_event_list,
e.current_release_id,
er.release_public_id,
er.config_label,
@@ -248,6 +256,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
&event.DisplayName,
&event.Summary,
&event.Status,
&event.IsDefaultExperience,
&event.ShowInEventList,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
@@ -601,3 +611,16 @@ func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releas
}
return nil
}
func (s *Store) SetEventReleaseBindings(ctx context.Context, tx Tx, releaseID string, runtimeBindingID, presentationID, contentBundleID *string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET runtime_binding_id = $2,
presentation_id = $3,
content_bundle_id = $4
WHERE id = $1
`, releaseID, runtimeBindingID, presentationID, contentBundleID); err != nil {
return fmt.Errorf("set event release bindings: %w", err)
}
return nil
}

View File

@@ -0,0 +1,216 @@
package postgres
import (
"context"
"fmt"
)
type MapExperienceRow struct {
PlacePublicID string
PlaceName string
MapAssetPublicID string
MapAssetName string
MapCoverURL *string
MapSummary *string
TileBaseURL *string
TileMetaURL *string
EventPublicID *string
EventDisplayName *string
EventSummary *string
EventStatus *string
EventIsDefaultExperience bool
EventShowInEventList bool
EventReleasePayloadJSON *string
EventPresentationID *string
EventPresentationName *string
EventPresentationType *string
EventPresentationSchema *string
EventContentBundleID *string
EventContentBundleName *string
EventContentEntryURL *string
EventContentAssetRootURL *string
EventContentMetadataJSON *string
}
func (s *Store) ListMapExperienceRows(ctx context.Context, limit int) ([]MapExperienceRow, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
COALESCE(ma.cover_url, p.cover_url) AS cover_url,
COALESCE(ma.description, p.description) AS summary,
tr.tile_base_url,
tr.meta_url,
e.event_public_id,
e.display_name,
e.summary,
e.status,
COALESCE(e.is_default_experience, false),
COALESCE(e.show_in_event_list, true),
er.payload_jsonb::text,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
ep.schema_jsonb::text,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
cb.metadata_jsonb::text
FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN tile_releases tr ON tr.id = ma.current_tile_release_id
LEFT JOIN map_runtime_bindings mrb
ON mrb.map_asset_id = ma.id
AND mrb.status = 'active'
LEFT JOIN event_releases er
ON er.runtime_binding_id = mrb.id
AND er.status = 'published'
LEFT JOIN events e
ON e.current_release_id = er.id
AND e.status = 'active'
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE ma.status = 'active'
AND (e.id IS NULL OR e.show_in_event_list = true)
ORDER BY p.name ASC, ma.name ASC, COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list map experience rows: %w", err)
}
defer rows.Close()
items := make([]MapExperienceRow, 0, limit)
for rows.Next() {
var item MapExperienceRow
if err := rows.Scan(
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.MapCoverURL,
&item.MapSummary,
&item.TileBaseURL,
&item.TileMetaURL,
&item.EventPublicID,
&item.EventDisplayName,
&item.EventSummary,
&item.EventStatus,
&item.EventIsDefaultExperience,
&item.EventShowInEventList,
&item.EventReleasePayloadJSON,
&item.EventPresentationID,
&item.EventPresentationName,
&item.EventPresentationType,
&item.EventPresentationSchema,
&item.EventContentBundleID,
&item.EventContentBundleName,
&item.EventContentEntryURL,
&item.EventContentAssetRootURL,
&item.EventContentMetadataJSON,
); err != nil {
return nil, fmt.Errorf("scan map experience row: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate map experience rows: %w", err)
}
return items, nil
}
func (s *Store) ListMapExperienceRowsByMapPublicID(ctx context.Context, mapAssetPublicID string) ([]MapExperienceRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
COALESCE(ma.cover_url, p.cover_url) AS cover_url,
COALESCE(ma.description, p.description) AS summary,
tr.tile_base_url,
tr.meta_url,
e.event_public_id,
e.display_name,
e.summary,
e.status,
COALESCE(e.is_default_experience, false),
COALESCE(e.show_in_event_list, true),
er.payload_jsonb::text,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
ep.schema_jsonb::text,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
cb.metadata_jsonb::text
FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN tile_releases tr ON tr.id = ma.current_tile_release_id
LEFT JOIN map_runtime_bindings mrb
ON mrb.map_asset_id = ma.id
AND mrb.status = 'active'
LEFT JOIN event_releases er
ON er.runtime_binding_id = mrb.id
AND er.status = 'published'
LEFT JOIN events e
ON e.current_release_id = er.id
AND e.status = 'active'
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE ma.map_asset_public_id = $1
AND ma.status = 'active'
AND (e.id IS NULL OR e.show_in_event_list = true)
ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
`, mapAssetPublicID)
if err != nil {
return nil, fmt.Errorf("list map experience rows by map public id: %w", err)
}
defer rows.Close()
items := make([]MapExperienceRow, 0, 8)
for rows.Next() {
var item MapExperienceRow
if err := rows.Scan(
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.MapCoverURL,
&item.MapSummary,
&item.TileBaseURL,
&item.TileMetaURL,
&item.EventPublicID,
&item.EventDisplayName,
&item.EventSummary,
&item.EventStatus,
&item.EventIsDefaultExperience,
&item.EventShowInEventList,
&item.EventReleasePayloadJSON,
&item.EventPresentationID,
&item.EventPresentationName,
&item.EventPresentationType,
&item.EventPresentationSchema,
&item.EventContentBundleID,
&item.EventContentBundleName,
&item.EventContentEntryURL,
&item.EventContentAssetRootURL,
&item.EventContentMetadataJSON,
); err != nil {
return nil, fmt.Errorf("scan map experience detail row: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate map experience detail rows: %w", err)
}
return items, nil
}

View File

@@ -0,0 +1,217 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type OpsUser struct {
ID string
PublicID string
CountryCode string
Mobile string
DisplayName string
Status string
LastLoginAt *time.Time
}
type OpsRole struct {
ID string
RoleCode string
DisplayName string
RoleRank int
}
type CreateOpsUserParams struct {
PublicID string
CountryCode string
Mobile string
DisplayName string
Status string
}
type CreateOpsRefreshTokenParams struct {
OpsUserID string
DeviceKey string
TokenHash string
ExpiresAt time.Time
}
type OpsRefreshTokenRecord struct {
ID string
OpsUserID string
DeviceKey *string
ExpiresAt time.Time
IsRevoked bool
}
func (s *Store) CountOpsUsers(ctx context.Context) (int, error) {
row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM ops_users WHERE status <> 'deleted'`)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("count ops users: %w", err)
}
return count, nil
}
func (s *Store) GetOpsUserByMobile(ctx context.Context, db queryRower, countryCode, mobile string) (*OpsUser, error) {
row := db.QueryRow(ctx, `
SELECT id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
FROM ops_users
WHERE country_code = $1 AND mobile = $2
LIMIT 1
`, countryCode, mobile)
return scanOpsUser(row)
}
func (s *Store) GetOpsUserByID(ctx context.Context, db queryRower, opsUserID string) (*OpsUser, error) {
row := db.QueryRow(ctx, `
SELECT id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
FROM ops_users
WHERE id = $1
`, opsUserID)
return scanOpsUser(row)
}
func (s *Store) CreateOpsUser(ctx context.Context, tx Tx, params CreateOpsUserParams) (*OpsUser, error) {
row := tx.QueryRow(ctx, `
INSERT INTO ops_users (ops_user_public_id, country_code, mobile, display_name, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
`, params.PublicID, params.CountryCode, params.Mobile, params.DisplayName, params.Status)
return scanOpsUser(row)
}
func (s *Store) TouchOpsUserLogin(ctx context.Context, tx Tx, opsUserID string) error {
_, err := tx.Exec(ctx, `
UPDATE ops_users
SET last_login_at = NOW()
WHERE id = $1
`, opsUserID)
if err != nil {
return fmt.Errorf("touch ops user last login: %w", err)
}
return nil
}
func (s *Store) GetOpsRoleByCode(ctx context.Context, tx Tx, roleCode string) (*OpsRole, error) {
row := tx.QueryRow(ctx, `
SELECT id, role_code, display_name, role_rank
FROM ops_roles
WHERE role_code = $1
AND status = 'active'
LIMIT 1
`, roleCode)
return scanOpsRole(row)
}
func (s *Store) AssignOpsRole(ctx context.Context, tx Tx, opsUserID, roleID string) error {
_, err := tx.Exec(ctx, `
INSERT INTO ops_user_roles (ops_user_id, ops_role_id, status)
VALUES ($1, $2, 'active')
ON CONFLICT (ops_user_id, ops_role_id) DO NOTHING
`, opsUserID, roleID)
if err != nil {
return fmt.Errorf("assign ops role: %w", err)
}
return nil
}
func (s *Store) GetPrimaryOpsRole(ctx context.Context, db queryRower, opsUserID string) (*OpsRole, error) {
row := db.QueryRow(ctx, `
SELECT r.id, r.role_code, r.display_name, r.role_rank
FROM ops_user_roles ur
JOIN ops_roles r ON r.id = ur.ops_role_id
WHERE ur.ops_user_id = $1
AND ur.status = 'active'
AND r.status = 'active'
ORDER BY r.role_rank DESC, r.created_at ASC
LIMIT 1
`, opsUserID)
return scanOpsRole(row)
}
func (s *Store) CreateOpsRefreshToken(ctx context.Context, tx Tx, params CreateOpsRefreshTokenParams) (string, error) {
row := tx.QueryRow(ctx, `
INSERT INTO ops_refresh_tokens (ops_user_id, device_key, token_hash, expires_at)
VALUES ($1, NULLIF($2, ''), $3, $4)
RETURNING id
`, params.OpsUserID, params.DeviceKey, params.TokenHash, params.ExpiresAt)
var id string
if err := row.Scan(&id); err != nil {
return "", fmt.Errorf("create ops refresh token: %w", err)
}
return id, nil
}
func (s *Store) GetOpsRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*OpsRefreshTokenRecord, error) {
row := tx.QueryRow(ctx, `
SELECT id, ops_user_id, device_key, expires_at, revoked_at IS NOT NULL
FROM ops_refresh_tokens
WHERE token_hash = $1
FOR UPDATE
`, tokenHash)
var record OpsRefreshTokenRecord
err := row.Scan(&record.ID, &record.OpsUserID, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query ops refresh token for update: %w", err)
}
return &record, nil
}
func (s *Store) RotateOpsRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
_, err := tx.Exec(ctx, `
UPDATE ops_refresh_tokens
SET revoked_at = NOW(), replaced_by_token_id = $2
WHERE id = $1
`, oldTokenID, newTokenID)
if err != nil {
return fmt.Errorf("rotate ops refresh token: %w", err)
}
return nil
}
func (s *Store) RevokeOpsRefreshToken(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `
UPDATE ops_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE token_hash = $1
`, tokenHash)
if err != nil {
return fmt.Errorf("revoke ops refresh token: %w", err)
}
return nil
}
func scanOpsUser(row pgx.Row) (*OpsUser, error) {
var item OpsUser
err := row.Scan(&item.ID, &item.PublicID, &item.CountryCode, &item.Mobile, &item.DisplayName, &item.Status, &item.LastLoginAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan ops user: %w", err)
}
return &item, nil
}
func scanOpsRole(row pgx.Row) (*OpsRole, error) {
var item OpsRole
err := row.Scan(&item.ID, &item.RoleCode, &item.DisplayName, &item.RoleRank)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan ops role: %w", err)
}
return &item, nil
}

View File

@@ -0,0 +1,66 @@
package postgres
import (
"context"
"fmt"
)
type OpsOverviewCounts struct {
ManagedAssets int
Places int
MapAssets int
TileReleases int
CourseSets int
CourseVariants int
Events int
DefaultEvents int
PublishedEvents int
ConfigSources int
Releases int
RuntimeBindings int
Presentations int
ContentBundles int
OpsUsers int
}
func (s *Store) GetOpsOverviewCounts(ctx context.Context) (*OpsOverviewCounts, error) {
row := s.pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM managed_assets WHERE status <> 'archived') AS managed_assets,
(SELECT COUNT(*) FROM places WHERE status <> 'archived') AS places,
(SELECT COUNT(*) FROM map_assets WHERE status <> 'archived') AS map_assets,
(SELECT COUNT(*) FROM tile_releases WHERE status <> 'archived') AS tile_releases,
(SELECT COUNT(*) FROM course_sets WHERE status <> 'archived') AS course_sets,
(SELECT COUNT(*) FROM course_variants WHERE status <> 'archived') AS course_variants,
(SELECT COUNT(*) FROM events WHERE status <> 'archived') AS events,
(SELECT COUNT(*) FROM events WHERE status <> 'archived' AND COALESCE(is_default_experience, false)) AS default_events,
(SELECT COUNT(*) FROM events WHERE status <> 'archived' AND current_release_id IS NOT NULL) AS published_events,
(SELECT COUNT(*) FROM event_config_sources) AS config_sources,
(SELECT COUNT(*) FROM event_releases) AS releases,
(SELECT COUNT(*) FROM map_runtime_bindings WHERE status <> 'archived') AS runtime_bindings,
(SELECT COUNT(*) FROM event_presentations WHERE status <> 'archived') AS presentations,
(SELECT COUNT(*) FROM content_bundles WHERE status <> 'archived') AS content_bundles,
(SELECT COUNT(*) FROM ops_users WHERE status <> 'deleted') AS ops_users
`)
var item OpsOverviewCounts
if err := row.Scan(
&item.ManagedAssets,
&item.Places,
&item.MapAssets,
&item.TileReleases,
&item.CourseSets,
&item.CourseVariants,
&item.Events,
&item.DefaultEvents,
&item.PublishedEvents,
&item.ConfigSources,
&item.Releases,
&item.RuntimeBindings,
&item.Presentations,
&item.ContentBundles,
&item.OpsUsers,
); err != nil {
return nil, fmt.Errorf("get ops overview counts: %w", err)
}
return &item, nil
}

View File

@@ -28,6 +28,8 @@ type MapAsset struct {
ID string
PublicID string
PlaceID string
PlacePublicID *string
PlaceName *string
LegacyMapID *string
LegacyMapPublicID *string
Code string
@@ -152,6 +154,16 @@ type CreateMapAssetParams struct {
Status string
}
type UpdateMapAssetParams struct {
MapAssetID string
Code string
Name string
MapType string
CoverURL *string
Description *string
Status string
}
type CreateTileReleaseParams struct {
PublicID string
MapAssetID string
@@ -215,6 +227,53 @@ type CreateMapRuntimeBindingParams struct {
Notes *string
}
type MapAssetLinkedEvent struct {
EventPublicID string
DisplayName string
Summary *string
Status string
IsDefaultExperience bool
ShowInEventList bool
CurrentReleasePublicID *string
ConfigLabel *string
RouteCode *string
CurrentPresentationID *string
CurrentPresentationName *string
CurrentContentBundleID *string
CurrentContentBundleName *string
}
func (s *Store) ListMapAssets(ctx context.Context, limit int) ([]MapAsset, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
ORDER BY ma.created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list all map assets: %w", err)
}
defer rows.Close()
items := []MapAsset{}
for rows.Next() {
item, err := scanMapAssetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate all map assets: %w", err)
}
return items, nil
}
func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
if limit <= 0 || limit > 200 {
limit = 50
@@ -253,6 +312,16 @@ func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place
return scanPlace(row)
}
func (s *Store) GetPlaceByCode(ctx context.Context, code string) (*Place, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
FROM places
WHERE code = $1
LIMIT 1
`, code)
return scanPlace(row)
}
func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
centerPointJSON, err := marshalJSONMap(params.CenterPoint)
if err != nil {
@@ -268,9 +337,10 @@ func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams
func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]MapAsset, error) {
rows, err := s.pool.Query(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.place_id = $1
ORDER BY ma.created_at DESC
@@ -295,9 +365,10 @@ func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]M
func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*MapAsset, error) {
row := s.pool.QueryRow(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.map_asset_public_id = $1
LIMIT 1
@@ -305,15 +376,44 @@ func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*Ma
return scanMapAsset(row)
}
func (s *Store) GetMapAssetByCode(ctx context.Context, code string) (*MapAsset, error) {
row := s.pool.QueryRow(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.code = $1
LIMIT 1
`, code)
return scanMapAsset(row)
}
func (s *Store) CreateMapAsset(ctx context.Context, tx Tx, params CreateMapAssetParams) (*MapAsset, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_assets (map_asset_public_id, place_id, legacy_map_id, code, name, map_type, cover_url, description, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, map_asset_public_id, place_id, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
`, params.PublicID, params.PlaceID, params.LegacyMapID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
return scanMapAsset(row)
}
func (s *Store) UpdateMapAsset(ctx context.Context, tx Tx, params UpdateMapAssetParams) (*MapAsset, error) {
row := tx.QueryRow(ctx, `
UPDATE map_assets
SET code = $2,
name = $3,
map_type = $4,
cover_url = $5,
description = $6,
status = $7,
updated_at = NOW()
WHERE id = $1
RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
`, params.MapAssetID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
return scanMapAsset(row)
}
func (s *Store) ListTileReleasesByMapAssetID(ctx context.Context, mapAssetID string) ([]TileRelease, error) {
rows, err := s.pool.Query(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
@@ -355,6 +455,19 @@ func (s *Store) GetTileReleaseByPublicID(ctx context.Context, publicID string) (
return scanTileRelease(row)
}
func (s *Store) GetTileReleaseByMapAssetIDAndVersionCode(ctx context.Context, mapAssetID, versionCode string) (*TileRelease, error) {
row := s.pool.QueryRow(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
FROM tile_releases tr
LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
WHERE tr.map_asset_id = $1 AND tr.version_code = $2
LIMIT 1
`, mapAssetID, versionCode)
return scanTileRelease(row)
}
func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
@@ -506,6 +619,16 @@ func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*C
return scanCourseSet(row)
}
func (s *Store) GetCourseSetByCode(ctx context.Context, code string) (*CourseSet, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
WHERE code = $1
LIMIT 1
`, code)
return scanCourseSet(row)
}
func (s *Store) CreateCourseSet(ctx context.Context, tx Tx, params CreateCourseSetParams) (*CourseSet, error) {
row := tx.QueryRow(ctx, `
INSERT INTO course_sets (course_set_public_id, place_id, map_asset_id, code, mode, name, description, status)
@@ -556,6 +679,19 @@ func (s *Store) GetCourseVariantByPublicID(ctx context.Context, publicID string)
return scanCourseVariant(row)
}
func (s *Store) GetCourseVariantByCourseSetIDAndRouteCode(ctx context.Context, courseSetID, routeCode string) (*CourseVariant, error) {
row := s.pool.QueryRow(ctx, `
SELECT cv.id, cv.course_variant_public_id, cv.course_set_id, cv.source_id, cs.course_source_public_id, cv.name, cv.route_code,
cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
FROM course_variants cv
LEFT JOIN course_sources cs ON cs.id = cv.source_id
WHERE cv.course_set_id = $1 AND cv.route_code = $2
LIMIT 1
`, courseSetID, routeCode)
return scanCourseVariant(row)
}
func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
if err != nil {
@@ -641,6 +777,66 @@ func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID str
return scanMapRuntimeBinding(row)
}
func (s *Store) ListMapAssetLinkedEvents(ctx context.Context, mapAssetID string, limit int) ([]MapAssetLinkedEvent, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT
e.event_public_id,
e.display_name,
e.summary,
e.status,
COALESCE(e.is_default_experience, false),
COALESCE(e.show_in_event_list, true),
er.release_public_id,
er.config_label,
er.route_code,
ep.presentation_public_id,
ep.name,
cb.content_bundle_public_id,
cb.name
FROM events e
JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = e.current_presentation_id
LEFT JOIN content_bundles cb ON cb.id = e.current_content_bundle_id
WHERE mrb.map_asset_id = $1
ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
LIMIT $2
`, mapAssetID, limit)
if err != nil {
return nil, fmt.Errorf("list map asset linked events: %w", err)
}
defer rows.Close()
items := []MapAssetLinkedEvent{}
for rows.Next() {
var item MapAssetLinkedEvent
if err := rows.Scan(
&item.EventPublicID,
&item.DisplayName,
&item.Summary,
&item.Status,
&item.IsDefaultExperience,
&item.ShowInEventList,
&item.CurrentReleasePublicID,
&item.ConfigLabel,
&item.RouteCode,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
); err != nil {
return nil, fmt.Errorf("scan map asset linked event: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate map asset linked events: %w", err)
}
return items, nil
}
func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings (
@@ -681,7 +877,7 @@ func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
func scanMapAsset(row pgx.Row) (*MapAsset, error) {
var item MapAsset
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -693,7 +889,7 @@ func scanMapAsset(row pgx.Row) (*MapAsset, error) {
func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) {
var item MapAsset
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan map asset row: %w", err)
}