Files
YMhut-box-C-/server/unified-management/internal/notices/notices.go
T
QWQLwToo df6e9ab9e9
build-winui / winui (push) Has been cancelled
更新 unified management 服务逻辑
2026-06-26 13:57:58 +08:00

498 lines
14 KiB
Go

package notices
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"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 {
Notice db.ReleaseNotice `json:"notice"`
Raw string `json:"raw"`
Parsed map[string]any `json:"parsed"`
Path string `json:"path"`
UpdatedAt string `json:"updatedAt"`
Revisions []db.ReleaseNoticeRevision `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) Import(ctx context.Context) error {
if strings.TrimSpace(s.cfg.UpdateNoticeDir) == "" {
return nil
}
if _, err := os.Stat(s.cfg.UpdateNoticeDir); errors.Is(err, os.ErrNotExist) {
return nil
}
if err := s.importTotalIndex(); err != nil {
return err
}
entries, err := os.ReadDir(s.cfg.UpdateNoticeDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json") || strings.EqualFold(entry.Name(), "total.json") {
continue
}
path := filepath.Join(s.cfg.UpdateNoticeDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return err
}
item, _, _, err := parseNotice(data, versionFromFile(entry.Name()), entry.Name())
if err != nil {
return err
}
if _, err := s.store.UpsertReleaseNotice(item); err != nil {
return err
}
}
return nil
}
func (s *Service) List(limit int) ([]db.ReleaseNotice, error) {
return s.store.ListReleaseNotices(limit)
}
func (s *Service) Get(version string) (Document, error) {
item, err := s.store.GetReleaseNotice(version)
if err != nil {
return Document{}, err
}
raw := item.RawJSON
parsed, formatted, err := parseAndFormat([]byte(raw), version, item.NoticeFile)
if err != nil {
return Document{}, err
}
revisions, _ := s.store.ListReleaseNoticeRevisions(version, 20)
updatedAt := item.UpdatedAt
path := filepath.Join(s.cfg.UpdateNoticeDir, firstNonEmpty(item.NoticeFile, version+".json"))
if info, err := os.Stat(path); err == nil {
updatedAt = info.ModTime().UTC().Format(time.RFC3339)
}
return Document{Notice: item, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: updatedAt, Revisions: revisions}, nil
}
func (s *Service) Validate(ctx context.Context, version string, req SaveRequest) (Document, error) {
raw, err := requestRaw(req)
if err != nil {
return Document{}, err
}
item, parsed, formatted, err := parseNotice([]byte(raw), version, version+".json")
if err != nil {
return Document{}, err
}
return Document{Notice: item, Raw: formatted, Parsed: parsed}, nil
}
func (s *Service) Save(ctx context.Context, version string, req SaveRequest, actor string) (Document, error) {
raw, err := requestRaw(req)
if err != nil {
return Document{}, err
}
item, parsed, formatted, err := parseNotice([]byte(raw), version, version+".json")
if err != nil {
return Document{}, err
}
item.RawJSON = formatted
if current, err := s.store.GetReleaseNotice(item.Version); err == nil && current.RawJSON != "" {
_, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before save", actor)
}
saved, err := s.store.UpsertReleaseNotice(item)
if err != nil {
return Document{}, err
}
_, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, req.Note, actor)
if err := s.writeNoticeFile(saved, formatted); err != nil {
return Document{}, err
}
if err := s.writeTotalIndex(saved, parsed); err != nil {
return Document{}, err
}
if err := s.syncLegacyUpdateInfo(saved, parsed); err != nil {
return Document{}, err
}
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.saved", Target: saved.Version, Message: "版本日志已保存并同步兼容更新信息"})
return s.Get(saved.Version)
}
func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) {
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
if err != nil {
return Document{}, err
}
return s.Save(ctx, version, SaveRequest{Raw: revision.RawJSON, Note: "restored revision"}, actor)
}
func (s *Service) importTotalIndex() error {
path := filepath.Join(s.cfg.UpdateNoticeDir, "total.json")
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return err
}
if latest, ok := root["latest"].(map[string]any); ok {
raw, _ := json.MarshalIndent(latest, "", " ")
item, _, _, err := parseNotice(raw, stringValue(root, "latest_version"), stringValue(root, "latest_notice_file"))
if err == nil {
_, _ = s.store.UpsertReleaseNotice(item)
}
}
for _, entry := range arrayValue(root, "versions") {
version := stringValue(entry, "version")
if version == "" {
continue
}
raw, _ := json.MarshalIndent(entry, "", " ")
item, _, _, err := parseNotice(raw, version, stringValue(entry, "notice_file"))
if err == nil {
_, _ = s.store.UpsertReleaseNotice(item)
}
}
return nil
}
func (s *Service) writeNoticeFile(item db.ReleaseNotice, raw string) error {
if err := os.MkdirAll(s.cfg.UpdateNoticeDir, 0o750); err != nil {
return err
}
return atomicWrite(filepath.Join(s.cfg.UpdateNoticeDir, firstNonEmpty(item.NoticeFile, item.Version+".json")), []byte(raw))
}
func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any) error {
path := filepath.Join(s.cfg.UpdateNoticeDir, "total.json")
root := map[string]any{"schema_version": 1, "product": "YMhut Box", "versions": []any{}}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &root)
}
root["latest_version"] = newestVersion(stringValue(root, "latest_version"), item.Version)
if root["latest_version"] == item.Version {
root["latest_notice_file"] = item.NoticeFile
root["latest"] = latestMap(item, parsed)
}
root["last_updated"] = db.Now()
versions := arrayValue(root, "versions")
next := make([]any, 0, len(versions)+1)
found := false
for _, entry := range versions {
if stringValue(entry, "version") == item.Version {
next = append(next, summaryMap(item, parsed))
found = true
continue
}
next = append(next, entry)
}
if !found {
next = append(next, summaryMap(item, parsed))
}
sort.SliceStable(next, func(i, j int) bool {
return compareVersion(stringValue(next[i], "version"), stringValue(next[j], "version")) > 0
})
root["versions"] = next
data, err := json.MarshalIndent(root, "", " ")
if err != nil {
return err
}
return atomicWrite(path, append(data, '\n'))
}
func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error {
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
payload := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &payload)
}
payload["app_version"] = item.Version
setNonEmpty(payload, "build", item.Build)
setNonEmpty(payload, "channel", item.Channel)
setNonEmpty(payload, "title", item.Title)
setNonEmpty(payload, "message", item.Message)
setNonEmpty(payload, "message_md", item.MessageMD)
setNonEmpty(payload, "release_notes", item.ReleaseNotes)
setNonEmpty(payload, "release_notes_md", item.ReleaseNotesMD)
setNonEmpty(payload, "download_url", item.DownloadURL)
payload["last_updated"] = firstNonEmpty(item.PublishedAt, db.Now())
for _, key := range []string{"update_notes", "last_update_notes", "download_mirrors", "detected_packages", "detected_product", "category_list", "home_notes", "tool_metadata", "api_keys"} {
if value, ok := parsed[key]; ok {
payload[key] = value
}
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
return atomicWrite(path, append(data, '\n'))
}
func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) {
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
return parsed, formatted, err
}
func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNotice, 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 db.ReleaseNotice{}, nil, "", err
}
version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion)
if version == "" {
return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required")
}
if noticeFile == "" {
noticeFile = version + ".json"
}
formattedBytes, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return db.ReleaseNotice{}, nil, "", err
}
formatted := string(formattedBytes) + "\n"
item := db.ReleaseNotice{
Version: version,
Build: stringValue(parsed, "build"),
Channel: firstNonEmpty(stringValue(parsed, "channel"), "stable"),
Title: firstNonEmpty(stringValue(parsed, "title"), "YMhut Box "+version),
Message: firstNonEmpty(stringValue(parsed, "message"), stringValue(parsed, "summary"), stringValue(parsed, "home_notes")),
ReleaseNotes: firstNonEmpty(stringValue(parsed, "release_notes"), stringValue(parsed, "summary")),
MessageMD: stringValue(parsed, "message_md"),
ReleaseNotesMD: stringValue(parsed, "release_notes_md"),
DownloadURL: stringValue(parsed, "download_url"),
NoticeFile: noticeFile,
RawJSON: formatted,
PublishedAt: normalizeTime(firstNonEmpty(stringValue(parsed, "published_at"), stringValue(parsed, "release_date"), stringValue(parsed, "last_updated"))),
}
return item, parsed, formatted, nil
}
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)
return string(data), err
}
func latestMap(item db.ReleaseNotice, parsed map[string]any) map[string]any {
out := summaryMap(item, parsed)
out["title"] = item.Title
out["message"] = item.Message
out["download_url"] = item.DownloadURL
out["release_notes"] = item.ReleaseNotes
out["message_md"] = item.MessageMD
out["release_notes_md"] = item.ReleaseNotesMD
return out
}
func summaryMap(item db.ReleaseNotice, parsed map[string]any) map[string]any {
out := map[string]any{
"version": item.Version,
"build": item.Build,
"channel": item.Channel,
"release_date": dateOnly(item.PublishedAt),
"notice_file": item.NoticeFile,
"summary": firstNonEmpty(item.Message, item.ReleaseNotes),
}
if value, ok := parsed["highlights"]; ok {
out["highlights"] = value
}
if value, ok := parsed["categories"]; ok {
out["categories"] = value
} else if value, ok := parsed["update_notes"]; ok {
out["categories"] = value
}
return out
}
func PublicNotice(item db.ReleaseNotice) map[string]any {
return map[string]any{
"version": item.Version,
"build": item.Build,
"channel": item.Channel,
"title": item.Title,
"message": item.Message,
"release_notes": item.ReleaseNotes,
"message_md": item.MessageMD,
"release_notes_md": item.ReleaseNotesMD,
"download_url": item.DownloadURL,
"notice_file": item.NoticeFile,
"published_at": item.PublishedAt,
"updated_at": item.UpdatedAt,
"releaseNotes": item.ReleaseNotes,
"messageMarkdown": item.MessageMD,
"releaseNotesMarkdown": item.ReleaseNotesMD,
}
}
func PublicList(items []db.ReleaseNotice) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, PublicNotice(item))
}
return out
}
func arrayValue(root map[string]any, key string) []any {
if values, ok := root[key].([]any); ok {
return values
}
return []any{}
}
func stringValue(root any, key string) string {
obj, ok := root.(map[string]any)
if !ok {
return ""
}
switch value := obj[key].(type) {
case string:
return strings.TrimSpace(value)
case json.Number:
return value.String()
default:
return ""
}
}
func setNonEmpty(target map[string]any, key, value string) {
if strings.TrimSpace(value) != "" {
target[key] = value
}
}
func normalizeTime(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.UTC().Format(time.RFC3339)
}
if t, err := time.Parse("2006-01-02", value); err == nil {
return t.UTC().Format(time.RFC3339)
}
return value
}
func dateOnly(value string) string {
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.UTC().Format("2006-01-02")
}
if len(value) >= 10 {
return value[:10]
}
return value
}
func newestVersion(current, candidate string) string {
if current == "" || compareVersion(candidate, current) >= 0 {
return candidate
}
return current
}
func compareVersion(a, b string) int {
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
for len(as) < 4 {
as = append(as, "0")
}
for len(bs) < 4 {
bs = append(bs, "0")
}
for i := 0; i < 4; i++ {
ai := parseInt(as[i])
bi := parseInt(bs[i])
if ai > bi {
return 1
}
if ai < bi {
return -1
}
}
return 0
}
func parseInt(value string) int {
n := 0
for _, r := range value {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
}
return n
}
func versionFromFile(name string) string {
return strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))
}
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
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)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}