270 lines
7.6 KiB
Go
270 lines
7.6 KiB
Go
package legacy
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"ymhut-box/server/unified-management/internal/config"
|
||
"ymhut-box/server/unified-management/internal/db"
|
||
)
|
||
|
||
type Service struct {
|
||
cfg *config.Config
|
||
store *db.Store
|
||
}
|
||
|
||
type Document struct {
|
||
Name string `json:"name"`
|
||
Raw string `json:"raw"`
|
||
Parsed map[string]any `json:"parsed"`
|
||
Path string `json:"path"`
|
||
UpdatedAt string `json:"updatedAt"`
|
||
Revisions []db.LegacyJsonRevision `json:"revisions"`
|
||
}
|
||
|
||
type SaveRequest struct {
|
||
Raw string `json:"raw"`
|
||
Parsed map[string]any `json:"parsed"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||
return &Service{cfg: cfg, store: store}
|
||
}
|
||
|
||
func (s *Service) EnsureSeedDocuments(ctx context.Context) error {
|
||
for _, name := range []string{"update-info", "media-types"} {
|
||
if err := s.ensureSeedDocument(ctx, name); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) ensureSeedDocument(ctx context.Context, name string) error {
|
||
fileName, err := fileNameFor(name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
target := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||
if _, err := os.Stat(target); err == nil {
|
||
return nil
|
||
} else if !errors.Is(err, os.ErrNotExist) {
|
||
return err
|
||
}
|
||
source := filepath.Join(s.cfg.LegacyUpdateDir, "public", fileName)
|
||
data, err := os.ReadFile(source)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
_, formatted, err := parseAndFormat(name, data)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||
return err
|
||
}
|
||
if err := atomicWrite(target, []byte(formatted)); err != nil {
|
||
return err
|
||
}
|
||
_, _ = s.store.SaveLegacyRevision(name, formatted, "从旧 update/public 初始化基板", "system")
|
||
_ = s.store.InsertAudit(db.AuditLog{Actor: "system", Type: "legacy_json.seeded", Target: name, Message: "已从旧项目导入兼容 JSON 基板"})
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) Get(ctx context.Context, name string) (Document, error) {
|
||
fileName, err := fileNameFor(name)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
path := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
parsed, formatted, err := parseAndFormat(name, data)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||
updatedAt := ""
|
||
if info, err := os.Stat(path); err == nil {
|
||
updatedAt = info.ModTime().UTC().Format(time.RFC3339)
|
||
}
|
||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: updatedAt, Revisions: revisions}, nil
|
||
}
|
||
|
||
func (s *Service) Validate(ctx context.Context, name string, req SaveRequest) (Document, error) {
|
||
raw, err := requestRaw(req)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
parsed, formatted, err := parseAndFormat(name, []byte(raw))
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
return Document{Name: name, Raw: formatted, Parsed: parsed}, nil
|
||
}
|
||
|
||
func (s *Service) Save(ctx context.Context, name string, req SaveRequest, actor string) (Document, error) {
|
||
fileName, err := fileNameFor(name)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
raw, err := requestRaw(req)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
parsed, formatted, err := parseAndFormat(name, []byte(raw))
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
path := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||
current, _ := os.ReadFile(path)
|
||
if len(current) > 0 {
|
||
_, _ = s.store.SaveLegacyRevision(name, string(current), "auto backup before save", actor)
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||
return Document{}, err
|
||
}
|
||
if err := atomicWrite(path, []byte(formatted)); err != nil {
|
||
return Document{}, err
|
||
}
|
||
_, _ = s.store.SaveLegacyRevision(name, formatted, req.Note, actor)
|
||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.saved", Target: name, Message: legacyMessage(name, "已保存并发布兼容 JSON")})
|
||
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
||
}
|
||
|
||
func (s *Service) Restore(ctx context.Context, name string, revisionID int64, actor string) (Document, error) {
|
||
fileName, err := fileNameFor(name)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
revision, err := s.store.GetLegacyRevision(name, revisionID)
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
parsed, formatted, err := parseAndFormat(name, []byte(revision.Raw))
|
||
if err != nil {
|
||
return Document{}, err
|
||
}
|
||
path := filepath.Join(s.cfg.UpdatePublicDir, fileName)
|
||
current, _ := os.ReadFile(path)
|
||
if len(current) > 0 {
|
||
_, _ = s.store.SaveLegacyRevision(name, string(current), "auto backup before restore", actor)
|
||
}
|
||
if err := atomicWrite(path, []byte(formatted)); err != nil {
|
||
return Document{}, err
|
||
}
|
||
_, _ = s.store.SaveLegacyRevision(name, formatted, "restored revision", actor)
|
||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.restored", Target: name, Message: legacyMessage(name, "已恢复兼容 JSON 历史版本")})
|
||
revisions, _ := s.store.ListLegacyRevisions(name, 20)
|
||
return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil
|
||
}
|
||
|
||
func legacyMessage(name, action string) string {
|
||
switch name {
|
||
case "update-info":
|
||
return "更新 JSON:" + action
|
||
case "media-types":
|
||
return "媒体源 JSON:" + action
|
||
default:
|
||
return action
|
||
}
|
||
}
|
||
|
||
func fileNameFor(name string) (string, error) {
|
||
switch strings.TrimSpace(name) {
|
||
case "update-info":
|
||
return "update-info.json", nil
|
||
case "media-types":
|
||
return "media-types.json", nil
|
||
default:
|
||
return "", errors.New("unsupported legacy document")
|
||
}
|
||
}
|
||
|
||
func requestRaw(req SaveRequest) (string, error) {
|
||
if strings.TrimSpace(req.Raw) != "" {
|
||
return req.Raw, nil
|
||
}
|
||
if req.Parsed == nil {
|
||
return "", errors.New("raw or parsed JSON is required")
|
||
}
|
||
data, err := json.Marshal(req.Parsed)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
func parseAndFormat(name string, data []byte) (map[string]any, string, error) {
|
||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||
decoder.UseNumber()
|
||
var parsed map[string]any
|
||
if err := decoder.Decode(&parsed); err != nil {
|
||
return nil, "", err
|
||
}
|
||
if err := validate(name, parsed); err != nil {
|
||
return nil, "", err
|
||
}
|
||
out, err := json.MarshalIndent(parsed, "", " ")
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return parsed, string(out) + "\n", nil
|
||
}
|
||
|
||
func validate(name string, parsed map[string]any) error {
|
||
switch name {
|
||
case "update-info":
|
||
if _, ok := parsed["app_version"]; !ok {
|
||
if _, ok := parsed["title"]; !ok {
|
||
return errors.New("更新 JSON 需要填写 app_version 或 title")
|
||
}
|
||
}
|
||
case "media-types":
|
||
if _, ok := parsed["categories"].([]any); !ok {
|
||
return errors.New("媒体源 JSON 需要包含 categories 数组")
|
||
}
|
||
if _, ok := parsed["layout_version"]; !ok {
|
||
parsed["layout_version"] = "1.0.0"
|
||
}
|
||
if _, ok := parsed["last_updated"]; !ok {
|
||
parsed["last_updated"] = time.Now().UTC().Format(time.RFC3339)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func atomicWrite(path string, data []byte) error {
|
||
dir := filepath.Dir(path)
|
||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmpName := tmp.Name()
|
||
defer os.Remove(tmpName)
|
||
if _, err := tmp.Write(data); err != nil {
|
||
_ = tmp.Close()
|
||
return err
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmpName, path)
|
||
}
|