同步前后端联调与文档更新

This commit is contained in:
2026-04-02 09:25:05 +08:00
parent af43beadb0
commit 6964e26ec9
113 changed files with 4317 additions and 293 deletions

View File

@@ -0,0 +1,490 @@
package service
import (
"context"
"fmt"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminEventService struct {
store *postgres.Store
}
type AdminEventSummary struct {
ID string `json:"id"`
TenantCode *string `json:"tenantCode,omitempty"`
TenantName *string `json:"tenantName,omitempty"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
CurrentRelease *AdminEventReleaseRef `json:"currentRelease,omitempty"`
}
type AdminEventReleaseRef struct {
ID string `json:"id"`
ConfigLabel *string `json:"configLabel,omitempty"`
ManifestURL *string `json:"manifestUrl,omitempty"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}
type AdminEventDetail struct {
Event AdminEventSummary `json:"event"`
LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
SourceCount int `json:"sourceCount"`
CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
}
type CreateAdminEventInput struct {
TenantCode *string `json:"tenantCode,omitempty"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
}
type UpdateAdminEventInput struct {
TenantCode *string `json:"tenantCode,omitempty"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
}
type SaveAdminEventSourceInput struct {
Map struct {
MapID string `json:"mapId"`
VersionID string `json:"versionId"`
} `json:"map"`
Playfield struct {
PlayfieldID string `json:"playfieldId"`
VersionID string `json:"versionId"`
} `json:"playfield"`
ResourcePack *struct {
ResourcePackID string `json:"resourcePackId"`
VersionID string `json:"versionId"`
} `json:"resourcePack,omitempty"`
GameModeCode string `json:"gameModeCode"`
RouteCode *string `json:"routeCode,omitempty"`
Overrides map[string]any `json:"overrides,omitempty"`
Notes *string `json:"notes,omitempty"`
}
type AdminAssembledSource struct {
Refs map[string]any `json:"refs"`
Runtime map[string]any `json:"runtime"`
Overrides map[string]any `json:"overrides,omitempty"`
}
func NewAdminEventService(store *postgres.Store) *AdminEventService {
return &AdminEventService{store: store}
}
func (s *AdminEventService) ListEvents(ctx context.Context, limit int) ([]AdminEventSummary, error) {
items, err := s.store.ListAdminEvents(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminEventSummary, 0, len(items))
for _, item := range items {
results = append(results, buildAdminEventSummary(item))
}
return results, nil
}
func (s *AdminEventService) CreateEvent(ctx context.Context, input CreateAdminEventInput) (*AdminEventSummary, error) {
input.Slug = strings.TrimSpace(input.Slug)
input.DisplayName = strings.TrimSpace(input.DisplayName)
if input.Slug == "" || input.DisplayName == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
}
var tenantID *string
var tenantCode *string
var tenantName *string
if input.TenantCode != nil && strings.TrimSpace(*input.TenantCode) != "" {
tenant, err := s.store.GetTenantByCode(ctx, strings.TrimSpace(*input.TenantCode))
if err != nil {
return nil, err
}
if tenant == nil {
return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
}
tenantID = &tenant.ID
tenantCode = &tenant.TenantCode
tenantName = &tenant.Name
}
publicID, err := security.GeneratePublicID("evt")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateAdminEvent(ctx, tx, postgres.CreateAdminEventParams{
PublicID: publicID,
TenantID: tenantID,
Slug: input.Slug,
DisplayName: input.DisplayName,
Summary: trimStringPtr(input.Summary),
Status: normalizeEventCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminEventSummary{
ID: item.PublicID,
TenantCode: tenantCode,
TenantName: tenantName,
Slug: item.Slug,
DisplayName: item.DisplayName,
Summary: item.Summary,
Status: item.Status,
}, nil
}
func (s *AdminEventService) UpdateEvent(ctx context.Context, eventPublicID string, input UpdateAdminEventInput) (*AdminEventSummary, error) {
record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.Slug = strings.TrimSpace(input.Slug)
input.DisplayName = strings.TrimSpace(input.DisplayName)
if input.Slug == "" || input.DisplayName == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
}
var tenantID *string
clearTenant := false
if input.TenantCode != nil {
if trimmed := strings.TrimSpace(*input.TenantCode); trimmed != "" {
tenant, err := s.store.GetTenantByCode(ctx, trimmed)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
}
tenantID = &tenant.ID
} else {
clearTenant = true
}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
updated, err := s.store.UpdateAdminEvent(ctx, tx, postgres.UpdateAdminEventParams{
EventID: record.ID,
TenantID: tenantID,
Slug: input.Slug,
DisplayName: input.DisplayName,
Summary: trimStringPtr(input.Summary),
Status: normalizeEventCatalogStatus(input.Status),
ClearTenant: clearTenant,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
refreshed, err := s.store.GetAdminEventByPublicID(ctx, updated.PublicID)
if err != nil {
return nil, err
}
if refreshed == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
summary := buildAdminEventSummary(*refreshed)
return &summary, nil
}
func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID string) (*AdminEventDetail, error) {
record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
sources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 1)
if err != nil {
return nil, err
}
allSources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 200)
if err != nil {
return nil, err
}
result := &AdminEventDetail{
Event: buildAdminEventSummary(*record),
SourceCount: len(allSources),
}
if len(sources) > 0 {
latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
if err != nil {
return nil, err
}
result.LatestSource = latest
result.CurrentSource = buildAdminAssembledSource(latest.Source)
}
return result, nil
}
func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.GameModeCode = strings.TrimSpace(input.GameModeCode)
input.Map.MapID = strings.TrimSpace(input.Map.MapID)
input.Map.VersionID = strings.TrimSpace(input.Map.VersionID)
input.Playfield.PlayfieldID = strings.TrimSpace(input.Playfield.PlayfieldID)
input.Playfield.VersionID = strings.TrimSpace(input.Playfield.VersionID)
if input.Map.MapID == "" || input.Map.VersionID == "" || input.Playfield.PlayfieldID == "" || input.Playfield.VersionID == "" || input.GameModeCode == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "map, playfield and gameModeCode are required")
}
mapVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, input.Map.MapID, input.Map.VersionID)
if err != nil {
return nil, err
}
if mapVersion == nil {
return nil, apperr.New(http.StatusNotFound, "map_version_not_found", "map version not found")
}
playfieldVersion, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, input.Playfield.PlayfieldID, input.Playfield.VersionID)
if err != nil {
return nil, err
}
if playfieldVersion == nil {
return nil, apperr.New(http.StatusNotFound, "playfield_version_not_found", "playfield version not found")
}
var resourcePackVersion *postgres.ResourcePackVersion
if input.ResourcePack != nil {
input.ResourcePack.ResourcePackID = strings.TrimSpace(input.ResourcePack.ResourcePackID)
input.ResourcePack.VersionID = strings.TrimSpace(input.ResourcePack.VersionID)
if input.ResourcePack.ResourcePackID == "" || input.ResourcePack.VersionID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "resourcePackId and versionId are required when resourcePack is provided")
}
resourcePackVersion, err = s.store.GetResourcePackVersionByPublicID(ctx, input.ResourcePack.ResourcePackID, input.ResourcePack.VersionID)
if err != nil {
return nil, err
}
if resourcePackVersion == nil {
return nil, apperr.New(http.StatusNotFound, "resource_pack_version_not_found", "resource pack version not found")
}
}
source := s.buildEventSource(eventRecord, mapVersion, playfieldVersion, resourcePackVersion, input)
if err := validateSourceConfig(source); err != nil {
return nil, err
}
nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, eventRecord.ID)
if err != nil {
return nil, err
}
note := trimStringPtr(input.Notes)
if note == nil {
defaultNote := fmt.Sprintf("assembled from admin refs: map=%s/%s playfield=%s/%s", input.Map.MapID, input.Map.VersionID, input.Playfield.PlayfieldID, input.Playfield.VersionID)
note = &defaultNote
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
EventID: eventRecord.ID,
SourceVersionNo: nextVersion,
SourceKind: "admin_assembled_bundle",
SchemaID: "event-source",
SchemaVersion: resolveSchemaVersion(source),
Status: "active",
Source: source,
Notes: note,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildEventConfigSourceView(record, eventRecord.PublicID)
}
func (s *AdminEventService) buildEventSource(event *postgres.AdminEventRecord, mapVersion *postgres.ResourceMapVersion, playfieldVersion *postgres.ResourcePlayfieldVersion, resourcePackVersion *postgres.ResourcePackVersion, input SaveAdminEventSourceInput) map[string]any {
source := map[string]any{
"schemaVersion": "1",
"app": map[string]any{
"id": event.PublicID,
"title": event.DisplayName,
},
"refs": map[string]any{
"map": map[string]any{
"id": input.Map.MapID,
"versionId": input.Map.VersionID,
},
"playfield": map[string]any{
"id": input.Playfield.PlayfieldID,
"versionId": input.Playfield.VersionID,
},
"gameMode": map[string]any{
"code": input.GameModeCode,
},
},
"map": map[string]any{
"tiles": mapVersion.TilesRootURL,
"mapmeta": mapVersion.MapmetaURL,
},
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": playfieldVersion.SourceType,
"url": playfieldVersion.SourceURL,
},
},
"game": map[string]any{
"mode": input.GameModeCode,
},
}
if event.Summary != nil && strings.TrimSpace(*event.Summary) != "" {
source["summary"] = *event.Summary
}
if event.TenantCode != nil && strings.TrimSpace(*event.TenantCode) != "" {
source["branding"] = map[string]any{
"tenantCode": *event.TenantCode,
}
}
if input.RouteCode != nil && strings.TrimSpace(*input.RouteCode) != "" {
source["playfield"].(map[string]any)["metadata"] = map[string]any{
"routeCode": strings.TrimSpace(*input.RouteCode),
}
}
if resourcePackVersion != nil {
source["refs"].(map[string]any)["resourcePack"] = map[string]any{
"id": input.ResourcePack.ResourcePackID,
"versionId": input.ResourcePack.VersionID,
}
resources := map[string]any{}
assets := map[string]any{}
if resourcePackVersion.ThemeProfileCode != nil && strings.TrimSpace(*resourcePackVersion.ThemeProfileCode) != "" {
resources["themeProfile"] = *resourcePackVersion.ThemeProfileCode
}
if resourcePackVersion.ContentEntryURL != nil && strings.TrimSpace(*resourcePackVersion.ContentEntryURL) != "" {
assets["contentHtml"] = *resourcePackVersion.ContentEntryURL
}
if resourcePackVersion.AudioRootURL != nil && strings.TrimSpace(*resourcePackVersion.AudioRootURL) != "" {
resources["audioRoot"] = *resourcePackVersion.AudioRootURL
}
if len(resources) > 0 {
source["resources"] = resources
}
if len(assets) > 0 {
source["assets"] = assets
}
}
if len(input.Overrides) > 0 {
source["overrides"] = input.Overrides
mergeJSONObject(source, input.Overrides)
}
return source
}
func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
summary := AdminEventSummary{
ID: item.PublicID,
TenantCode: item.TenantCode,
TenantName: item.TenantName,
Slug: item.Slug,
DisplayName: item.DisplayName,
Summary: item.Summary,
Status: item.Status,
}
if item.CurrentReleasePubID != nil {
summary.CurrentRelease = &AdminEventReleaseRef{
ID: *item.CurrentReleasePubID,
ConfigLabel: item.ConfigLabel,
ManifestURL: item.ManifestURL,
ManifestChecksumSha256: item.ManifestChecksum,
RouteCode: item.RouteCode,
}
}
return summary
}
func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
result := &AdminAssembledSource{}
if refs, ok := source["refs"].(map[string]any); ok {
result.Refs = refs
}
runtime := cloneJSONObject(source)
delete(runtime, "refs")
delete(runtime, "overrides")
if overrides, ok := source["overrides"].(map[string]any); ok && len(overrides) > 0 {
result.Overrides = overrides
}
result.Runtime = runtime
return result
}
func normalizeEventCatalogStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "disabled":
return "disabled"
case "archived":
return "archived"
default:
return "draft"
}
}
func mergeJSONObject(target map[string]any, overrides map[string]any) {
for key, value := range overrides {
if valueMap, ok := value.(map[string]any); ok {
existing, ok := target[key].(map[string]any)
if !ok {
existing = map[string]any{}
target[key] = existing
}
mergeJSONObject(existing, valueMap)
continue
}
target[key] = value
}
}

