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

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