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.displayPath(s.cfg.LegacyUpdateDir), "legacyFeedbackDir": s.displayPath(s.cfg.LegacyFeedbackDir), "legacyUpdateNoticeDir": s.displayPath(s.cfg.LegacyUpdateNoticeDir), "updatePublicDir": s.displayPath(s.cfg.UpdatePublicDir), "updateNoticeDir": s.displayPath(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("旧项目同步完成:成功=%v,复制文件=%d,导入记录=%d,错误=%d", result.Ok, result.Stats["copiedFiles"], result.Stats["importedRows"], len(result.Errors))}) return result } func (s *Service) displayPath(path string) string { if strings.TrimSpace(path) == "" { return "" } rel, err := filepath.Rel(s.cfg.BaseDir, path) if err != nil || rel == "" { return path } return filepath.ToSlash(rel) } 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: "旧反馈 Webhook 记录:" + strings.TrimSpace(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 "" }