From df6e9ab9e915eaa60872310634ff3d8a95a3808f Mon Sep 17 00:00:00 2001 From: QWQLwToo <2467013926@qq.com> Date: Fri, 26 Jun 2026 13:57:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20unified=20management=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/unified-management/app/app.go | 3 + .../unified-management/internal/db/store.go | 5 +- .../internal/legacy/legacy.go | 58 ++++++- .../internal/notices/notices.go | 2 +- .../internal/releases/releases.go | 146 ++++++++++++++++++ .../internal/releases/releases_test.go | 76 ++++++++- .../internal/sources/sources.go | 62 +++++++- .../internal/sources/sources_test.go | 81 ++++++++++ .../internal/synclegacy/synclegacy.go | 4 +- .../unified-management/internal/web/router.go | 35 ++++- 10 files changed, 459 insertions(+), 13 deletions(-) create mode 100644 server/unified-management/internal/sources/sources_test.go diff --git a/server/unified-management/cmd/unified-management/app/app.go b/server/unified-management/cmd/unified-management/app/app.go index ac1568d..d414ec6 100644 --- a/server/unified-management/cmd/unified-management/app/app.go +++ b/server/unified-management/cmd/unified-management/app/app.go @@ -60,6 +60,9 @@ func Run() { legacySyncService := synclegacy.New(cfg, store, noticeService) authService := auth.NewService(store) + if err := legacyService.EnsureSeedDocuments(context.Background()); err != nil { + log.Printf("legacy json seed skipped: %v", err) + } if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil { log.Printf("legacy media source import skipped: %v", err) } diff --git a/server/unified-management/internal/db/store.go b/server/unified-management/internal/db/store.go index 2996698..a6cbd08 100644 --- a/server/unified-management/internal/db/store.go +++ b/server/unified-management/internal/db/store.go @@ -1147,7 +1147,7 @@ func (s *Store) UpdateFeedbackTicket(code string, update FeedbackUpdate) error { sanitizeLong(update.Note, 3000), sanitize(update.Assignee), sanitize(update.HandledBy), update.DueAt, update.SLALevel, sanitizeLong(update.Resolution, 3000), string(tagsJSON), now, now, code) if err == nil { - _ = s.InsertAudit(AuditLog{Actor: firstNonEmpty(update.Actor, "admin"), Type: "feedback.updated", Target: code, Message: "Feedback updated"}) + _ = s.InsertAudit(AuditLog{Actor: firstNonEmpty(update.Actor, "admin"), Type: "feedback.updated", Target: code, Message: "反馈工单已更新"}) } return err } @@ -1416,6 +1416,9 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, if status == "ok" { _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = '', consecutive_failure = 0, updated_at = ? WHERE id = ?`, status, latency, now, now, sourceDBID) + } else if status == "redirected" { + _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`, + status, latency, now, sanitize(message), now, sourceDBID) } else { _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = consecutive_failure + 1, updated_at = ? WHERE id = ?`, status, latency, now, sanitize(message), now, sourceDBID) diff --git a/server/unified-management/internal/legacy/legacy.go b/server/unified-management/internal/legacy/legacy.go index 41d464c..74011c4 100644 --- a/server/unified-management/internal/legacy/legacy.go +++ b/server/unified-management/internal/legacy/legacy.go @@ -38,6 +38,49 @@ 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 { @@ -97,7 +140,7 @@ func (s *Service) Save(ctx context.Context, name string, req SaveRequest, actor 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: "Legacy JSON saved"}) + _ = 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 } @@ -124,11 +167,22 @@ func (s *Service) Restore(ctx context.Context, name string, revisionID int64, ac 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: "Legacy JSON restored"}) + _ = 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": diff --git a/server/unified-management/internal/notices/notices.go b/server/unified-management/internal/notices/notices.go index ce6a224..4eaa04a 100644 --- a/server/unified-management/internal/notices/notices.go +++ b/server/unified-management/internal/notices/notices.go @@ -135,7 +135,7 @@ func (s *Service) Save(ctx context.Context, version string, req SaveRequest, act 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: "Release notice saved"}) + _ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.saved", Target: saved.Version, Message: "版本日志已保存并同步兼容更新信息"}) return s.Get(saved.Version) } diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 7a5fb5b..436e0e3 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -4,6 +4,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" + "fmt" "io" "net/http" "os" @@ -40,6 +42,16 @@ type Package struct { UpdatedAt string `json:"updatedAt"` } +type UploadOptions struct { + FileName string + Version string + Platform string + Arch string + Channel string + Notes string + UpdateManifest bool +} + func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service { service := &Service{cfg: cfg, store: store} if len(noticeService) > 0 { @@ -162,6 +174,103 @@ func (s *Service) StaticJSON(name string) map[string]any { return readJSON(filepath.Join(s.cfg.UpdatePublicDir, name)) } +func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts UploadOptions, actor string) (Package, error) { + name, err := safePackageName(opts.FileName) + if err != nil { + return Package{}, err + } + if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil { + return Package{}, err + } + target := filepath.Join(s.cfg.DownloadsDir, name) + resolved, err := filepath.Abs(target) + if err != nil { + return Package{}, err + } + base, _ := filepath.Abs(s.cfg.DownloadsDir) + if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) { + return Package{}, errors.New("path escape rejected") + } + tmp, err := os.CreateTemp(s.cfg.DownloadsDir, "."+name+".*.upload") + if err != nil { + return Package{}, err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + hash := sha256.New() + written, err := io.Copy(tmp, io.TeeReader(reader, hash)) + if closeErr := tmp.Close(); err == nil { + err = closeErr + } + if err != nil { + return Package{}, err + } + if written <= 0 { + return Package{}, errors.New("uploaded file is empty") + } + if err := os.Chmod(tmpName, 0o640); err != nil { + return Package{}, err + } + if err := os.Rename(tmpName, target); err != nil { + return Package{}, err + } + version := firstNonEmpty(opts.Version, detectVersion(name)) + platform, arch := detectPlatform(name) + platform = firstNonEmpty(opts.Platform, platform) + arch = firstNonEmpty(opts.Arch, arch) + product := detectProduct(name) + pkg := Package{ + ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")), + Name: product, + Version: version, + Platform: platform, + Arch: arch, + URL: requestBaseURL(r, s.cfg.BaseURL) + "/downloads/" + name, + SHA256: hex.EncodeToString(hash.Sum(nil)), + Size: written, + Required: strings.Contains(strings.ToLower(product), "ymhut"), + Enabled: true, + FileName: name, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + if opts.UpdateManifest { + if err := s.updateLegacyManifest(pkg, opts); err != nil { + return Package{}, err + } + } + _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(written))}) + return pkg, nil +} + +func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error { + path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") + payload := readJSON(path) + payload["app_version"] = pkg.Version + payload["download_url"] = pkg.URL + payload["package_sha256"] = pkg.SHA256 + payload["package_size"] = pkg.Size + payload["updated_at"] = time.Now().UTC().Format(time.RFC3339) + payload["download_mirrors"] = []map[string]any{{ + "id": "primary", + "name": "官方直连", + "url": pkg.URL, + "type": "direct", + "sha256": pkg.SHA256, + "enabled": true, + }} + if strings.TrimSpace(opts.Notes) != "" { + payload["release_notes"] = opts.Notes + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o640) +} + func readJSON(path string) map[string]any { data, err := os.ReadFile(path) if err != nil { @@ -257,3 +366,40 @@ func sha256File(path string) string { } return hex.EncodeToString(hash.Sum(nil)) } + +func safePackageName(name string) (string, error) { + name = strings.TrimSpace(filepath.Base(name)) + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + return "", errors.New("invalid filename") + } + lower := strings.ToLower(name) + for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} { + if strings.HasSuffix(lower, suffix) { + return name, nil + } + } + return "", errors.New("unsupported package extension") +} + +func formatBytes(value int64) string { + if value < 1024 { + return fmt.Sprintf("%d B", value) + } + next := float64(value) / 1024 + for _, unit := range []string{"KB", "MB", "GB"} { + if next < 1024 || unit == "GB" { + return fmt.Sprintf("%.1f %s", next, unit) + } + next /= 1024 + } + return fmt.Sprintf("%d B", value) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/server/unified-management/internal/releases/releases_test.go b/server/unified-management/internal/releases/releases_test.go index e248382..dc805a0 100644 --- a/server/unified-management/internal/releases/releases_test.go +++ b/server/unified-management/internal/releases/releases_test.go @@ -1,6 +1,15 @@ package releases -import "testing" +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "ymhut-box/server/unified-management/internal/config" + "ymhut-box/server/unified-management/internal/db" +) func TestCompareVersion(t *testing.T) { cases := []struct { @@ -29,3 +38,68 @@ func TestDetectPackageMetadata(t *testing.T) { t.Fatalf("detectVersion returned %q", version) } } + +func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + BaseDir: dir, + StorageDir: filepath.Join(dir, "storage"), + DataDir: filepath.Join(dir, "data"), + UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), + DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), + BaseURL: "https://update.ymhut.cn", + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + }, + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(cfg, store) + req := httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil) + pkg, err := service.SaveUploadedPackage(req, strings.NewReader("package bytes"), UploadOptions{ + FileName: "YMhut_Box_WinUI_Setup_2.0.6.31.exe", + UpdateManifest: true, + }, "admin") + if err != nil { + t.Fatal(err) + } + if pkg.Version != "2.0.6.31" || pkg.SHA256 == "" || pkg.Size == 0 { + t.Fatalf("unexpected package metadata: %#v", pkg) + } + if _, err := os.Stat(filepath.Join(cfg.DownloadsDir, pkg.FileName)); err != nil { + t.Fatal(err) + } + manifest := readJSON(filepath.Join(cfg.UpdatePublicDir, "update-info.json")) + if manifest["download_url"] != pkg.URL || manifest["package_sha256"] != pkg.SHA256 { + t.Fatalf("manifest not updated: %#v", manifest) + } +} + +func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + BaseDir: dir, + StorageDir: filepath.Join(dir, "storage"), + DataDir: filepath.Join(dir, "data"), + UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), + DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + }, + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(cfg, store) + _, err = service.SaveUploadedPackage(httptest.NewRequest("POST", "/", nil), strings.NewReader("x"), UploadOptions{FileName: "../evil.exe"}, "admin") + if err == nil { + t.Fatal("expected unsafe filename to be rejected") + } +} diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index fb31f6d..f32430c 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -168,6 +169,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { "last_checked_at": item.LastCheckedAt, "last_error": item.LastError, "consecutiveFailure": item.ConsecutiveFailure, + "meta": parseHealthMeta(item.LastError), }, } cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub) @@ -209,6 +211,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { "lastCheckedAt": item.LastCheckedAt, "lastError": item.LastError, "consecutiveFailure": item.ConsecutiveFailure, + "meta": parseHealthMeta(item.LastError), }, }) } @@ -252,13 +255,30 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error { } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, item.Method, item.APIURL, nil) + method := strings.TrimSpace(item.Method) + if method == "" { + method = http.MethodGet + } + req, err := http.NewRequestWithContext(ctx, method, item.APIURL, nil) if err != nil { _ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error()) return err } + redirects := []string{} + client := *s.client + client.Timeout = timeout + client.CheckRedirect = func(next *http.Request, via []*http.Request) error { + if next.URL == nil || !isHTTPURL(next.URL) { + return errors.New("redirect target must be http or https") + } + redirects = append(redirects, next.URL.String()) + if len(via) >= 5 { + return errors.New("too many redirects") + } + return nil + } start := time.Now() - resp, err := s.client.Do(req) + resp, err := client.Do(req) latency := int(time.Since(start).Milliseconds()) if err != nil { _ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error()) @@ -267,13 +287,49 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error { defer resp.Body.Close() status := "ok" message := "" + if len(redirects) > 0 { + status = "redirected" + message = healthMetaMessage(map[string]any{ + "redirected": true, + "redirectCount": len(redirects), + "finalUrl": resp.Request.URL.String(), + "finalStatus": resp.StatusCode, + }) + } if resp.StatusCode >= 400 { status = "degraded" - message = resp.Status + message = healthMetaMessage(map[string]any{ + "redirected": len(redirects) > 0, + "redirectCount": len(redirects), + "finalUrl": resp.Request.URL.String(), + "finalStatus": resp.StatusCode, + "error": resp.Status, + }) } return s.store.RecordSourceCheck(item.ID, status, latency, message) } +func isHTTPURL(value *url.URL) bool { + scheme := strings.ToLower(value.Scheme) + return scheme == "http" || scheme == "https" +} + +func healthMetaMessage(meta map[string]any) string { + data, err := json.Marshal(meta) + if err != nil { + return "" + } + return string(data) +} + +func parseHealthMeta(message string) map[string]any { + var meta map[string]any + if strings.TrimSpace(message) == "" || json.Unmarshal([]byte(message), &meta) != nil { + return map[string]any{} + } + return meta +} + func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback diff --git a/server/unified-management/internal/sources/sources_test.go b/server/unified-management/internal/sources/sources_test.go new file mode 100644 index 0000000..b761ab1 --- /dev/null +++ b/server/unified-management/internal/sources/sources_test.go @@ -0,0 +1,81 @@ +package sources + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "ymhut-box/server/unified-management/internal/config" + "ymhut-box/server/unified-management/internal/db" +) + +func TestCheckOneTreatsRedirectToOKAsRedirected(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + defer target.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirector.Close() + + cfg, store := testStore(t) + service := NewService(cfg, store) + item, err := store.UpsertSource(db.Source{ + CategoryID: "test", + CategoryName: "Test", + SourceID: "redirect", + Name: "Redirect", + Method: "GET", + APIURL: redirector.URL, + TimeoutMS: 3000, + CheckIntervalSec: 300, + Enabled: true, + ClientVisible: true, + }) + if err != nil { + t.Fatal(err) + } + if err := service.CheckOne(context.Background(), item); err != nil { + t.Fatal(err) + } + checked, err := store.GetSourceBySourceID("redirect") + if err != nil { + t.Fatal(err) + } + if checked.LastStatus != "redirected" { + t.Fatalf("LastStatus = %q, want redirected", checked.LastStatus) + } + if !strings.Contains(checked.LastError, `"redirected":true`) { + t.Fatalf("LastError does not contain redirect metadata: %s", checked.LastError) + } + if checked.ConsecutiveFailure != 0 { + t.Fatalf("ConsecutiveFailure = %d, want 0", checked.ConsecutiveFailure) + } +} + +func testStore(t *testing.T) (*config.Config, *db.Store) { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + BaseDir: dir, + StorageDir: filepath.Join(dir, "storage"), + DataDir: filepath.Join(dir, "data"), + UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), + DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), + BaseURL: "https://update.ymhut.cn", + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + }, + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return cfg, store +} diff --git a/server/unified-management/internal/synclegacy/synclegacy.go b/server/unified-management/internal/synclegacy/synclegacy.go index 3c0a06c..f6964bf 100644 --- a/server/unified-management/internal/synclegacy/synclegacy.go +++ b/server/unified-management/internal/synclegacy/synclegacy.go @@ -80,7 +80,7 @@ func (s *Service) run(ctx context.Context, dryRun bool) 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))}) + _ = 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 } @@ -294,7 +294,7 @@ func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) { 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())}) + _ = 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"]++ } } diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go index 23001f7..2baf041 100644 --- a/server/unified-management/internal/web/router.go +++ b/server/unified-management/internal/web/router.go @@ -177,7 +177,7 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) { return } auth.SetSessionCookie(w, sessionID) - _ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "Admin login", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + _ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}}) } @@ -199,7 +199,7 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request) writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err) return } - _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "Admin password changed", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } @@ -319,7 +319,7 @@ func (r *router) handleFeedbackSubmit(w http.ResponseWriter, req *http.Request) writeError(w, http.StatusBadRequest, "FEEDBACK_FAILED", err) return } - _ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.created", Target: item.Code, Message: item.Title, IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + _ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.created", Target: item.Code, Message: "客户端提交反馈:" + item.Title, IP: req.RemoteAddr, UserAgent: req.UserAgent()}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "code": item.Code}) } @@ -639,6 +639,35 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) { return } switch path { + case "/api/admin/releases/packages": + if req.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) + return + } + if err := req.ParseMultipartForm(256 << 20); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err) + return + } + file, header, err := req.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err) + return + } + defer file.Close() + pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{ + FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename), + Version: req.FormValue("version"), + Platform: req.FormValue("platform"), + Arch: req.FormValue("arch"), + Channel: req.FormValue("channel"), + Notes: req.FormValue("notes"), + UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1", + }, "admin") + if err != nil { + writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg}) case "/api/admin/releases": writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)}) case "/api/admin/releases/legacy-preview":