7745e7a2d4
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 滚动到底部。
187 lines
8.2 KiB
Go
187 lines
8.2 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"ymhut-box/server/unified-management/internal/db"
|
|
)
|
|
|
|
func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
|
path := cleanPath(req.URL.Path)
|
|
switch {
|
|
case req.Method == http.MethodGet && path == "/api/admin/sources":
|
|
catalog, err := r.sources.Catalog(true)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "catalog": catalog})
|
|
case req.Method == http.MethodPost && path == "/api/admin/sources/import-media-types":
|
|
if err := r.sources.ImportLegacyMediaTypes(req.Context()); err != nil {
|
|
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()
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true, "jobId": job.ID, "job": job})
|
|
case req.Method == http.MethodGet && path == "/api/admin/sources/check/status":
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": r.sources.CheckJobs()})
|
|
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/sources/check/status/"):
|
|
jobID := strings.TrimPrefix(path, "/api/admin/sources/check/status/")
|
|
if job, ok := r.sources.CheckJob(jobID); ok {
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "CHECK_JOB_NOT_FOUND", errors.New("check job not found"))
|
|
case req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/sources/") && strings.HasSuffix(path, "/check"):
|
|
sourceID := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/sources/"), "/check")
|
|
item, err := r.sources.CheckSourceID(req.Context(), sourceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "CHECK_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
|
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
|
item, err := r.decodeAdminSource(req)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
|
return
|
|
}
|
|
saved, err := r.store.UpsertSource(item)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
|
|
return
|
|
}
|
|
_ = r.sources.PublishLegacyMediaTypes(req.Context(), "admin")
|
|
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
|
|
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "source.saved", Target: saved.SourceID, Message: "客户端接口已保存并同步兼容 media-types.json", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
|
|
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
|
|
sourceID := strings.TrimPrefix(path, "/api/admin/sources/")
|
|
if err := r.sources.DeleteSourceAndPublishCompatibility(req.Context(), sourceID, "admin"); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
|
|
return
|
|
}
|
|
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
default:
|
|
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
|
|
}
|