服务端媒体源导入/保存/客户端输出链路修复:支持 snake/camel、subcategories/sources,默认客户端可见,保存后发布兼容 media-types.json。
build-winui / winui (push) Has been cancelled
build-winui / winui (push) Has been cancelled
新增数据库同步 Job API、持久化状态、实时输出、最新任务恢复,以及系统日志聚合接口。 管理端优化:日志中心、运维实时状态框、同步输出自动滚动、仪表盘“输出”列、真实延迟空态、本地 favicon/avatar。 新增 server/unified-management/assets/favicon.ico 和 developer-avatar.png,并接好 /favicon.ico、/admin/favicon.ico、/setup/favicon.ico、/assets/*。 WinUI 随机放映室卡片优先显示子接口原始 Description。 Inno 安装器输出框改为选区末尾 + SendMessage 滚动到底部。
This commit is contained in:
@@ -24,6 +24,7 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.sources.PublishLegacyMediaTypes(req.Context(), "admin")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
|
||||
job := r.sources.QueueCheckAll()
|
||||
@@ -46,8 +47,8 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
||||
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
||||
var item db.Source
|
||||
if err := json.NewDecoder(req.Body).Decode(&item); err != nil {
|
||||
item, err := r.decodeAdminSource(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
@@ -72,3 +73,114 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
type adminSourceRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID string `json:"categoryId"`
|
||||
CategoryIDAlt string `json:"category_id"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CategoryNameAlt string `json:"category_name"`
|
||||
SourceID string `json:"sourceId"`
|
||||
SourceIDAlt string `json:"source_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Method string `json:"method"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
APIURLAlt string `json:"api_url"`
|
||||
URLTemplate string `json:"urlTemplate"`
|
||||
URLTemplateAlt string `json:"url_template"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
ThumbnailURLAlt string `json:"thumbnail_url"`
|
||||
ProxyMode string `json:"proxyMode"`
|
||||
ProxyModeAlt string `json:"proxy_mode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
TimeoutMSAlt int `json:"timeout_ms"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
RetryCountAlt int `json:"retry_count"`
|
||||
CacheSeconds int `json:"cacheSeconds"`
|
||||
CacheSecondsAlt int `json:"cache_seconds"`
|
||||
CheckIntervalSec int `json:"checkIntervalSec"`
|
||||
CheckIntervalSecAlt int `json:"check_interval_sec"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ClientVisible *bool `json:"clientVisible"`
|
||||
ClientVisibleAlt *bool `json:"client_visible"`
|
||||
SupportedFormats []string `json:"supportedFormats"`
|
||||
SupportedFormatsAlt []string `json:"supported_formats"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastStatusAlt string `json:"last_status"`
|
||||
LastLatencyMS int `json:"lastLatencyMs"`
|
||||
LastLatencyMSAlt int `json:"last_latency_ms"`
|
||||
LastCheckedAt string `json:"lastCheckedAt"`
|
||||
LastCheckedAtAlt string `json:"last_checked_at"`
|
||||
LastError string `json:"lastError"`
|
||||
LastErrorAlt string `json:"last_error"`
|
||||
ConsecutiveFailure int `json:"consecutiveFailure"`
|
||||
ConsecutiveFailAlt int `json:"consecutive_failure"`
|
||||
}
|
||||
|
||||
func (r *router) decodeAdminSource(req *http.Request) (db.Source, error) {
|
||||
var body adminSourceRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return db.Source{}, err
|
||||
}
|
||||
sourceID := firstNonEmpty(body.SourceID, body.SourceIDAlt)
|
||||
var existing db.Source
|
||||
hasExisting := false
|
||||
if sourceID != "" {
|
||||
if item, err := r.store.GetSourceBySourceID(sourceID); err == nil {
|
||||
existing = item
|
||||
hasExisting = true
|
||||
}
|
||||
}
|
||||
item := existing
|
||||
item.ID = body.ID
|
||||
item.CategoryID = firstNonEmpty(body.CategoryID, body.CategoryIDAlt, item.CategoryID)
|
||||
item.CategoryName = firstNonEmpty(body.CategoryName, body.CategoryNameAlt, item.CategoryName)
|
||||
item.SourceID = firstNonEmpty(sourceID, item.SourceID)
|
||||
item.Name = firstNonEmpty(body.Name, item.Name)
|
||||
item.Description = body.Description
|
||||
item.Method = firstNonEmpty(body.Method, item.Method)
|
||||
item.APIURL = firstNonEmpty(body.APIURL, body.APIURLAlt, item.APIURL)
|
||||
item.URLTemplate = firstNonEmpty(body.URLTemplate, body.URLTemplateAlt, item.URLTemplate, item.APIURL)
|
||||
item.ThumbnailURL = firstNonEmpty(body.ThumbnailURL, body.ThumbnailURLAlt)
|
||||
item.ProxyMode = firstNonEmpty(body.ProxyMode, body.ProxyModeAlt, item.ProxyMode)
|
||||
item.TimeoutMS = firstPositive(body.TimeoutMS, body.TimeoutMSAlt, item.TimeoutMS)
|
||||
item.RetryCount = firstPositive(body.RetryCount, body.RetryCountAlt, item.RetryCount)
|
||||
item.CacheSeconds = firstPositive(body.CacheSeconds, body.CacheSecondsAlt, item.CacheSeconds)
|
||||
item.CheckIntervalSec = firstPositive(body.CheckIntervalSec, body.CheckIntervalSecAlt, item.CheckIntervalSec)
|
||||
item.LastStatus = firstNonEmpty(body.LastStatus, body.LastStatusAlt, item.LastStatus)
|
||||
item.LastLatencyMS = firstPositive(body.LastLatencyMS, body.LastLatencyMSAlt, item.LastLatencyMS)
|
||||
item.LastCheckedAt = firstNonEmpty(body.LastCheckedAt, body.LastCheckedAtAlt, item.LastCheckedAt)
|
||||
item.LastError = firstNonEmpty(body.LastError, body.LastErrorAlt, item.LastError)
|
||||
item.ConsecutiveFailure = firstPositive(body.ConsecutiveFailure, body.ConsecutiveFailAlt, item.ConsecutiveFailure)
|
||||
if formats := firstNonEmptyStringSlice(body.SupportedFormats, body.SupportedFormatsAlt); len(formats) > 0 {
|
||||
data, _ := json.Marshal(formats)
|
||||
item.SupportedFormats = string(data)
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
item.Enabled = *body.Enabled
|
||||
item.EnabledSet = true
|
||||
} else if hasExisting {
|
||||
item.Enabled = existing.Enabled
|
||||
}
|
||||
visible := body.ClientVisible
|
||||
if visible == nil {
|
||||
visible = body.ClientVisibleAlt
|
||||
}
|
||||
if visible != nil {
|
||||
item.ClientVisible = *visible
|
||||
item.ClientVisibleSet = true
|
||||
} else if hasExisting {
|
||||
item.ClientVisible = existing.ClientVisible
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyStringSlice(values ...[]string) []string {
|
||||
for _, value := range values {
|
||||
if len(value) > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -55,6 +56,40 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/sync/jobs":
|
||||
var body struct {
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
job, err := r.store.QueueDatabaseSync(body.Direction)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "DATABASE_SYNC_RUNNING", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "database.sync.queued", Target: job.Direction, Message: "数据库同步任务已启动", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "jobId": job.ID, "job": job})
|
||||
case req.Method == http.MethodGet && path == "/api/admin/database/sync/jobs/latest":
|
||||
job, err := r.store.LatestDatabaseSyncJob()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/database/sync/jobs/"):
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(path, "/api/admin/database/sync/jobs/"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_JOB_ID", errors.New("invalid sync job id"))
|
||||
return
|
||||
}
|
||||
job, err := r.store.GetDatabaseSyncJob(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "SYNC_JOB_NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||
result, err := r.store.ImportSQLiteToRemote()
|
||||
if err != nil {
|
||||
@@ -218,6 +253,22 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
||||
case "/api/admin/system/logs":
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
page, err := r.store.ListSystemLogsPage(db.SystemLogFilters{
|
||||
Page: queryInt(req, "page", 1),
|
||||
PerPage: queryInt(req, "perPage", 35),
|
||||
Category: req.URL.Query().Get("category"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SYSTEM_LOGS_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
||||
case "/api/admin/system/mail/config":
|
||||
r.handleMailConfig(w, req)
|
||||
case "/api/admin/system/mail/test":
|
||||
|
||||
@@ -71,6 +71,12 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.auth.Require(http.HandlerFunc(r.handleChangePassword)).ServeHTTP(w, req)
|
||||
case path == "/api/client/bootstrap":
|
||||
r.handleClientBootstrap(w, req)
|
||||
case path == "/favicon.ico" || path == "/admin/favicon.ico":
|
||||
r.serveServerAsset(w, req, "favicon.ico")
|
||||
case path == "/assets/favicon.ico":
|
||||
r.serveServerAsset(w, req, "favicon.ico")
|
||||
case path == "/assets/developer-avatar.png":
|
||||
r.serveServerAsset(w, req, "developer-avatar.png")
|
||||
case path == "/api/client/releases" || path == "/api/releases" || path == "/api/update-info":
|
||||
writeJSON(w, http.StatusOK, r.releases.Manifest(req))
|
||||
case path == "/api/client/sources":
|
||||
|
||||
@@ -447,6 +447,31 @@ func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerBrandAssetsAreServed(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, item := range []struct {
|
||||
Path string
|
||||
ContentTypes []string
|
||||
}{
|
||||
{Path: "/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}},
|
||||
{Path: "/admin/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}},
|
||||
{Path: "/assets/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}},
|
||||
{Path: "/assets/developer-avatar.png", ContentTypes: []string{"image/png"}},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, item.Path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("%s returned %d: %s", item.Path, res.Code, res.Body.String())
|
||||
}
|
||||
if got := res.Header().Get("Content-Type"); !containsAny(got, item.ContentTypes) {
|
||||
t.Fatalf("%s content type = %q, want one of %v", item.Path, got, item.ContentTypes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -720,9 +745,19 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
root := t.TempDir()
|
||||
public := filepath.Join(root, "public")
|
||||
noticeDir := filepath.Join(root, "update-notice")
|
||||
assetDir := filepath.Join(root, "assets")
|
||||
if err := os.MkdirAll(filepath.Join(public, "downloads"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(assetDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetDir, "favicon.ico"), []byte("ico"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetDir, "developer-avatar.png"), []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminDist := filepath.Join(root, "admin")
|
||||
portalDist := filepath.Join(root, "portal")
|
||||
for _, dir := range []string{filepath.Join(adminDist, "assets"), filepath.Join(portalDist, "assets")} {
|
||||
|
||||
@@ -17,10 +17,10 @@ type setupRouter struct {
|
||||
}
|
||||
|
||||
type setupRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
SQLitePath string `json:"sqlitePath"`
|
||||
MySQLDSN string `json:"mysqlDsn"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
SQLitePath string `json:"sqlitePath"`
|
||||
MySQLDSN string `json:"mysqlDsn"`
|
||||
MySQL config.MySQLInput `json:"mysql"`
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ func (r *setupRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case path == "/" || path == "/setup":
|
||||
r.serveSetup(w, req)
|
||||
case path == "/favicon.ico" || path == "/setup/favicon.ico":
|
||||
serveSetupServerAsset(w, req, r.cfg.BaseDir, "favicon.ico")
|
||||
case path == "/assets/favicon.ico":
|
||||
serveSetupServerAsset(w, req, r.cfg.BaseDir, "favicon.ico")
|
||||
case path == "/assets/developer-avatar.png":
|
||||
serveSetupServerAsset(w, req, r.cfg.BaseDir, "developer-avatar.png")
|
||||
case strings.HasPrefix(path, "/setup/assets/"):
|
||||
serveStaticAsset(w, req, r.cfg.SetupWebDir, "setup/dist", strings.TrimPrefix(path, "/setup/"))
|
||||
case path == "/api/setup/status":
|
||||
|
||||
@@ -47,6 +47,14 @@ func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot,
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
|
||||
serveStaticAsset(w, req, filepath.Join(r.cfg.BaseDir, "assets"), "", assetPath)
|
||||
}
|
||||
|
||||
func serveSetupServerAsset(w http.ResponseWriter, req *http.Request, cfgRoot, assetPath string) {
|
||||
serveStaticAsset(w, req, filepath.Join(cfgRoot, "assets"), "", assetPath)
|
||||
}
|
||||
|
||||
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
||||
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
||||
resolved, err := filepath.Abs(path)
|
||||
|
||||
Reference in New Issue
Block a user