Add backend foundation and config-driven workbench

This commit is contained in:
2026-04-01 15:01:44 +08:00
parent 88b8f05f03
commit 94a1f0ba78
68 changed files with 10833 additions and 0 deletions

View File

@@ -0,0 +1,678 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type ConfigService struct {
store *postgres.Store
localEventDir string
assetBaseURL string
}
type ConfigPipelineSummary struct {
SourceTable string `json:"sourceTable"`
BuildTable string `json:"buildTable"`
ReleaseAssetsTable string `json:"releaseAssetsTable"`
}
type LocalEventFile struct {
FileName string `json:"fileName"`
FullPath string `json:"fullPath"`
}
type EventConfigSourceView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
SourceVersionNo int `json:"sourceVersionNo"`
SourceKind string `json:"sourceKind"`
SchemaID string `json:"schemaId"`
SchemaVersion string `json:"schemaVersion"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
Source map[string]any `json:"source"`
}
type EventConfigBuildView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
SourceID string `json:"sourceId"`
BuildNo int `json:"buildNo"`
BuildStatus string `json:"buildStatus"`
BuildLog *string `json:"buildLog,omitempty"`
Manifest map[string]any `json:"manifest"`
AssetIndex []map[string]any `json:"assetIndex"`
}
type PublishedReleaseView struct {
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
}
type ImportLocalEventConfigInput struct {
EventPublicID string
FileName string `json:"fileName"`
Notes *string `json:"notes,omitempty"`
}
type BuildPreviewInput struct {
SourceID string `json:"sourceId"`
}
type PublishBuildInput struct {
BuildID string `json:"buildId"`
}
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService {
return &ConfigService{
store: store,
localEventDir: localEventDir,
assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
}
}
func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
return ConfigPipelineSummary{
SourceTable: "event_config_sources",
BuildTable: "event_config_builds",
ReleaseAssetsTable: "event_release_assets",
}
}
func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
dir, err := filepath.Abs(s.localEventDir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
}
files := make([]LocalEventFile, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
continue
}
files = append(files, LocalEventFile{
FileName: entry.Name(),
FullPath: filepath.Join(dir, entry.Name()),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].FileName < files[j].FileName
})
return files, nil
}
func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
event, err := s.requireEvent(ctx, eventPublicID)
if err != nil {
return nil, err
}
items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
results := make([]EventConfigSourceView, 0, len(items))
for i := range items {
view, err := buildEventConfigSourceView(&items[i], event.PublicID)
if err != nil {
return nil, err
}
results = append(results, *view)
}
return results, nil
}
func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
}
return buildEventConfigSourceView(record, "")
}
func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
}
return buildEventConfigBuildView(record)
}
func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
event, err := s.requireEvent(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
fileName := strings.TrimSpace(filepath.Base(input.FileName))
if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
}
dir, err := filepath.Abs(s.localEventDir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
}
path := filepath.Join(dir, fileName)
raw, err := os.ReadFile(path)
if err != nil {
return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
}
source := map[string]any{}
if err := json.Unmarshal(raw, &source); err != nil {
return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
}
if err := validateSourceConfig(source); err != nil {
return nil, err
}
nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
if err != nil {
return nil, err
}
note := input.Notes
if note == nil || strings.TrimSpace(*note) == "" {
defaultNote := "imported from local event file: " + fileName
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: event.ID,
SourceVersionNo: nextVersion,
SourceKind: "event_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, event.PublicID)
}
func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
if err != nil {
return nil, err
}
if sourceRecord == nil {
return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
}
source, err := decodeJSONObject(sourceRecord.SourceJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
}
if err := validateSourceConfig(source); err != nil {
return nil, err
}
buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
if err != nil {
return nil, err
}
previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
manifest := s.buildPreviewManifest(source, previewReleaseID)
assetIndex := s.buildAssetIndex(manifest)
buildLog := "preview build generated from source " + sourceRecord.ID
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
EventID: sourceRecord.EventID,
SourceID: sourceRecord.ID,
BuildNo: buildNo,
BuildStatus: "success",
BuildLog: &buildLog,
Manifest: manifest,
AssetIndex: assetIndex,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildEventConfigBuildView(record)
}
func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
if err != nil {
return nil, err
}
if buildRecord == nil {
return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
}
if buildRecord.BuildStatus != "success" {
return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
}
event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
}
assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
}
releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
if err != nil {
return nil, err
}
releasePublicID, err := security.GeneratePublicID("rel")
if err != nil {
return nil, err
}
configLabel := deriveConfigLabel(event, manifest, releaseNo)
manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
checksum := security.HashText(buildRecord.ManifestJSON)
routeCode := deriveRouteCode(manifest)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
PublicID: releasePublicID,
EventID: event.ID,
ReleaseNo: releaseNo,
ConfigLabel: configLabel,
ManifestURL: manifestURL,
ManifestChecksum: &checksum,
RouteCode: routeCode,
BuildID: &buildRecord.ID,
Status: "published",
PayloadJSON: buildRecord.ManifestJSON,
})
if err != nil {
return nil, err
}
if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, &checksum, assetIndex)); err != nil {
return nil, err
}
if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &PublishedReleaseView{
EventID: event.PublicID,
Release: ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: LaunchSourceEventCurrentRelease,
EventID: event.PublicID,
ReleaseID: releaseRecord.PublicID,
ConfigLabel: releaseRecord.ConfigLabel,
ManifestURL: releaseRecord.ManifestURL,
ManifestChecksumSha256: releaseRecord.ManifestChecksum,
RouteCode: releaseRecord.RouteCode,
},
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
}, nil
}
func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
return event, nil
}
func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
source, err := decodeJSONObject(record.SourceJSON)
if err != nil {
return nil, err
}
view := &EventConfigSourceView{
ID: record.ID,
EventID: eventPublicID,
SourceVersionNo: record.SourceVersionNo,
SourceKind: record.SourceKind,
SchemaID: record.SchemaID,
SchemaVersion: record.SchemaVersion,
Status: record.Status,
Notes: record.Notes,
Source: source,
}
return view, nil
}
func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
manifest, err := decodeJSONObject(record.ManifestJSON)
if err != nil {
return nil, err
}
assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
if err != nil {
return nil, err
}
return &EventConfigBuildView{
ID: record.ID,
EventID: record.EventID,
SourceID: record.SourceID,
BuildNo: record.BuildNo,
BuildStatus: record.BuildStatus,
BuildLog: record.BuildLog,
Manifest: manifest,
AssetIndex: assetIndex,
}, nil
}
func validateSourceConfig(source map[string]any) error {
requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
value, ok := parent[key]
if !ok {
return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
}
asMap, ok := value.(map[string]any)
if !ok {
return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
}
return asMap, nil
}
requiredString := func(parent map[string]any, key string) error {
value, ok := parent[key]
if !ok {
return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
}
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
}
return nil
}
if err := requiredString(source, "schemaVersion"); err != nil {
return err
}
app, err := requiredMap(source, "app")
if err != nil {
return err
}
if err := requiredString(app, "id"); err != nil {
return err
}
if err := requiredString(app, "title"); err != nil {
return err
}
m, err := requiredMap(source, "map")
if err != nil {
return err
}
if err := requiredString(m, "tiles"); err != nil {
return err
}
if err := requiredString(m, "mapmeta"); err != nil {
return err
}
playfield, err := requiredMap(source, "playfield")
if err != nil {
return err
}
if err := requiredString(playfield, "kind"); err != nil {
return err
}
playfieldSource, err := requiredMap(playfield, "source")
if err != nil {
return err
}
if err := requiredString(playfieldSource, "type"); err != nil {
return err
}
if err := requiredString(playfieldSource, "url"); err != nil {
return err
}
game, err := requiredMap(source, "game")
if err != nil {
return err
}
if err := requiredString(game, "mode"); err != nil {
return err
}
return nil
}
func resolveSchemaVersion(source map[string]any) string {
if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
return value
}
return "1"
}
func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
manifest := cloneJSONObject(source)
manifest["releaseId"] = previewReleaseID
manifest["preview"] = true
manifest["assetBaseUrl"] = s.assetBaseURL
if version, ok := manifest["version"]; !ok || version == "" {
manifest["version"] = "preview"
}
if m, ok := manifest["map"].(map[string]any); ok {
if tiles, ok := m["tiles"].(string); ok {
m["tiles"] = s.normalizeAssetURL(tiles)
}
if meta, ok := m["mapmeta"].(string); ok {
m["mapmeta"] = s.normalizeAssetURL(meta)
}
}
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if src, ok := playfield["source"].(map[string]any); ok {
if url, ok := src["url"].(string); ok {
src["url"] = s.normalizeAssetURL(url)
}
}
}
if assets, ok := manifest["assets"].(map[string]any); ok {
for key, value := range assets {
if text, ok := value.(string); ok {
assets[key] = s.normalizeAssetURL(text)
}
}
}
return manifest
}
func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
var assets []map[string]any
if m, ok := manifest["map"].(map[string]any); ok {
if tiles, ok := m["tiles"].(string); ok {
assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
}
if meta, ok := m["mapmeta"].(string); ok {
assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
}
}
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if src, ok := playfield["source"].(map[string]any); ok {
if url, ok := src["url"].(string); ok {
assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
}
}
}
if rawAssets, ok := manifest["assets"].(map[string]any); ok {
keys := make([]string, 0, len(rawAssets))
for key := range rawAssets {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if url, ok := rawAssets[key].(string); ok {
assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
}
}
}
return assets
}
func (s *ConfigService) normalizeAssetURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value
}
trimmed := strings.TrimPrefix(value, "../")
trimmed = strings.TrimPrefix(trimmed, "./")
trimmed = strings.TrimLeft(trimmed, "/")
return s.assetBaseURL + "/" + trimmed
}
func cloneJSONObject(source map[string]any) map[string]any {
raw, _ := json.Marshal(source)
cloned := map[string]any{}
_ = json.Unmarshal(raw, &cloned)
return cloned
}
func decodeJSONObject(raw string) (map[string]any, error) {
result := map[string]any{}
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, err
}
return result, nil
}
func decodeJSONArray(raw string) ([]map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return []map[string]any{}, nil
}
var result []map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, err
}
return result, nil
}
func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
if app, ok := manifest["app"].(map[string]any); ok {
if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
}
}
if event != nil && strings.TrimSpace(event.DisplayName) != "" {
return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
}
return fmt.Sprintf("Release %d", releaseNo)
}
func deriveRouteCode(manifest map[string]any) *string {
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
route := strings.TrimSpace(value)
return &route
}
}
return nil
}
func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
assets := []postgres.UpsertEventReleaseAssetParams{
{
EventReleaseID: eventReleaseID,
AssetType: "manifest",
AssetKey: "manifest",
AssetURL: manifestURL,
Checksum: checksum,
Meta: map[string]any{"source": "published-build"},
},
}
for _, asset := range assetIndex {
assetType, _ := asset["assetType"].(string)
assetKey, _ := asset["assetKey"].(string)
assetURL, _ := asset["assetUrl"].(string)
if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
continue
}
mappedType := assetType
if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
mappedType = "other"
}
assets = append(assets, postgres.UpsertEventReleaseAssetParams{
EventReleaseID: eventReleaseID,
AssetType: mappedType,
AssetKey: assetKey,
AssetURL: assetURL,
Meta: asset,
})
}
return assets
}