@@ -0,0 +1,385 @@
|
||||
package synclegacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
notices *notices.Service
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Ok bool `json:"ok"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
Paths map[string]any `json:"paths"`
|
||||
Stats map[string]int `json:"stats"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Errors []string `json:"errors"`
|
||||
Started string `json:"startedAt"`
|
||||
Finished string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store *db.Store, noticeService *notices.Service) *Service {
|
||||
return &Service{cfg: cfg, store: store, notices: noticeService}
|
||||
}
|
||||
|
||||
func (s *Service) Preview(ctx context.Context) Result {
|
||||
return s.run(ctx, true)
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) Result {
|
||||
return s.run(ctx, false)
|
||||
}
|
||||
|
||||
func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
||||
result := Result{
|
||||
Ok: true,
|
||||
DryRun: dryRun,
|
||||
Paths: map[string]any{
|
||||
"legacyUpdateDir": s.cfg.LegacyUpdateDir,
|
||||
"legacyFeedbackDir": s.cfg.LegacyFeedbackDir,
|
||||
"legacyUpdateNoticeDir": s.cfg.LegacyUpdateNoticeDir,
|
||||
"updatePublicDir": s.cfg.UpdatePublicDir,
|
||||
"updateNoticeDir": s.cfg.UpdateNoticeDir,
|
||||
},
|
||||
Stats: map[string]int{},
|
||||
Started: db.Now(),
|
||||
}
|
||||
defer func() {
|
||||
result.Finished = db.Now()
|
||||
if len(result.Errors) > 0 {
|
||||
result.Ok = false
|
||||
}
|
||||
}()
|
||||
s.previewPath(&result, "legacy_update", s.cfg.LegacyUpdateDir)
|
||||
s.previewPath(&result, "legacy_feedback", s.cfg.LegacyFeedbackDir)
|
||||
s.previewPath(&result, "legacy_update_notice", s.cfg.LegacyUpdateNoticeDir)
|
||||
if dryRun {
|
||||
return result
|
||||
}
|
||||
if err := s.backupCurrent(); err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
return result
|
||||
}
|
||||
s.syncUpdatePublic(&result)
|
||||
s.syncNotices(ctx, &result)
|
||||
s.syncFeedbackSQLite(&result)
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "legacy.sync", Target: "legacy-projects", Message: fmt.Sprintf("Legacy sync finished: ok=%v copied=%d imported=%d errors=%d", result.Ok, result.Stats["copiedFiles"], result.Stats["importedRows"], len(result.Errors))})
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) previewPath(result *Result, key, path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, key+": "+err.Error())
|
||||
result.Stats["missingPaths"]++
|
||||
return
|
||||
}
|
||||
if !info.IsDir() {
|
||||
result.Warnings = append(result.Warnings, key+": path is not a directory")
|
||||
result.Stats["missingPaths"]++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) backupCurrent() error {
|
||||
backupRoot := filepath.Join(s.cfg.StorageDir, "backups", "legacy-sync-"+time.Now().UTC().Format("20060102-150405"))
|
||||
for _, path := range []string{s.cfg.UpdatePublicDir, s.cfg.UpdateNoticeDir} {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
target := filepath.Join(backupRoot, filepath.Base(path))
|
||||
if err := copyDir(path, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncUpdatePublic(result *Result) {
|
||||
sourcePublic := filepath.Join(s.cfg.LegacyUpdateDir, "public")
|
||||
if _, err := os.Stat(sourcePublic); err != nil {
|
||||
result.Warnings = append(result.Warnings, "update public not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, name := range []string{"update-info.json", "media-types.json", "tool-status.json", "modules.json"} {
|
||||
source := filepath.Join(sourcePublic, name)
|
||||
if _, err := os.Stat(source); err == nil {
|
||||
if err := copyFile(source, filepath.Join(s.cfg.UpdatePublicDir, name)); err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
} else {
|
||||
result.Stats["copiedFiles"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceDownloads := filepath.Join(sourcePublic, "downloads")
|
||||
if _, err := os.Stat(sourceDownloads); err == nil {
|
||||
if err := copyDir(sourceDownloads, s.cfg.DownloadsDir); err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
} else {
|
||||
result.Stats["copiedDirectories"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) syncNotices(ctx context.Context, result *Result) {
|
||||
for _, source := range []string{filepath.Join(s.cfg.LegacyUpdateDir, "update-notice"), s.cfg.LegacyUpdateNoticeDir} {
|
||||
if _, err := os.Stat(source); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := copyDir(source, s.cfg.UpdateNoticeDir); err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
continue
|
||||
}
|
||||
result.Stats["copiedDirectories"]++
|
||||
}
|
||||
if s.notices != nil {
|
||||
if err := s.notices.Import(ctx); err != nil {
|
||||
result.Errors = append(result.Errors, "notice import: "+err.Error())
|
||||
} else {
|
||||
result.Stats["noticeImports"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) syncFeedbackSQLite(result *Result) {
|
||||
path := filepath.Join(s.cfg.LegacyFeedbackDir, "storage", "feedback.sqlite")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
result.Warnings = append(result.Warnings, "feedback sqlite not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
oldDB, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
return
|
||||
}
|
||||
defer oldDB.Close()
|
||||
s.importOldFeedbacks(oldDB, result)
|
||||
s.importOldComments(oldDB, result)
|
||||
s.importOldEvents(oldDB, result)
|
||||
s.importOldTags(oldDB, result)
|
||||
s.importOldMail(oldDB, result)
|
||||
s.importOldWebhooks(oldDB, result)
|
||||
}
|
||||
|
||||
func (s *Service) importOldFeedbacks(oldDB *sql.DB, result *Result) {
|
||||
rows, err := oldDB.Query(`SELECT code, received_at, title, type, severity, category, priority, contact, body, status, status_detail, note, public_reply, handled_by, assignee, due_at, resolved_at, archived_at, sla_level, source_channel, risk_score, resolution, package_path, encrypted_package_path, package_sha256, plain_package_sha256, remote_addr, summary_text, included_files, mail_sent, updated_at, last_activity_at FROM feedbacks`)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "feedbacks: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item db.Feedback
|
||||
var mailSent int
|
||||
if err := rows.Scan(&item.Code, &item.CreatedAt, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, &item.Contact, &item.Body, &item.Status, &item.StatusDetail, &item.Note, &item.PublicReply, &item.HandledBy, &item.Assignee, &item.DueAt, &item.ResolvedAt, &item.ArchivedAt, &item.SLALevel, &item.SourceChannel, &item.RiskScore, &item.Resolution, &item.PackagePath, &item.EncryptedPackagePath, &item.PackageSha256, &item.PlainPackageSha256, &item.RemoteAddr, &item.SummaryText, &item.IncludedFiles, &mailSent, &item.UpdatedAt, &item.LastActivityAt); err != nil {
|
||||
result.Errors = append(result.Errors, "feedback scan: "+err.Error())
|
||||
continue
|
||||
}
|
||||
item.MailSent = mailSent == 1
|
||||
item.PackagePath = s.copyLegacyFeedbackFile(item.PackagePath, item.Code, result)
|
||||
item.EncryptedPackagePath = s.copyLegacyFeedbackFile(item.EncryptedPackagePath, item.Code, result)
|
||||
if err := s.store.InsertFeedback(item); err != nil && !strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
result.Errors = append(result.Errors, "feedback import "+item.Code+": "+err.Error())
|
||||
} else {
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) importOldComments(oldDB *sql.DB, result *Result) {
|
||||
rows, err := oldDB.Query(`SELECT id, feedback_code, author, body, internal, created_at FROM feedback_comments`)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "comments: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var comment db.FeedbackComment
|
||||
var internal int
|
||||
var oldID int64
|
||||
if err := rows.Scan(&oldID, &comment.Code, &comment.Author, &comment.Body, &internal, &comment.CreatedAt); err != nil {
|
||||
result.Errors = append(result.Errors, "comment scan: "+err.Error())
|
||||
continue
|
||||
}
|
||||
comment.Internal = internal == 1
|
||||
if _, err := s.store.InsertFeedbackComment(comment); err == nil {
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) importOldEvents(oldDB *sql.DB, result *Result) {
|
||||
rows, err := oldDB.Query(`SELECT id, feedback_code, event_type, actor, from_value, to_value, message, created_at FROM feedback_events`)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "events: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var event db.LegacyFeedbackEvent
|
||||
if err := rows.Scan(&event.ID, &event.FeedbackCode, &event.EventType, &event.Actor, &event.FromValue, &event.ToValue, &event.Message, &event.CreatedAt); err != nil {
|
||||
result.Errors = append(result.Errors, "event scan: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpsertFeedbackEvent(event); err == nil {
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) importOldTags(oldDB *sql.DB, result *Result) {
|
||||
rows, err := oldDB.Query(`SELECT feedback_code, tag, created_at FROM feedback_tags`)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "tags: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var code, tag, createdAt string
|
||||
if err := rows.Scan(&code, &tag, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpsertFeedbackTag(code, tag, createdAt); err == nil {
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) importOldMail(oldDB *sql.DB, result *Result) {
|
||||
rows, err := oldDB.Query(`SELECT id, feedback_code, kind, status, to_address, subject, attachment_path, attachment_name, error_message, created_at, sent_at FROM mail_records`)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "mail_records: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item db.LegacyMailRecord
|
||||
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Status, &item.ToAddress, &item.Subject, &item.AttachmentPath, &item.AttachmentName, &item.ErrorMessage, &item.CreatedAt, &item.SentAt); err != nil {
|
||||
result.Errors = append(result.Errors, "mail scan: "+err.Error())
|
||||
continue
|
||||
}
|
||||
item.AttachmentPath = s.copyLegacyFeedbackFile(item.AttachmentPath, item.FeedbackCode, result)
|
||||
if err := s.store.UpsertMailRecord(item); err == nil {
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) {
|
||||
rows, err := oldDB.Query(`SELECT id, webhook_name, event, status, attempts, response_code, error_message, payload_sha256, created_at, finished_at FROM webhook_deliveries`)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "webhook_deliveries: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, event, status, message, payload, createdAt, finishedAt string
|
||||
var attempts, response int
|
||||
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: event + " " + message, CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) copyLegacyFeedbackFile(path, code string, result *Result) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
source := path
|
||||
if !filepath.IsAbs(source) {
|
||||
source = filepath.Join(s.cfg.LegacyFeedbackDir, source)
|
||||
}
|
||||
info, err := os.Stat(source)
|
||||
if err != nil || info.IsDir() {
|
||||
return path
|
||||
}
|
||||
target := filepath.Join(s.cfg.StorageDir, "legacy-feedback", safeName(code), filepath.Base(source))
|
||||
if err := copyFile(source, target); err != nil {
|
||||
result.Warnings = append(result.Warnings, "copy attachment: "+err.Error())
|
||||
return path
|
||||
}
|
||||
result.Stats["copiedFiles"]++
|
||||
return target
|
||||
}
|
||||
|
||||
func copyDir(source, target string) error {
|
||||
sourceInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sourceInfo.IsDir() {
|
||||
return errors.New(source + " is not a directory")
|
||||
}
|
||||
return filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(target, rel)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(dest, 0o750)
|
||||
}
|
||||
return copyFile(path, dest)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(source, target string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func safeName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, value)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user