Files
YMhut-box-C-/server/unified-management/internal/legacy/legacy.go
T
QWQLwToo 962a2f2143
build-winui / winui (push) Waiting to run
更新 update 门户站点界面和后台功能
2026-06-27 18:09:11 +08:00

270 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}