同步前后端联调与文档更新
This commit is contained in:
490
backend/internal/service/admin_event_service.go
Normal file
490
backend/internal/service/admin_event_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user