View File

@@ -0,0 +1,178 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type AdminPipelineService struct {
store *postgres.Store
configService *ConfigService
}
type AdminReleaseView struct {
ID string `json:"id"`
ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"`
PublishedAt string `json:"publishedAt"`
}
type AdminEventPipelineView struct {
EventID string `json:"eventId"`
CurrentRelease *AdminReleaseView `json:"currentRelease,omitempty"`
Sources []EventConfigSourceView `json:"sources"`
Builds []EventConfigBuildView `json:"builds"`
Releases []AdminReleaseView `json:"releases"`
}
type AdminRollbackReleaseInput struct {
ReleaseID string `json:"releaseId"`
}
func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService {
return &AdminPipelineService{
store: store,
configService: configService,
}
}
func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublicID string, limit int) (*AdminEventPipelineView, error) {
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
sources, err := s.configService.ListEventConfigSources(ctx, event.PublicID, limit)
if err != nil {
return nil, err
}
buildRecords, err := s.store.ListEventConfigBuildsByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
releaseRecords, err := s.store.ListEventReleasesByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
builds := make([]EventConfigBuildView, 0, len(buildRecords))
for i := range buildRecords {
item, err := buildEventConfigBuildView(&buildRecords[i])
if err != nil {
return nil, err
}
builds = append(builds, *item)
}
releases := make([]AdminReleaseView, 0, len(releaseRecords))
for _, item := range releaseRecords {
releases = append(releases, buildAdminReleaseView(item))
}
result := &AdminEventPipelineView{
EventID: event.PublicID,
Sources: sources,
Builds: builds,
Releases: releases,
}
if event.CurrentReleasePubID != nil {
result.CurrentRelease = &AdminReleaseView{
ID: *event.CurrentReleasePubID,
ConfigLabel: derefStringOrEmpty(event.ConfigLabel),
ManifestURL: derefStringOrEmpty(event.ManifestURL),
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
Status: "published",
}
}
return result, nil
}
func (s *AdminPipelineService) BuildSource(ctx context.Context, sourceID string) (*EventConfigBuildView, error) {
return s.configService.BuildPreview(ctx, BuildPreviewInput{SourceID: sourceID})
}
func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
return s.configService.GetEventConfigBuild(ctx, buildID)
}
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID})
}
func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) {
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
if input.ReleaseID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "releaseId is required")
}
release, err := s.store.GetEventReleaseByPublicID(ctx, input.ReleaseID)
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
if release.EventID != event.ID {
return nil, apperr.New(http.StatusConflict, "release_not_belong_to_event", "release does not belong to event")
}
if release.Status != "published" {
return nil, apperr.New(http.StatusConflict, "release_not_publishable", "release is not published")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, release.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminReleaseView(*release)
return &view, nil
}
func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView {
return AdminReleaseView{
ID: item.PublicID,
ReleaseNo: item.ReleaseNo,
ConfigLabel: item.ConfigLabel,
ManifestURL: item.ManifestURL,
ManifestChecksumSha256: item.ManifestChecksum,
RouteCode: item.RouteCode,
BuildID: item.BuildID,
Status: item.Status,
PublishedAt: item.PublishedAt.Format(timeRFC3339),
}
}
func derefStringOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -0,0 +1,719 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminResourceService struct {
store *postgres.Store
}
type AdminMapSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
CurrentVersionID *string `json:"currentVersionId,omitempty"`
CurrentVersion *AdminMapVersionBrief `json:"currentVersion,omitempty"`
}
type AdminMapVersionBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminMapVersion struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
MapmetaURL string `json:"mapmetaUrl"`
TilesRootURL string `json:"tilesRootUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminMapDetail struct {
Map AdminMapSummary `json:"map"`
Versions []AdminMapVersion `json:"versions"`
}
type CreateAdminMapInput struct {
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
}
type CreateAdminMapVersionInput struct {
VersionCode string `json:"versionCode"`
Status string `json:"status"`
MapmetaURL string `json:"mapmetaUrl"`
TilesRootURL string `json:"tilesRootUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminPlayfieldSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
CurrentVersionID *string `json:"currentVersionId,omitempty"`
CurrentVersion *AdminPlayfieldVersionBrief `json:"currentVersion,omitempty"`
}
type AdminPlayfieldVersionBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
SourceType string `json:"sourceType"`
}
type AdminPlayfieldVersion struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
SourceType string `json:"sourceType"`
SourceURL string `json:"sourceUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
ControlCount *int `json:"controlCount,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminPlayfieldDetail struct {
Playfield AdminPlayfieldSummary `json:"playfield"`
Versions []AdminPlayfieldVersion `json:"versions"`
}
type CreateAdminPlayfieldInput struct {
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
}
type CreateAdminPlayfieldVersionInput struct {
VersionCode string `json:"versionCode"`
Status string `json:"status"`
SourceType string `json:"sourceType"`
SourceURL string `json:"sourceUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
ControlCount *int `json:"controlCount,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminResourcePackSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
CurrentVersionID *string `json:"currentVersionId,omitempty"`
CurrentVersion *AdminResourcePackVersionBrief `json:"currentVersion,omitempty"`
}
type AdminResourcePackVersionBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminResourcePackVersion struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
ContentEntryURL *string `json:"contentEntryUrl,omitempty"`
AudioRootURL *string `json:"audioRootUrl,omitempty"`
ThemeProfileCode *string `json:"themeProfileCode,omitempty"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminResourcePackDetail struct {
ResourcePack AdminResourcePackSummary `json:"resourcePack"`
Versions []AdminResourcePackVersion `json:"versions"`
}
type CreateAdminResourcePackInput struct {
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
}
type CreateAdminResourcePackVersionInput struct {
VersionCode string `json:"versionCode"`
Status string `json:"status"`
ContentEntryURL *string `json:"contentEntryUrl,omitempty"`
AudioRootURL *string `json:"audioRootUrl,omitempty"`
ThemeProfileCode *string `json:"themeProfileCode,omitempty"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
func NewAdminResourceService(store *postgres.Store) *AdminResourceService {
return &AdminResourceService{store: store}
}
func (s *AdminResourceService) ListMaps(ctx context.Context, limit int) ([]AdminMapSummary, error) {
items, err := s.store.ListResourceMaps(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminMapSummary, 0, len(items))
for _, item := range items {
results = append(results, AdminMapSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
})
}
return results, nil
}
func (s *AdminResourceService) CreateMap(ctx context.Context, input CreateAdminMapInput) (*AdminMapSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
status := normalizeCatalogStatus(input.Status)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("map")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateResourceMap(ctx, tx, postgres.CreateResourceMapParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Status: status,
Description: trimStringPtr(input.Description),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminMapSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
}, nil
}
func (s *AdminResourceService) GetMapDetail(ctx context.Context, mapPublicID string) (*AdminMapDetail, error) {
item, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
}
versions, err := s.store.ListResourceMapVersions(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminMapDetail{
Map: AdminMapSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
},
Versions: make([]AdminMapVersion, 0, len(versions)),
}
for _, version := range versions {
view := AdminMapVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
MapmetaURL: version.MapmetaURL,
TilesRootURL: version.TilesRootURL,
PublishedAssetRoot: version.PublishedAssetRoot,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}
result.Versions = append(result.Versions, view)
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
result.Map.CurrentVersion = &AdminMapVersionBrief{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
}
result.Map.CurrentVersionID = &view.ID
}
}
return result, nil
}
func (s *AdminResourceService) CreateMapVersion(ctx context.Context, mapPublicID string, input CreateAdminMapVersionInput) (*AdminMapVersion, error) {
mapItem, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID))
if err != nil {
return nil, err
}
if mapItem == nil {
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.MapmetaURL = strings.TrimSpace(input.MapmetaURL)
input.TilesRootURL = strings.TrimSpace(input.TilesRootURL)
if input.VersionCode == "" || input.MapmetaURL == "" || input.TilesRootURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, mapmetaUrl and tilesRootUrl are required")
}
publicID, err := security.GeneratePublicID("mapv")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
version, err := s.store.CreateResourceMapVersion(ctx, tx, postgres.CreateResourceMapVersionParams{
PublicID: publicID,
MapID: mapItem.ID,
VersionCode: input.VersionCode,
Status: normalizeVersionStatus(input.Status),
MapmetaURL: input.MapmetaURL,
TilesRootURL: input.TilesRootURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
BoundsJSON: input.Bounds,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetResourceMapCurrentVersion(ctx, tx, mapItem.ID, version.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminMapVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
MapmetaURL: version.MapmetaURL,
TilesRootURL: version.TilesRootURL,
PublishedAssetRoot: version.PublishedAssetRoot,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}, nil
}
func (s *AdminResourceService) ListPlayfields(ctx context.Context, limit int) ([]AdminPlayfieldSummary, error) {
items, err := s.store.ListResourcePlayfields(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminPlayfieldSummary, 0, len(items))
for _, item := range items {
results = append(results, AdminPlayfieldSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Kind: item.Kind,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
})
}
return results, nil
}
func (s *AdminResourceService) CreatePlayfield(ctx context.Context, input CreateAdminPlayfieldInput) (*AdminPlayfieldSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
kind := strings.TrimSpace(input.Kind)
if kind == "" {
kind = "course"
}
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("pf")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateResourcePlayfield(ctx, tx, postgres.CreateResourcePlayfieldParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Kind: kind,
Status: normalizeCatalogStatus(input.Status),
Description: trimStringPtr(input.Description),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminPlayfieldSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Kind: item.Kind,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
}, nil
}
func (s *AdminResourceService) GetPlayfieldDetail(ctx context.Context, publicID string) (*AdminPlayfieldDetail, error) {
item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found")
}
versions, err := s.store.ListResourcePlayfieldVersions(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminPlayfieldDetail{
Playfield: AdminPlayfieldSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Kind: item.Kind,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
},
Versions: make([]AdminPlayfieldVersion, 0, len(versions)),
}
for _, version := range versions {
view := AdminPlayfieldVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
SourceType: version.SourceType,
SourceURL: version.SourceURL,
PublishedAssetRoot: version.PublishedAssetRoot,
ControlCount: version.ControlCount,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}
result.Versions = append(result.Versions, view)
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
result.Playfield.CurrentVersion = &AdminPlayfieldVersionBrief{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
SourceType: version.SourceType,
}
result.Playfield.CurrentVersionID = &view.ID
}
}
return result, nil
}
func (s *AdminResourceService) CreatePlayfieldVersion(ctx context.Context, publicID string, input CreateAdminPlayfieldVersionInput) (*AdminPlayfieldVersion, error) {
item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.SourceType = strings.TrimSpace(input.SourceType)
input.SourceURL = strings.TrimSpace(input.SourceURL)
if input.VersionCode == "" || input.SourceType == "" || input.SourceURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, sourceType and sourceUrl are required")
}
publicVersionID, err := security.GeneratePublicID("pfv")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
version, err := s.store.CreateResourcePlayfieldVersion(ctx, tx, postgres.CreateResourcePlayfieldVersionParams{
PublicID: publicVersionID,
PlayfieldID: item.ID,
VersionCode: input.VersionCode,
Status: normalizeVersionStatus(input.Status),
SourceType: input.SourceType,
SourceURL: input.SourceURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
ControlCount: input.ControlCount,
BoundsJSON: input.Bounds,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetResourcePlayfieldCurrentVersion(ctx, tx, item.ID, version.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminPlayfieldVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
SourceType: version.SourceType,
SourceURL: version.SourceURL,
PublishedAssetRoot: version.PublishedAssetRoot,
ControlCount: version.ControlCount,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}, nil
}
func (s *AdminResourceService) ListResourcePacks(ctx context.Context, limit int) ([]AdminResourcePackSummary, error) {
items, err := s.store.ListResourcePacks(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminResourcePackSummary, 0, len(items))
for _, item := range items {
results = append(results, AdminResourcePackSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
})
}
return results, nil
}
func (s *AdminResourceService) CreateResourcePack(ctx context.Context, input CreateAdminResourcePackInput) (*AdminResourcePackSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("rp")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateResourcePack(ctx, tx, postgres.CreateResourcePackParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Status: normalizeCatalogStatus(input.Status),
Description: trimStringPtr(input.Description),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminResourcePackSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
}, nil
}
func (s *AdminResourceService) GetResourcePackDetail(ctx context.Context, publicID string) (*AdminResourcePackDetail, error) {
item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found")
}
versions, err := s.store.ListResourcePackVersions(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminResourcePackDetail{
ResourcePack: AdminResourcePackSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
},
Versions: make([]AdminResourcePackVersion, 0, len(versions)),
}
for _, version := range versions {
view := AdminResourcePackVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
ContentEntryURL: version.ContentEntryURL,
AudioRootURL: version.AudioRootURL,
ThemeProfileCode: version.ThemeProfileCode,
PublishedAssetRoot: version.PublishedAssetRoot,
Metadata: decodeJSONMap(version.MetadataJSON),
}
result.Versions = append(result.Versions, view)
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
result.ResourcePack.CurrentVersion = &AdminResourcePackVersionBrief{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
}
result.ResourcePack.CurrentVersionID = &view.ID
}
}
return result, nil
}
func (s *AdminResourceService) CreateResourcePackVersion(ctx context.Context, publicID string, input CreateAdminResourcePackVersionInput) (*AdminResourcePackVersion, error) {
item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
if input.VersionCode == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode is required")
}
publicVersionID, err := security.GeneratePublicID("rpv")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
version, err := s.store.CreateResourcePackVersion(ctx, tx, postgres.CreateResourcePackVersionParams{
PublicID: publicVersionID,
ResourcePackID: item.ID,
VersionCode: input.VersionCode,
Status: normalizeVersionStatus(input.Status),
ContentEntryURL: trimStringPtr(input.ContentEntryURL),
AudioRootURL: trimStringPtr(input.AudioRootURL),
ThemeProfileCode: trimStringPtr(input.ThemeProfileCode),
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetResourcePackCurrentVersion(ctx, tx, item.ID, version.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminResourcePackVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
ContentEntryURL: version.ContentEntryURL,
AudioRootURL: version.AudioRootURL,
ThemeProfileCode: version.ThemeProfileCode,
PublishedAssetRoot: version.PublishedAssetRoot,
Metadata: decodeJSONMap(version.MetadataJSON),
}, nil
}
func normalizeCatalogStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "disabled":
return "disabled"
case "archived":
return "archived"
default:
return "draft"
}
}
func normalizeVersionStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "archived":
return "archived"
default:
return "draft"
}
}
func trimStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func decodeJSONMap(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return nil
}
result := map[string]any{}
if err := json.Unmarshal(raw, &result); err != nil || len(result) == 0 {
return nil
}
return result
}

