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) SyncFromLegacyUpdateInfo(ctx context.Context, raw string, actor string) error { if strings.TrimSpace(raw) == "" { return nil } item, parsed, formatted, err := parseNotice([]byte(raw), "", "") if err != nil { return err } item.RawJSON = formatted current, err := s.store.GetReleaseNotice(item.Version) if err == nil && current.RawJSON != "" && current.RawJSON != formatted { _, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before legacy update-info sync", actor) } saved, err := s.store.UpsertReleaseNotice(item) if err != nil { return err } _, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, "synced from update-info.json", actor) if err := s.writeNoticeFile(saved, formatted); err != nil { return err } if err := s.writeTotalIndex(saved, parsed); err != nil { return err } _ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.synced", Target: saved.Version, Message: "版本日志已从兼容 update-info.json 同步"}) return nil } 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 := s.legacyUpdateBase(path) 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 (s *Service) legacyUpdateBase(currentPath string) map[string]any { payload := map[string]any{} for _, path := range []string{ filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"), currentPath, } { if data, err := os.ReadFile(path); err == nil { var doc map[string]any if json.Unmarshal(data, &doc) == nil { for key, value := range doc { payload[key] = value } } } } if payload["app_version"] == nil { if value, ok := payload["appVersion"]; ok { payload["app_version"] = value } else if value, ok := payload["latestVersion"]; ok { payload["app_version"] = value } } if payload["manifest_version"] == nil { if value, ok := payload["manifestVersion"]; ok { payload["manifest_version"] = value } } return payload } 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 或 app_version") } 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 "" }