更新 unified management 服务逻辑
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:57:58 +08:00
parent 79bdc34664
commit df6e9ab9e9
10 changed files with 459 additions and 13 deletions
@@ -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)
}
@@ -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)
@@ -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":
@@ -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)
}
@@ -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 ""
}
@@ -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")
}
}
@@ -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
@@ -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
}
@@ -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"]++
}
}
@@ -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":