View File

@@ -127,7 +127,7 @@ func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInpu
}
for i := range sessions {
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
if isSessionOngoingStatus(sessions[i].Status) {
ongoing := buildEntrySessionSummary(&sessions[i])
result.OngoingSession = &ongoing
break

View File

@@ -99,7 +99,7 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
result.Play.RecentSession = &recent
}
for i := range sessions {
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
if isSessionOngoingStatus(sessions[i].Status) {
ongoing := buildEntrySessionSummary(&sessions[i])
result.Play.OngoingSession = &ongoing
break

View File

@@ -16,6 +16,10 @@ type SessionService struct {
store *postgres.Store
}
type sessionTokenPolicy struct {
AllowExpired bool
}
type SessionResult struct {
Session struct {
ID string `json:"id"`
@@ -99,57 +103,11 @@ func (s *SessionService) ListMySessions(ctx context.Context, userID string, limi
}
func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{})
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
return nil, err
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
input.Status = normalizeFinishStatus(input.Status)
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
if session.Status == SessionStatusRunning || isSessionTerminalStatus(session.Status) {
return buildSessionResult(session), nil
}
@@ -166,11 +124,73 @@ func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionI
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{}); err != nil {
return nil, err
}
if locked.Status == SessionStatusRunning || isSessionTerminalStatus(locked.Status) {
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(locked), nil
}
if locked.Status == SessionStatusLaunched {
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
return nil, err
}
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if updated == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
status, err := normalizeFinishStatus(input.Status)
if err != nil {
return nil, err
}
input.Status = status
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{
AllowExpired: input.Status == SessionStatusCancelled,
})
if err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
if isSessionTerminalStatus(session.Status) {
return buildSessionResult(session), nil
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{
AllowExpired: input.Status == SessionStatusCancelled || isSessionTerminalStatus(locked.Status),
}); err != nil {
return nil, err
}
if isSessionTerminalStatus(locked.Status) {
if err := tx.Commit(ctx); err != nil {
return nil, err
}
@@ -208,7 +228,7 @@ func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionI
return buildSessionResult(updated), nil
}
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) {
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string, policy sessionTokenPolicy) (*postgres.Session, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
sessionToken = strings.TrimSpace(sessionToken)
if sessionPublicID == "" || sessionToken == "" {
@@ -222,19 +242,19 @@ func (s *SessionService) validateSessionAction(ctx context.Context, sessionPubli
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(session, sessionToken); err != nil {
if err := s.verifySessionToken(session, sessionToken, policy); err != nil {
return nil, err
}
return session, nil
}
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
}
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string, policy sessionTokenPolicy) error {
if session.SessionTokenHash != security.HashText(sessionToken) {
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
}
if !policy.AllowExpired && session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
}
return nil
}
@@ -265,14 +285,16 @@ func buildSessionResult(session *postgres.Session) *SessionResult {
return result
}
func normalizeFinishStatus(value string) string {
func normalizeFinishStatus(value string) (string, error) {
switch strings.TrimSpace(value) {
case "failed":
return "failed"
case "cancelled":
return "cancelled"
case "", SessionStatusFinished:
return SessionStatusFinished, nil
case SessionStatusFailed:
return SessionStatusFailed, nil
case SessionStatusCancelled:
return SessionStatusCancelled, nil
default:
return "finished"
return "", apperr.New(http.StatusBadRequest, "invalid_finish_status", "status must be finished, failed or cancelled")
}
}

View File

@@ -0,0 +1,27 @@
package service
const (
SessionStatusLaunched = "launched"
SessionStatusRunning = "running"
SessionStatusFinished = "finished"
SessionStatusFailed = "failed"
SessionStatusCancelled = "cancelled"
)
func isSessionTerminalStatus(status string) bool {
switch status {
case SessionStatusFinished, SessionStatusFailed, SessionStatusCancelled:
return true
default:
return false
}
}
func isSessionOngoingStatus(status string) bool {
switch status {
case SessionStatusLaunched, SessionStatusRunning:
return true
default:
return false
}
}