Add backend foundation and config-driven workbench
This commit is contained in:
678
backend/internal/service/config_service.go
Normal file
678
backend/internal/service/config_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user