diff --git a/server/unified-management/internal/config/branding.go b/server/unified-management/internal/config/branding.go new file mode 100644 index 0000000..f49620a --- /dev/null +++ b/server/unified-management/internal/config/branding.go @@ -0,0 +1,48 @@ +package config + +import "strings" + +type SafeBrandingConfig struct { + SiteIconURL string `json:"siteIconUrl"` + DeveloperAvatarURL string `json:"developerAvatarUrl"` + DeveloperName string `json:"developerName"` + FeedbackEmail string `json:"feedbackEmail"` +} + +func SafeBranding(cfg BrandingConfig) SafeBrandingConfig { + return SafeBrandingConfig{ + SiteIconURL: strings.TrimSpace(cfg.SiteIconURL), + DeveloperAvatarURL: strings.TrimSpace(cfg.DeveloperAvatarURL), + DeveloperName: strings.TrimSpace(firstNonEmpty(cfg.DeveloperName, "YMhut")), + FeedbackEmail: strings.TrimSpace(firstNonEmpty(cfg.FeedbackEmail, "support@ymhut.cn")), + } +} + +func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) BrandingConfig { + next := current + if value := strings.TrimSpace(incoming.SiteIconURL); value != "" { + next.SiteIconURL = value + } + if value := strings.TrimSpace(incoming.DeveloperAvatarURL); value != "" { + next.DeveloperAvatarURL = value + } + if value := strings.TrimSpace(incoming.DeveloperName); value != "" { + next.DeveloperName = value + } + if value := strings.TrimSpace(incoming.FeedbackEmail); value != "" { + next.FeedbackEmail = value + } + if next.SiteIconURL == "" { + next.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp" + } + if next.DeveloperAvatarURL == "" { + next.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp" + } + if next.DeveloperName == "" { + next.DeveloperName = "YMhut" + } + if next.FeedbackEmail == "" { + next.FeedbackEmail = "support@ymhut.cn" + } + return next +} diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go index cb821bd..29d9991 100644 --- a/server/unified-management/internal/config/config.go +++ b/server/unified-management/internal/config/config.go @@ -36,6 +36,8 @@ type Config struct { MaxRequestBytes int64 `json:"max_request_bytes"` MaxPackageBytes int64 `json:"max_package_bytes"` Database DatabaseConfig `json:"database"` + Mail MailConfig `json:"mail"` + Branding BrandingConfig `json:"branding"` UploadGuard UploadGuardConfig `json:"upload_guard"` SourceCheckSeconds int `json:"source_check_seconds"` } @@ -44,6 +46,11 @@ type DatabaseConfig struct { Provider string `json:"provider"` SQLitePath string `json:"sqlite_path"` MySQLDSN string `json:"mysql_dsn"` + MySQLHost string `json:"mysql_host"` + MySQLPort int `json:"mysql_port"` + MySQLDatabase string `json:"mysql_database"` + MySQLUser string `json:"mysql_user"` + MySQLPassword string `json:"mysql_password"` FailoverEnabled bool `json:"failover_enabled"` HotSyncEnabled bool `json:"hot_sync_enabled"` HealthIntervalSec int `json:"health_interval_sec"` @@ -52,6 +59,25 @@ type DatabaseConfig struct { ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"` } +type MailConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Secure string `json:"secure"` + Username string `json:"username"` + Password string `json:"password"` + FromAddress string `json:"from_address"` + FromName string `json:"from_name"` + DeveloperAddress string `json:"developer_address"` + TimeoutSeconds int `json:"timeout_seconds"` +} + +type BrandingConfig struct { + SiteIconURL string `json:"site_icon_url"` + DeveloperAvatarURL string `json:"developer_avatar_url"` + DeveloperName string `json:"developer_name"` + FeedbackEmail string `json:"feedback_email"` +} + type UploadGuardConfig struct { MaxZipFiles int `json:"max_zip_files"` MaxDecompressedBytes int64 `json:"max_decompressed_bytes"` @@ -120,6 +146,8 @@ func defaults(root string) *Config { Database: DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), + MySQLHost: "127.0.0.1", + MySQLPort: 3306, FailoverEnabled: true, HotSyncEnabled: true, HealthIntervalSec: 30, @@ -127,6 +155,19 @@ func defaults(root string) *Config { MaxIdleConns: 4, ConnMaxLifetimeSeconds: 300, }, + Mail: MailConfig{ + Port: 465, + Secure: "ssl", + FromName: "YMhut Box Feedback", + DeveloperAddress: "support@ymhut.cn", + TimeoutSeconds: 20, + }, + Branding: BrandingConfig{ + SiteIconURL: "https://img.ymhut.cn/file/1782108850041_icon.webp", + DeveloperAvatarURL: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp", + DeveloperName: "YMhut", + FeedbackEmail: "support@ymhut.cn", + }, UploadGuard: UploadGuardConfig{ MaxZipFiles: 80, MaxDecompressedBytes: 30 * 1024 * 1024, @@ -184,6 +225,66 @@ func applyEnv(cfg *Config) { if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" { cfg.Database.MySQLDSN = value } + if value := os.Getenv("YMHUT_MYSQL_HOST"); value != "" { + cfg.Database.MySQLHost = value + } + if value := os.Getenv("YMHUT_MYSQL_PORT"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Database.MySQLPort = parsed + } + } + if value := os.Getenv("YMHUT_MYSQL_DATABASE"); value != "" { + cfg.Database.MySQLDatabase = value + } + if value := os.Getenv("YMHUT_MYSQL_USER"); value != "" { + cfg.Database.MySQLUser = value + } + if value := os.Getenv("YMHUT_MYSQL_PASSWORD"); value != "" { + cfg.Database.MySQLPassword = value + } + if value := os.Getenv("YMHUT_MAIL_HOST"); value != "" { + cfg.Mail.Host = value + } + if value := os.Getenv("YMHUT_MAIL_PORT"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Mail.Port = parsed + } + } + if value := os.Getenv("YMHUT_MAIL_SECURE"); value != "" { + cfg.Mail.Secure = value + } + if value := os.Getenv("YMHUT_MAIL_USERNAME"); value != "" { + cfg.Mail.Username = value + } + if value := os.Getenv("YMHUT_MAIL_PASSWORD"); value != "" { + cfg.Mail.Password = value + } + if value := os.Getenv("YMHUT_MAIL_FROM_ADDRESS"); value != "" { + cfg.Mail.FromAddress = value + } + if value := os.Getenv("YMHUT_MAIL_FROM_NAME"); value != "" { + cfg.Mail.FromName = value + } + if value := os.Getenv("YMHUT_MAIL_DEVELOPER_ADDRESS"); value != "" { + cfg.Mail.DeveloperAddress = value + } + if value := os.Getenv("YMHUT_MAIL_TIMEOUT_SECONDS"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + cfg.Mail.TimeoutSeconds = parsed + } + } + if value := os.Getenv("YMHUT_BRAND_ICON_URL"); value != "" { + cfg.Branding.SiteIconURL = value + } + if value := os.Getenv("YMHUT_BRAND_DEVELOPER_AVATAR_URL"); value != "" { + cfg.Branding.DeveloperAvatarURL = value + } + if value := os.Getenv("YMHUT_BRAND_DEVELOPER_NAME"); value != "" { + cfg.Branding.DeveloperName = value + } + if value := os.Getenv("YMHUT_BRAND_FEEDBACK_EMAIL"); value != "" { + cfg.Branding.FeedbackEmail = value + } if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" { cfg.ClientSignatureKey = value } @@ -267,10 +368,30 @@ func normalize(root string, cfg *Config) { if cfg.Database.Provider == "" { cfg.Database.Provider = "sqlite" } + cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider)) if cfg.Database.SQLitePath == "" { cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite") } cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath) + if cfg.Database.MySQLHost == "" { + cfg.Database.MySQLHost = "127.0.0.1" + } + if cfg.Database.MySQLPort <= 0 { + cfg.Database.MySQLPort = 3306 + } + if cfg.Database.Provider == "mysql" && cfg.Database.MySQLDSN == "" && cfg.Database.MySQLDatabase != "" && cfg.Database.MySQLUser != "" { + if dsn, err := BuildMySQLDSN(MySQLInput{ + Host: cfg.Database.MySQLHost, + Port: cfg.Database.MySQLPort, + Database: cfg.Database.MySQLDatabase, + Username: cfg.Database.MySQLUser, + Password: cfg.Database.MySQLPassword, + Charset: "utf8mb4", + ParseTime: true, + }); err == nil { + cfg.Database.MySQLDSN = dsn + } + } if cfg.Database.HealthIntervalSec <= 0 { cfg.Database.HealthIntervalSec = 30 } @@ -316,6 +437,37 @@ func normalize(root string, cfg *Config) { if cfg.SourceCheckSeconds <= 0 { cfg.SourceCheckSeconds = 300 } + if cfg.Mail.Port <= 0 { + cfg.Mail.Port = 465 + } + cfg.Mail.Secure = strings.ToLower(strings.TrimSpace(cfg.Mail.Secure)) + if cfg.Mail.Secure == "" { + cfg.Mail.Secure = "ssl" + } + if cfg.Mail.FromName == "" { + cfg.Mail.FromName = "YMhut Box Feedback" + } + if cfg.Mail.FromAddress == "" { + cfg.Mail.FromAddress = cfg.Mail.Username + } + if cfg.Mail.TimeoutSeconds <= 0 { + cfg.Mail.TimeoutSeconds = 20 + } + if cfg.Branding.SiteIconURL == "" { + cfg.Branding.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp" + } + if cfg.Branding.DeveloperAvatarURL == "" { + cfg.Branding.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp" + } + if cfg.Branding.DeveloperName == "" { + cfg.Branding.DeveloperName = "YMhut" + } + if cfg.Branding.FeedbackEmail == "" { + cfg.Branding.FeedbackEmail = "support@ymhut.cn" + } + if cfg.Mail.DeveloperAddress == "" { + cfg.Mail.DeveloperAddress = cfg.Branding.FeedbackEmail + } } func ResolveBaseDir() (string, error) { diff --git a/server/unified-management/internal/config/database.go b/server/unified-management/internal/config/database.go new file mode 100644 index 0000000..03f040c --- /dev/null +++ b/server/unified-management/internal/config/database.go @@ -0,0 +1,189 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" +) + +type MySQLInput struct { + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + Charset string `json:"charset"` + ParseTime bool `json:"parseTime"` + TLS string `json:"tls"` +} + +type SafeDatabaseConfig struct { + Provider string `json:"provider"` + SQLitePath string `json:"sqlitePath"` + MySQLDSN string `json:"mysqlDsn"` + MySQLHost string `json:"mysqlHost"` + MySQLPort int `json:"mysqlPort"` + MySQLDatabase string `json:"mysqlDatabase"` + MySQLUser string `json:"mysqlUser"` + HasPassword bool `json:"hasPassword"` +} + +func BuildMySQLDSN(input MySQLInput) (string, error) { + host := strings.TrimSpace(input.Host) + if host == "" { + host = "127.0.0.1" + } + port := input.Port + if port <= 0 { + port = 3306 + } + database := strings.TrimSpace(input.Database) + username := strings.TrimSpace(input.Username) + if database == "" { + return "", errors.New("mysql database is required") + } + if username == "" { + return "", errors.New("mysql username is required") + } + params := url.Values{} + params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4")) + params.Set("parseTime", strconv.FormatBool(input.ParseTime)) + if tls := strings.TrimSpace(input.TLS); tls != "" { + params.Set("tls", tls) + } + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil +} + +func NormalizeDatabase(baseDir string, current DatabaseConfig, incoming DatabaseConfig, keepPassword bool) (DatabaseConfig, error) { + next := current + structuredChanged := false + if incoming.Provider != "" { + next.Provider = strings.ToLower(strings.TrimSpace(incoming.Provider)) + } + if next.Provider == "" { + next.Provider = "sqlite" + } + if incoming.SQLitePath != "" { + next.SQLitePath = incoming.SQLitePath + } + if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") { + next.SQLitePath = filepath.Join(baseDir, next.SQLitePath) + } + if incoming.MySQLHost != "" { + next.MySQLHost = strings.TrimSpace(incoming.MySQLHost) + structuredChanged = true + } + if incoming.MySQLPort > 0 { + next.MySQLPort = incoming.MySQLPort + structuredChanged = true + } + if incoming.MySQLDatabase != "" { + next.MySQLDatabase = strings.TrimSpace(incoming.MySQLDatabase) + structuredChanged = true + } + if incoming.MySQLUser != "" { + next.MySQLUser = strings.TrimSpace(incoming.MySQLUser) + structuredChanged = true + } + if incoming.MySQLPassword != "" || !keepPassword { + next.MySQLPassword = incoming.MySQLPassword + structuredChanged = true + } + if incoming.MySQLDSN != "" { + next.MySQLDSN = strings.TrimSpace(incoming.MySQLDSN) + } + if next.MySQLHost == "" { + next.MySQLHost = "127.0.0.1" + } + if next.MySQLPort <= 0 { + next.MySQLPort = 3306 + } + if next.Provider == "sqlite" { + next.MySQLDSN = "" + } else if next.Provider == "mysql" { + if structuredChanged || next.MySQLDSN == "" { + dsn, err := BuildMySQLDSN(MySQLInput{ + Host: next.MySQLHost, + Port: next.MySQLPort, + Database: next.MySQLDatabase, + Username: next.MySQLUser, + Password: next.MySQLPassword, + Charset: "utf8mb4", + ParseTime: true, + }) + if err != nil { + return DatabaseConfig{}, err + } + next.MySQLDSN = dsn + } + if strings.TrimSpace(next.MySQLDSN) == "" { + return DatabaseConfig{}, errors.New("mysql connection is required") + } + } else { + return DatabaseConfig{}, errors.New("provider must be sqlite or mysql") + } + if strings.TrimSpace(next.SQLitePath) == "" { + return DatabaseConfig{}, errors.New("sqlite path is required") + } + if next.MaxOpenConns <= 0 { + next.MaxOpenConns = 10 + } + if next.MaxIdleConns <= 0 { + next.MaxIdleConns = 4 + } + if next.ConnMaxLifetimeSeconds <= 0 { + next.ConnMaxLifetimeSeconds = 300 + } + if next.HealthIntervalSec <= 0 { + next.HealthIntervalSec = 30 + } + return next, nil +} + +func SafeDatabase(baseDir string, cfg DatabaseConfig) SafeDatabaseConfig { + return SafeDatabaseConfig{ + Provider: firstNonEmpty(cfg.Provider, "sqlite"), + SQLitePath: relativeToBase(baseDir, cfg.SQLitePath), + MySQLDSN: MaskDSN(cfg.MySQLDSN), + MySQLHost: cfg.MySQLHost, + MySQLPort: cfg.MySQLPort, + MySQLDatabase: cfg.MySQLDatabase, + MySQLUser: cfg.MySQLUser, + HasPassword: strings.TrimSpace(cfg.MySQLPassword) != "" || dsnHasPassword(cfg.MySQLDSN), + } +} + +func MaskDSN(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + at := strings.Index(value, "@") + colon := strings.Index(value, ":") + if at > -1 && colon > -1 && colon < at { + return value[:colon+1] + "******" + value[at:] + } + return value +} + +func relativeToBase(base, value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + if base != "" { + if rel, err := filepath.Rel(base, value); err == nil && !strings.HasPrefix(rel, "..") && rel != "." { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(value) +} + +func dsnHasPassword(value string) bool { + value = strings.TrimSpace(value) + at := strings.Index(value, "@") + colon := strings.Index(value, ":") + return at > -1 && colon > -1 && colon < at && colon+1 < at +} diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go index 38edc67..6e9049a 100644 --- a/server/unified-management/internal/db/audit_store.go +++ b/server/unified-management/internal/db/audit_store.go @@ -2,6 +2,7 @@ package db import ( "fmt" + "strings" "time" ) @@ -40,7 +41,7 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) { } func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { - rows, err := s.query(`SELECT h.id, e.source_id, e.name, h.status, h.latency_ms, h.error, h.checked_at + rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit) if err != nil { @@ -49,13 +50,19 @@ func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { defer rows.Close() items := []map[string]any{} for rows.Next() { - var id int64 + var id, sourceDBID int64 var sourceID, name, status, message, checkedAt string var latency int - if err := rows.Scan(&id, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil { + if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil { return nil, err } - items = append(items, map[string]any{"id": id, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt}) + if sourceID == "" { + sourceID = fmt.Sprintf("deleted-%d", sourceDBID) + } + if name == "" { + name = fmt.Sprintf("已删除接口 #%d", sourceDBID) + } + items = append(items, map[string]any{"id": id, "sourceDbId": sourceDBID, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt}) } return items, rows.Err() } @@ -100,6 +107,59 @@ func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) { return scanAuditRows(rows) } +func (s *Store) ListAuditLogsPage(filters AuditFilters) (AuditPage, error) { + page := filters.Page + if page <= 0 { + page = 1 + } + perPage := filters.PerPage + if perPage <= 0 { + perPage = 35 + } + if perPage > 100 { + perPage = 100 + } + where, args := auditWhere(filters) + var total int + if err := s.queryRow(`SELECT COUNT(*) FROM audit_logs`+where, args...).Scan(&total); err != nil { + return AuditPage{}, err + } + offset := (page - 1) * perPage + queryArgs := append(append([]any{}, args...), perPage, offset) + rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + return AuditPage{}, err + } + defer rows.Close() + items, err := scanAuditRows(rows) + if err != nil { + return AuditPage{}, err + } + return AuditPage{Items: items, Total: total, Page: page, PerPage: perPage}, nil +} + +func auditWhere(filters AuditFilters) (string, []any) { + clauses := []string{} + args := []any{} + if value := strings.TrimSpace(filters.Type); value != "" { + clauses = append(clauses, "type = ?") + args = append(args, sanitize(value)) + } + if value := strings.TrimSpace(filters.Target); value != "" { + clauses = append(clauses, "target = ?") + args = append(args, sanitize(value)) + } + if value := strings.TrimSpace(filters.Query); value != "" { + clauses = append(clauses, "(actor LIKE ? OR type LIKE ? OR target LIKE ? OR message LIKE ? OR ip LIKE ?)") + like := "%" + sanitize(value) + "%" + args = append(args, like, like, like, like, like) + } + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) { if limit <= 0 || limit > 200 { limit = 100 diff --git a/server/unified-management/internal/db/database_sync.go b/server/unified-management/internal/db/database_sync.go index d272596..58b931e 100644 --- a/server/unified-management/internal/db/database_sync.go +++ b/server/unified-management/internal/db/database_sync.go @@ -21,6 +21,10 @@ func (s *Store) CopyRemoteToSQLite() (string, error) { } func (s *Store) ImportSQLiteToRemote() (SyncResult, error) { + if !s.trySyncLock() { + return SyncResult{Direction: "sqlite_to_remote", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running") + } + defer s.syncMu.Unlock() s.mu.RLock() remote := s.remoteDB remoteDialect := s.remoteDialect @@ -28,9 +32,9 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) { localDialect := s.localDialect s.mu.RUnlock() if remote == nil { - err := errors.New("remote database is not configured") - s.setSyncStatus(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err) - return SyncResult{}, err + result := SyncResult{Direction: "sqlite_to_remote", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}} + s.setSyncStatus(result, nil) + return result, nil } result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote") s.setSyncStatus(result, err) @@ -38,6 +42,10 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) { } func (s *Store) SyncNow() (SyncResult, error) { + if !s.trySyncLock() { + return SyncResult{Direction: "remote_to_sqlite", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running") + } + defer s.syncMu.Unlock() s.mu.RLock() remote := s.remoteDB remoteDialect := s.remoteDialect @@ -45,7 +53,7 @@ func (s *Store) SyncNow() (SyncResult, error) { localDialect := s.localDialect s.mu.RUnlock() if remote == nil { - result := SyncResult{Direction: "remote_to_sqlite", Tables: map[string]int{}, FinishedAt: Now()} + result := SyncResult{Direction: "remote_to_sqlite", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}} s.setSyncStatus(result, nil) return result, nil } @@ -66,6 +74,10 @@ func (s *Store) setSyncStatus(result SyncResult, err error) { s.status.LastSyncError = "" } +func (s *Store) trySyncLock() bool { + return s.syncMu.TryLock() +} + type tableSpec struct { Name string Columns []string @@ -88,6 +100,7 @@ var syncTables = []tableSpec{ {"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}}, {"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}}, {"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}}, + {"system_settings", []string{"key", "value", "updated_at"}, []string{"key"}}, {"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}}, {"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "created_at"}, []string{"id"}}, {"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}}, @@ -95,19 +108,22 @@ var syncTables = []tableSpec{ } func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) { - result := SyncResult{Direction: direction, Tables: map[string]int{}, FinishedAt: Now()} + result := SyncResult{Direction: direction, Status: "completed", Tables: map[string]int{}, FinishedAt: Now()} for _, table := range syncTables { count, err := copyTable(src, srcDialect, dst, dstDialect, table) if err != nil { + result.Status = "failed" + result.FinishedAt = Now() return result, err } result.Tables[table.Name] = count } + result.FinishedAt = Now() return result, nil } func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) { - rows, err := src.Query(srcDialect.rebind("SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name)) + rows, err := src.Query(srcDialect.rebind("SELECT " + srcDialect.columnList(spec.Columns) + " FROM " + spec.Name)) if err != nil { return 0, err } diff --git a/server/unified-management/internal/db/dialect.go b/server/unified-management/internal/db/dialect.go index 255f6b7..62dc706 100644 --- a/server/unified-management/internal/db/dialect.go +++ b/server/unified-management/internal/db/dialect.go @@ -42,6 +42,46 @@ func (d dialect) idType() string { return "INTEGER PRIMARY KEY AUTOINCREMENT" } +func (d dialect) keyTextType() string { + if d.name == "mysql" { + return "VARCHAR(191)" + } + return "TEXT" +} + +func (d dialect) shortTextType() string { + if d.name == "mysql" { + return "VARCHAR(255)" + } + return "TEXT" +} + +func (d dialect) mediumTextType() string { + if d.name == "mysql" { + return "VARCHAR(1024)" + } + return "TEXT" +} + +func (d dialect) longTextType() string { + if d.name == "mysql" { + return "LONGTEXT" + } + return "TEXT" +} + +func (d dialect) quoteIdent(identifier string) string { + return "`" + strings.ReplaceAll(identifier, "`", "``") + "`" +} + +func (d dialect) columnList(columns []string) string { + quoted := make([]string, len(columns)) + for index, column := range columns { + quoted[index] = d.quoteIdent(column) + } + return strings.Join(quoted, ", ") +} + func (d dialect) boolExpr(value bool) int { if value { return 1 @@ -54,7 +94,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string { for i := range placeholders { placeholders[i] = "?" } - base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", ")) + base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, d.columnList(columns), strings.Join(placeholders, ", ")) conflictSet := map[string]bool{} for _, column := range conflict { conflictSet[column] = true @@ -64,10 +104,11 @@ func (d dialect) upsert(table string, columns, conflict []string) string { if conflictSet[column] { continue } + quoted := d.quoteIdent(column) if d.name == "mysql" { - updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", column, column)) + updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", quoted, quoted)) } else { - updates = append(updates, fmt.Sprintf("%s = excluded.%s", column, column)) + updates = append(updates, fmt.Sprintf("%s = excluded.%s", quoted, quoted)) } } if len(updates) == 0 { @@ -79,7 +120,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string { if d.name == "mysql" { return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updates, ", ") } - return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updates, ", ") + return base + " ON CONFLICT (" + d.columnList(conflict) + ") DO UPDATE SET " + strings.Join(updates, ", ") } func (d dialect) limitOffset(limit, offset int) string { diff --git a/server/unified-management/internal/db/feedback_store.go b/server/unified-management/internal/db/feedback_store.go index f0ab302..eeee1c4 100644 --- a/server/unified-management/internal/db/feedback_store.go +++ b/server/unified-management/internal/db/feedback_store.go @@ -304,6 +304,36 @@ func (s *Store) UpsertMailRecord(item LegacyMailRecord) error { return err } +func (s *Store) InsertMailRecord(item LegacyMailRecord) (int64, error) { + if item.CreatedAt == "" { + item.CreatedAt = Now() + } + id, err := s.insertID(`INSERT INTO mail_records ( + feedback_code, kind, status, to_address, subject, plain_body, html_body, + attachment_path, attachment_name, error_message, created_at, sent_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sanitize(item.FeedbackCode), sanitize(firstNonEmpty(item.Kind, "feedback")), sanitize(firstNonEmpty(item.Status, "pending")), + sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000), sanitizeLong(item.PlainBody, 12000), sanitizeLong(item.HTMLBody, 12000), + item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt) + return id, err +} + +func (s *Store) UpdateMailState(id int64, status, errorMessage string) error { + sentAt := "" + if status == "sent" { + sentAt = Now() + } + _, err := s.exec(`UPDATE mail_records SET status = ?, error_message = ?, sent_at = ? WHERE id = ?`, + sanitize(status), sanitizeLong(errorMessage, 1000), sentAt, id) + return err +} + +func (s *Store) UpdateFeedbackMailState(code string, sent bool) error { + _, err := s.exec(`UPDATE feedback_tickets SET mail_sent = ?, updated_at = ?, last_activity_at = ? WHERE code = ?`, + boolInt(sent), Now(), Now(), code) + return err +} + func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) { if limit <= 0 || limit > 200 { limit = 100 diff --git a/server/unified-management/internal/db/models.go b/server/unified-management/internal/db/models.go index e3a6fe6..ee5d2b4 100644 --- a/server/unified-management/internal/db/models.go +++ b/server/unified-management/internal/db/models.go @@ -34,6 +34,9 @@ type DatabaseStatus struct { type SyncResult struct { Direction string `json:"direction"` + Status string `json:"status"` + Skipped bool `json:"skipped"` + Warnings []string `json:"warnings,omitempty"` Tables map[string]int `json:"tables"` FinishedAt string `json:"finishedAt"` } @@ -121,6 +124,8 @@ type LegacyMailRecord struct { Status string `json:"status"` ToAddress string `json:"toAddress"` Subject string `json:"subject"` + PlainBody string `json:"plainBody,omitempty"` + HTMLBody string `json:"htmlBody,omitempty"` AttachmentPath string `json:"attachmentPath"` AttachmentName string `json:"attachmentName"` ErrorMessage string `json:"errorMessage"` @@ -272,6 +277,21 @@ type AuditLog struct { CreatedAt string `json:"createdAt"` } +type AuditFilters struct { + Page int + PerPage int + Type string + Target string + Query string +} + +type AuditPage struct { + Items []AuditLog `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} + type LegacyJsonRevision struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/server/unified-management/internal/db/schema.go b/server/unified-management/internal/db/schema.go index 0668990..4ed41ba 100644 --- a/server/unified-management/internal/db/schema.go +++ b/server/unified-management/internal/db/schema.go @@ -26,252 +26,261 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error { } func schemaStatements(d dialect) []string { + keyText := d.keyTextType() + shortText := d.shortTextType() + mediumText := d.mediumTextType() + longText := d.longTextType() return []string{ - `CREATE TABLE IF NOT EXISTS schema_migrations ( + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS schema_migrations ( version VARCHAR(64) NOT NULL PRIMARY KEY, - applied_at TEXT NOT NULL, + applied_at %s NOT NULL, description VARCHAR(255) NOT NULL DEFAULT '' - )`, + )`, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users ( id %s, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, + username %s NOT NULL UNIQUE, + password_hash %s NOT NULL, password_changed INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions ( id %s, - session_id TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - csrf TEXT NOT NULL, - expires_at TEXT NOT NULL, - created_at TEXT NOT NULL - )`, d.idType()), + session_id %s NOT NULL UNIQUE, + username %s NOT NULL, + csrf %s NOT NULL, + expires_at %s NOT NULL, + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages ( id %s, - product TEXT NOT NULL, - version TEXT NOT NULL, - platform TEXT NOT NULL, - arch TEXT NOT NULL, - file_name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - sha256 TEXT NOT NULL, + product %s NOT NULL, + version %s NOT NULL, + platform %s NOT NULL, + arch %s NOT NULL, + file_name %s NOT NULL UNIQUE, + url %s NOT NULL, + sha256 %s NOT NULL, size_bytes BIGINT NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, keyText, keyText, keyText, keyText, mediumText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices ( id %s, - version TEXT NOT NULL UNIQUE, - build TEXT NOT NULL DEFAULT '', - channel TEXT NOT NULL DEFAULT 'stable', - title TEXT NOT NULL DEFAULT '', - message TEXT NOT NULL DEFAULT '', - release_notes TEXT NOT NULL DEFAULT '', - message_md TEXT NOT NULL DEFAULT '', - release_notes_md TEXT NOT NULL DEFAULT '', - download_url TEXT NOT NULL DEFAULT '', - notice_file TEXT NOT NULL DEFAULT '', - raw_json TEXT NOT NULL, - published_at TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + version %s NOT NULL UNIQUE, + build %s NOT NULL DEFAULT '', + channel %s NOT NULL DEFAULT 'stable', + title %s NOT NULL DEFAULT '', + message %s NOT NULL, + release_notes %s NOT NULL, + message_md %s NOT NULL, + release_notes_md %s NOT NULL, + download_url %s NOT NULL DEFAULT '', + notice_file %s NOT NULL DEFAULT '', + raw_json %s NOT NULL, + published_at %s NOT NULL DEFAULT '', + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, shortText, mediumText, longText, longText, longText, longText, mediumText, keyText, longText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions ( id %s, - version TEXT NOT NULL, - raw_json TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - created_by TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + version %s NOT NULL, + raw_json %s NOT NULL, + note %s NOT NULL DEFAULT '', + created_by %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, longText, mediumText, keyText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets ( - code TEXT PRIMARY KEY, - title TEXT NOT NULL, - type TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', - priority TEXT NOT NULL DEFAULT '', - contact TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL, - status_detail TEXT NOT NULL DEFAULT '', - public_reply TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - assignee TEXT NOT NULL DEFAULT '', - handled_by TEXT NOT NULL DEFAULT '', - due_at TEXT NOT NULL DEFAULT '', - resolved_at TEXT NOT NULL DEFAULT '', - archived_at TEXT NOT NULL DEFAULT '', - sla_level TEXT NOT NULL DEFAULT '', - source_channel TEXT NOT NULL DEFAULT '', + code %s PRIMARY KEY, + title %s NOT NULL, + type %s NOT NULL, + severity %s NOT NULL, + category %s NOT NULL DEFAULT '', + priority %s NOT NULL DEFAULT '', + contact %s NOT NULL DEFAULT '', + body %s NOT NULL, + status %s NOT NULL, + status_detail %s NOT NULL DEFAULT '', + public_reply %s NOT NULL, + note %s NOT NULL, + assignee %s NOT NULL DEFAULT '', + handled_by %s NOT NULL DEFAULT '', + due_at %s NOT NULL DEFAULT '', + resolved_at %s NOT NULL DEFAULT '', + archived_at %s NOT NULL DEFAULT '', + sla_level %s NOT NULL DEFAULT '', + source_channel %s NOT NULL DEFAULT '', risk_score INTEGER NOT NULL DEFAULT 0, - resolution TEXT NOT NULL DEFAULT '', - attachment TEXT NOT NULL DEFAULT '', - package_path TEXT NOT NULL DEFAULT '', - encrypted_package_path TEXT NOT NULL DEFAULT '', - package_sha256 TEXT NOT NULL DEFAULT '', - plain_package_sha256 TEXT NOT NULL DEFAULT '', - summary_text TEXT NOT NULL DEFAULT '', - included_files TEXT NOT NULL DEFAULT '', + resolution %s NOT NULL, + attachment %s NOT NULL DEFAULT '', + package_path %s NOT NULL DEFAULT '', + encrypted_package_path %s NOT NULL DEFAULT '', + package_sha256 %s NOT NULL DEFAULT '', + plain_package_sha256 %s NOT NULL DEFAULT '', + summary_text %s NOT NULL, + included_files %s NOT NULL, mail_sent INTEGER NOT NULL DEFAULT 0, - remote_addr TEXT NOT NULL DEFAULT '', - tags TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - last_activity_at TEXT NOT NULL - )`), + remote_addr %s NOT NULL DEFAULT '', + tags %s NOT NULL, + created_at %s NOT NULL, + updated_at %s NOT NULL, + last_activity_at %s NOT NULL + )`, keyText, mediumText, keyText, keyText, keyText, keyText, mediumText, longText, keyText, mediumText, longText, longText, keyText, keyText, shortText, shortText, shortText, keyText, keyText, longText, mediumText, mediumText, mediumText, shortText, shortText, longText, longText, shortText, longText, shortText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments ( id %s, - feedback_code TEXT NOT NULL, - author TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL, + feedback_code %s NOT NULL, + author %s NOT NULL DEFAULT '', + body %s NOT NULL, internal INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, longText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments ( id %s, - feedback_code TEXT NOT NULL, - kind TEXT NOT NULL, - path TEXT NOT NULL, - file_name TEXT NOT NULL, - sha256 TEXT NOT NULL DEFAULT '', + feedback_code %s NOT NULL, + kind %s NOT NULL, + path %s NOT NULL, + file_name %s NOT NULL, + sha256 %s NOT NULL DEFAULT '', size_bytes BIGINT NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, mediumText, mediumText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events ( id %s, - feedback_code TEXT NOT NULL, - event_type TEXT NOT NULL, - actor TEXT NOT NULL DEFAULT '', - from_value TEXT NOT NULL DEFAULT '', - to_value TEXT NOT NULL DEFAULT '', - message TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), - `CREATE TABLE IF NOT EXISTS feedback_tags ( - feedback_code TEXT NOT NULL, - tag TEXT NOT NULL, - created_at TEXT NOT NULL, + feedback_code %s NOT NULL, + event_type %s NOT NULL, + actor %s NOT NULL DEFAULT '', + from_value %s NOT NULL DEFAULT '', + to_value %s NOT NULL DEFAULT '', + message %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, mediumText, shortText), + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tags ( + feedback_code %s NOT NULL, + tag %s NOT NULL, + created_at %s NOT NULL, PRIMARY KEY (feedback_code, tag) - )`, + )`, keyText, keyText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records ( id %s, - feedback_code TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', - to_address TEXT NOT NULL DEFAULT '', - subject TEXT NOT NULL DEFAULT '', - plain_body TEXT NOT NULL DEFAULT '', - html_body TEXT NOT NULL DEFAULT '', - attachment_path TEXT NOT NULL DEFAULT '', - attachment_name TEXT NOT NULL DEFAULT '', - error_message TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - sent_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + feedback_code %s NOT NULL DEFAULT '', + kind %s NOT NULL DEFAULT '', + status %s NOT NULL DEFAULT '', + to_address %s NOT NULL DEFAULT '', + subject %s NOT NULL DEFAULT '', + plain_body %s NOT NULL, + html_body %s NOT NULL, + attachment_path %s NOT NULL DEFAULT '', + attachment_name %s NOT NULL DEFAULT '', + error_message %s NOT NULL, + created_at %s NOT NULL, + sent_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, longText, longText, mediumText, mediumText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories ( id %s, - category_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, + category_id %s NOT NULL UNIQUE, + name %s NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, - ui_config TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + ui_config %s NOT NULL, + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints ( id %s, - category_id TEXT NOT NULL, - category_name TEXT NOT NULL, - source_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - method TEXT NOT NULL DEFAULT 'GET', - api_url TEXT NOT NULL DEFAULT '', - url_template TEXT NOT NULL DEFAULT '', - thumbnail_url TEXT NOT NULL DEFAULT '', - proxy_mode TEXT NOT NULL DEFAULT 'client_direct', + category_id %s NOT NULL, + category_name %s NOT NULL, + source_id %s NOT NULL UNIQUE, + name %s NOT NULL, + description %s NOT NULL DEFAULT '', + method %s NOT NULL DEFAULT 'GET', + api_url %s NOT NULL DEFAULT '', + url_template %s NOT NULL DEFAULT '', + thumbnail_url %s NOT NULL DEFAULT '', + proxy_mode %s NOT NULL DEFAULT 'client_direct', timeout_ms INTEGER NOT NULL DEFAULT 8000, retry_count INTEGER NOT NULL DEFAULT 1, cache_seconds INTEGER NOT NULL DEFAULT 300, check_interval_sec INTEGER NOT NULL DEFAULT 300, enabled INTEGER NOT NULL DEFAULT 1, client_visible INTEGER NOT NULL DEFAULT 1, - supported_formats TEXT NOT NULL DEFAULT '[]', - last_status TEXT NOT NULL DEFAULT 'unknown', + supported_formats %s NOT NULL, + last_status %s NOT NULL DEFAULT 'unknown', last_latency_ms INTEGER NOT NULL DEFAULT 0, - last_checked_at TEXT NOT NULL DEFAULT '', - last_error TEXT NOT NULL DEFAULT '', + last_checked_at %s NOT NULL DEFAULT '', + last_error %s NOT NULL, consecutive_failure INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, d.idType()), + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, d.idType(), keyText, shortText, keyText, shortText, mediumText, keyText, mediumText, mediumText, mediumText, keyText, longText, keyText, shortText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks ( id %s, source_db_id BIGINT NOT NULL, - status TEXT NOT NULL, + status %s NOT NULL, latency_ms INTEGER NOT NULL DEFAULT 0, - error TEXT NOT NULL DEFAULT '', - checked_at TEXT NOT NULL - )`, d.idType()), + error %s NOT NULL, + checked_at %s NOT NULL + )`, d.idType(), keyText, longText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs ( id %s, - source_id TEXT NOT NULL, - status TEXT NOT NULL, + source_id %s NOT NULL, + status %s NOT NULL, latency_ms INTEGER NOT NULL DEFAULT 0, - error TEXT NOT NULL DEFAULT '', - client TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + error %s NOT NULL, + client %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, longText, mediumText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs ( id %s, - direction TEXT NOT NULL, - status TEXT NOT NULL, - message TEXT NOT NULL DEFAULT '', - tables_json TEXT NOT NULL DEFAULT '{}', - started_at TEXT NOT NULL, - finished_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + direction %s NOT NULL, + status %s NOT NULL, + message %s NOT NULL, + tables_json %s NOT NULL, + started_at %s NOT NULL, + finished_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, keyText, longText, longText, shortText, shortText), + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS system_settings ( + %s %s NOT NULL PRIMARY KEY, + value %s NOT NULL, + updated_at %s NOT NULL + )`, d.quoteIdent("key"), keyText, longText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs ( id %s, - status TEXT NOT NULL, - summary TEXT NOT NULL DEFAULT '', - stats_json TEXT NOT NULL DEFAULT '{}', - started_at TEXT NOT NULL, - finished_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + status %s NOT NULL, + summary %s NOT NULL, + stats_json %s NOT NULL, + started_at %s NOT NULL, + finished_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, longText, longText, shortText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs ( id %s, - actor TEXT NOT NULL DEFAULT '', - type TEXT NOT NULL, - target TEXT NOT NULL DEFAULT '', - message TEXT NOT NULL DEFAULT '', - ip TEXT NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + actor %s NOT NULL DEFAULT '', + type %s NOT NULL, + target %s NOT NULL DEFAULT '', + message %s NOT NULL, + ip %s NOT NULL DEFAULT '', + user_agent %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, keyText, keyText, longText, keyText, mediumText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions ( id %s, - name TEXT NOT NULL, - raw TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - created_by TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )`, d.idType()), + name %s NOT NULL, + raw %s NOT NULL, + note %s NOT NULL DEFAULT '', + created_by %s NOT NULL DEFAULT '', + created_at %s NOT NULL + )`, d.idType(), keyText, longText, mediumText, keyText, shortText), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries ( id %s, - webhook_name TEXT NOT NULL DEFAULT '', - event TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', + webhook_name %s NOT NULL DEFAULT '', + event %s NOT NULL DEFAULT '', + status %s NOT NULL DEFAULT '', attempts INTEGER NOT NULL DEFAULT 0, response_code INTEGER NOT NULL DEFAULT 0, - error_message TEXT NOT NULL DEFAULT '', - payload_sha256 TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - finished_at TEXT NOT NULL DEFAULT '' - )`, d.idType()), + error_message %s NOT NULL, + payload_sha256 %s NOT NULL DEFAULT '', + created_at %s NOT NULL, + finished_at %s NOT NULL DEFAULT '' + )`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText), `CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`, `CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`, `CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`, @@ -279,6 +288,8 @@ func schemaStatements(d dialect) []string { `CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`, `CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`, `CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_type ON audit_logs(type)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target)`, `CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`, `CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`, `CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`, diff --git a/server/unified-management/internal/db/settings_store.go b/server/unified-management/internal/db/settings_store.go new file mode 100644 index 0000000..8292817 --- /dev/null +++ b/server/unified-management/internal/db/settings_store.go @@ -0,0 +1,17 @@ +package db + +func (s *Store) GetSetting(key string) (string, error) { + var value string + err := s.queryRow("SELECT value FROM system_settings WHERE `key` = ?", sanitize(key)).Scan(&value) + return value, err +} + +func (s *Store) UpsertSetting(key, value string) error { + columns := []string{"key", "value", "updated_at"} + conn, d := s.active() + _, err := conn.Exec(d.rebind(d.upsert("system_settings", columns, []string{"key"})), sanitize(key), value, Now()) + if err != nil { + s.markFailover(err) + } + return err +} diff --git a/server/unified-management/internal/db/source_store.go b/server/unified-management/internal/db/source_store.go index 53dd94b..2c580f4 100644 --- a/server/unified-management/internal/db/source_store.go +++ b/server/unified-management/internal/db/source_store.go @@ -110,8 +110,8 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, return err } 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) + _, 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 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) diff --git a/server/unified-management/internal/db/store.go b/server/unified-management/internal/db/store.go index 053bc3c..4e29184 100644 --- a/server/unified-management/internal/db/store.go +++ b/server/unified-management/internal/db/store.go @@ -14,6 +14,7 @@ import ( type Store struct { mu sync.RWMutex + syncMu sync.Mutex cfg *config.Config path string db *sql.DB @@ -114,6 +115,80 @@ func (s *Store) Path() string { return s.path } +func (s *Store) ReconfigureDatabase(cfg *config.Config) error { + if cfg == nil { + return nil + } + path := cfg.Database.SQLitePath + if strings.TrimSpace(path) == "" { + path = filepath.Join(cfg.StorageDir, "unified.sqlite") + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil { + return err + } + localCfg := cfg.Database + localCfg.Provider = "sqlite" + localCfg.SQLitePath = path + local, localDialect, err := openSQLDatabase(localCfg) + if err != nil { + return err + } + local.SetMaxOpenConns(1) + if err := s.migrate(local, localDialect); err != nil { + _ = local.Close() + return err + } + var remote *sql.DB + var remoteDialect dialect + if strings.EqualFold(cfg.Database.Provider, "mysql") { + remote, remoteDialect, err = openSQLDatabase(cfg.Database) + if err != nil { + _ = local.Close() + return err + } + if err := s.migrate(remote, remoteDialect); err != nil { + _ = remote.Close() + _ = local.Close() + return err + } + } + s.mu.Lock() + oldLocal := s.localDB + oldRemote := s.remoteDB + s.cfg.Database = cfg.Database + s.path = path + s.localDB = local + s.localDialect = localDialect + s.remoteDB = remote + s.remoteDialect = remoteDialect + s.status.ConfigProvider = cfg.Database.Provider + s.status.SQLiteReady = true + s.status.RemoteReady = remote != nil + s.status.LastError = "" + s.status.FailoverActive = false + if remote != nil { + s.db = remote + s.dialect = remoteDialect + s.status.ActiveProvider = "mysql" + } else { + s.db = local + s.dialect = localDialect + s.status.ActiveProvider = "sqlite" + } + s.status.LastRecoveredAt = Now() + s.mu.Unlock() + if oldRemote != nil && oldRemote != oldLocal && oldRemote != remote { + _ = oldRemote.Close() + } + if oldLocal != nil && oldLocal != local { + _ = oldLocal.Close() + } + return nil +} + func (s *Store) active() (*sql.DB, dialect) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/server/unified-management/internal/db/store_test.go b/server/unified-management/internal/db/store_test.go index 7757d4d..3478ae3 100644 --- a/server/unified-management/internal/db/store_test.go +++ b/server/unified-management/internal/db/store_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "ymhut-box/server/unified-management/internal/config" @@ -273,3 +274,75 @@ func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) { } } } + +func TestMySQLSchemaAvoidsTextKeys(t *testing.T) { + statements := strings.Join(schemaStatements(dialectFor("mysql")), "\n") + for _, forbidden := range []string{ + "TEXT NOT NULL UNIQUE", + "TEXT PRIMARY KEY", + "TEXT NOT NULL PRIMARY KEY", + "key VARCHAR(191) NOT NULL PRIMARY KEY", + } { + if strings.Contains(statements, forbidden) { + t.Fatalf("mysql schema contains forbidden fragment %q:\n%s", forbidden, statements) + } + } + if !strings.Contains(statements, "`key` VARCHAR(191) NOT NULL PRIMARY KEY") { + t.Fatalf("system_settings.key must be quoted for MySQL:\n%s", statements) + } +} + +func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "unified.sqlite") + store, err := Open(&config.Config{ + StorageDir: root, + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: path, + FailoverEnabled: true, + HealthIntervalSec: 3600, + MaxOpenConns: 1, + MaxIdleConns: 1, + ConnMaxLifetimeSeconds: 60, + }, + }) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + source, err := store.UpsertSource(Source{ + CategoryID: "video", + CategoryName: "视频", + SourceID: "video-demo", + Name: "演示接口", + APIURL: "https://example.com/video.json", + Enabled: true, + ClientVisible: true, + }) + if err != nil { + t.Fatal(err) + } + if err := store.RecordSourceCheck(source.ID, "ok", 123, ""); err != nil { + t.Fatal(err) + } + if err := store.DeleteSource(source.SourceID); err != nil { + t.Fatal(err) + } + + overview, err := store.DashboardOverview(10) + if err != nil { + t.Fatal(err) + } + checks, ok := overview["heartbeats"].([]map[string]any) + if !ok { + t.Fatalf("heartbeats has unexpected type %T", overview["heartbeats"]) + } + if len(checks) != 1 { + t.Fatalf("expected deleted source check to remain visible, got %d", len(checks)) + } + if checks[0]["sourceId"] == "" || checks[0]["name"] == "" { + t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0]) + } +} diff --git a/server/unified-management/internal/feedback/feedback.go b/server/unified-management/internal/feedback/feedback.go index 867803e..e922f6b 100644 --- a/server/unified-management/internal/feedback/feedback.go +++ b/server/unified-management/internal/feedback/feedback.go @@ -23,6 +23,7 @@ import ( "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" + feedbackmail "ymhut-box/server/unified-management/internal/mail" ) const PackageMagic = "YMHUTFB1" @@ -79,14 +80,70 @@ func NewService(cfg *config.Config, store *db.Store) *Service { func (s *Service) Submit(r *http.Request) (db.Feedback, error) { contentType := r.Header.Get("Content-Type") + var item db.Feedback + var err error if strings.Contains(contentType, "multipart/form-data") { - if item, err := s.submitMultipart(r); err == nil { + if item, err = s.submitMultipart(r); err == nil { + if !DuplicateSubmission(r) && s.NotifyFeedback(item) == nil { + item.MailSent = true + } return item, nil } else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") { return db.Feedback{}, err } } - return s.submitSimple(r) + item, err = s.submitSimple(r) + if err == nil && s.NotifyFeedback(item) == nil { + item.MailSent = true + } + return item, err +} + +func (s *Service) RetryMail(code string) error { + item, err := s.store.GetFeedback(NormalizeCode(code)) + if err != nil { + return err + } + return s.NotifyFeedback(item) +} + +func (s *Service) NotifyFeedback(item db.Feedback) error { + message, err := feedbackmail.BuildFeedbackMessage(s.cfg, item) + if err != nil { + _, _ = s.store.InsertMailRecord(db.LegacyMailRecord{ + FeedbackCode: item.Code, + Kind: "feedback", + Status: "failed", + Subject: "反馈邮件未发送", + ErrorMessage: err.Error(), + CreatedAt: db.Now(), + }) + _ = s.store.UpdateFeedbackMailState(item.Code, false) + return err + } + mailID, err := s.store.InsertMailRecord(db.LegacyMailRecord{ + FeedbackCode: item.Code, + Kind: "feedback", + Status: "pending", + ToAddress: message.To, + Subject: message.Subject, + PlainBody: message.PlainBody, + HTMLBody: message.HTMLBody, + AttachmentPath: message.AttachmentPath, + AttachmentName: message.AttachmentName, + CreatedAt: db.Now(), + }) + if err != nil { + return err + } + if err := feedbackmail.Send(s.cfg, message); err != nil { + _ = s.store.UpdateMailState(mailID, "failed", err.Error()) + _ = s.store.UpdateFeedbackMailState(item.Code, false) + return err + } + _ = s.store.UpdateMailState(mailID, "sent", "") + _ = s.store.UpdateFeedbackMailState(item.Code, true) + return nil } func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) { diff --git a/server/unified-management/internal/legacy/legacy.go b/server/unified-management/internal/legacy/legacy.go index 74011c4..8fee3d7 100644 --- a/server/unified-management/internal/legacy/legacy.go +++ b/server/unified-management/internal/legacy/legacy.go @@ -230,12 +230,12 @@ func validate(name string, parsed map[string]any) error { case "update-info": if _, ok := parsed["app_version"]; !ok { if _, ok := parsed["title"]; !ok { - return errors.New("update-info requires app_version or title") + return errors.New("更新 JSON 需要填写 app_version 或 title") } } case "media-types": if _, ok := parsed["categories"].([]any); !ok { - return errors.New("media-types requires categories array") + return errors.New("媒体源 JSON 需要包含 categories 数组") } if _, ok := parsed["layout_version"]; !ok { parsed["layout_version"] = "1.0.0" diff --git a/server/unified-management/internal/mail/mail.go b/server/unified-management/internal/mail/mail.go new file mode 100644 index 0000000..ec75ebf --- /dev/null +++ b/server/unified-management/internal/mail/mail.go @@ -0,0 +1,357 @@ +package mail + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "mime" + "net" + "net/smtp" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "ymhut-box/server/unified-management/internal/config" + "ymhut-box/server/unified-management/internal/db" +) + +type Message struct { + From string + FromName string + To string + Subject string + PlainBody string + HTMLBody string + AttachmentPath string + AttachmentName string +} + +func SafeConfig(cfg config.MailConfig) map[string]any { + return map[string]any{ + "host": cfg.Host, + "port": cfg.Port, + "secure": cfg.Secure, + "username": cfg.Username, + "fromAddress": cfg.FromAddress, + "fromName": cfg.FromName, + "developerAddress": cfg.DeveloperAddress, + "timeoutSeconds": cfg.TimeoutSeconds, + "hasPassword": strings.TrimSpace(cfg.Password) != "", + "configured": IsConfigured(cfg), + } +} + +func IsConfigured(cfg config.MailConfig) bool { + channel := normalize(cfg) + return channel.Host != "" && channel.FromAddress != "" && channel.DeveloperAddress != "" +} + +func BuildFeedbackMessage(cfg *config.Config, record db.Feedback) (Message, error) { + channel, err := channel(cfg.Mail) + if err != nil { + return Message{}, err + } + attachment := record.PackagePath + name := "" + if attachment != "" { + name = record.Code + ".zip" + } + subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80) + return Message{ + From: channel.FromAddress, + FromName: channel.FromName, + To: channel.DeveloperAddress, + Subject: subject, + PlainBody: feedbackPlain(record), + HTMLBody: feedbackHTML(record), + AttachmentPath: attachment, + AttachmentName: name, + }, nil +} + +func BuildTestMessage(cfg *config.Config) (Message, error) { + channel, err := channel(cfg.Mail) + if err != nil { + return Message{}, err + } + now := time.Now().UTC().Format(time.RFC3339) + return Message{ + From: channel.FromAddress, + FromName: channel.FromName, + To: channel.DeveloperAddress, + Subject: "YMhut Box 反馈通知测试", + PlainBody: "这是一封来自 unified-management 的测试通知。\n时间:" + now, + HTMLBody: "

这是一封来自 unified-management 的测试通知。

时间:" + htmlEscape(now) + "

", + }, nil +} + +func Send(cfg *config.Config, message Message) error { + channel, err := channel(cfg.Mail) + if err != nil { + return err + } + raw, err := BuildMIME(message) + if err != nil { + return err + } + return smtpSend(channel, message.From, message.To, raw) +} + +func BuildMIME(message Message) (string, error) { + boundary := "ymhut_" + randomish() + altBoundary := "ymhut_alt_" + randomish() + headers := []string{ + "Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000", + "From: " + mimeAddress(message.From, message.FromName), + "To: " + message.To, + "Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject), + "MIME-Version: 1.0", + `Content-Type: multipart/mixed; boundary="` + boundary + `"`, + } + body := []string{ + "--" + boundary, + `Content-Type: multipart/alternative; boundary="` + altBoundary + `"`, + "", + "--" + altBoundary, + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: base64", + "", + wrapBase64([]byte(message.PlainBody)), + "--" + altBoundary, + "Content-Type: text/html; charset=UTF-8", + "Content-Transfer-Encoding: base64", + "", + wrapBase64([]byte(message.HTMLBody)), + "--" + altBoundary + "--", + } + if message.AttachmentPath != "" { + data, err := os.ReadFile(message.AttachmentPath) + if err == nil { + name := firstNonEmpty(message.AttachmentName, filepath.Base(message.AttachmentPath)) + escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`) + body = append(body, + "--"+boundary, + `Content-Type: application/zip; name="`+escaped+`"`, + "Content-Transfer-Encoding: base64", + `Content-Disposition: attachment; filename="`+escaped+`"`, + "", + wrapBase64(data), + ) + } + } + body = append(body, "--"+boundary+"--") + return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil +} + +func channel(cfg config.MailConfig) (config.MailConfig, error) { + cfg = normalize(cfg) + if cfg.Host == "" || cfg.FromAddress == "" || cfg.DeveloperAddress == "" { + return cfg, errors.New("mail is not configured") + } + return cfg, nil +} + +func normalize(cfg config.MailConfig) config.MailConfig { + cfg.Secure = strings.ToLower(strings.TrimSpace(cfg.Secure)) + if cfg.Secure == "" { + cfg.Secure = "ssl" + } + if cfg.Port <= 0 { + cfg.Port = 465 + } + if cfg.FromAddress == "" { + cfg.FromAddress = cfg.Username + } + if cfg.FromName == "" { + cfg.FromName = "YMhut Box Feedback" + } + if cfg.TimeoutSeconds <= 0 { + cfg.TimeoutSeconds = 20 + } + return cfg +} + +func smtpSend(channel config.MailConfig, from, to, rawMessage string) error { + address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port)) + timeout := time.Duration(channel.TimeoutSeconds) * time.Second + var client *smtp.Client + if channel.Secure == "ssl" || channel.Secure == "tls" { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host}) + if err != nil { + return fmt.Errorf("邮件服务器连接失败:%w", err) + } + var clientErr error + client, clientErr = smtp.NewClient(conn, channel.Host) + if clientErr != nil { + _ = conn.Close() + return clientErr + } + } else { + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return fmt.Errorf("邮件服务器连接失败:%w", err) + } + var clientErr error + client, clientErr = smtp.NewClient(conn, channel.Host) + if clientErr != nil { + _ = conn.Close() + return clientErr + } + } + defer client.Close() + if channel.Secure == "starttls" { + if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil { + return fmt.Errorf("邮件加密握手失败:%w", err) + } + } + if channel.Username != "" || channel.Password != "" { + if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil { + return fmt.Errorf("邮件认证失败:%w", err) + } + } + if err := client.Mail(extractEmail(from)); err != nil { + return err + } + if err := client.Rcpt(extractEmail(to)); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + if _, err := writer.Write([]byte(rawMessage)); err != nil { + _ = writer.Close() + return err + } + if err := writer.Close(); err != nil { + return err + } + return client.Quit() +} + +func feedbackPlain(record db.Feedback) string { + return strings.Join([]string{ + "YMhut Box 反馈工单", + "反馈编号:" + record.Code, + "标题:" + record.Title, + "类型:" + typeLabel(record.Type), + "优先级:" + priorityLabel(record.Priority, record.Severity), + "联系方式:" + record.Contact, + "接收时间:" + record.CreatedAt, + "包含文件:" + record.IncludedFiles, + "反馈包 SHA256:" + record.PlainPackageSha256, + "", + "正文:", + record.Body, + "", + "反馈包摘要:", + record.SummaryText, + }, "\n") +} + +func feedbackHTML(record db.Feedback) string { + rows := [][2]string{ + {"反馈编号", record.Code}, + {"标题", record.Title}, + {"类型", typeLabel(record.Type)}, + {"优先级", priorityLabel(record.Priority, record.Severity)}, + {"联系方式", record.Contact}, + {"接收时间", record.CreatedAt}, + {"包含文件", record.IncludedFiles}, + {"反馈包 SHA256", record.PlainPackageSha256}, + } + html := `

YMhut Box 反馈工单

` + for _, row := range rows { + html += `" + } + html += "
` + htmlEscape(row[0]) + "" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "
") + "
" + html += `

正文

` + htmlEscape(record.Body) + "

" + html += `

反馈包摘要

` + htmlEscape(record.SummaryText) + "
" + return html +} + +func typeLabel(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "suggestion": + return "建议" + case "ui": + return "界面反馈" + case "other": + return "其他" + default: + return "问题" + } +} + +func priorityLabel(priority, severity string) string { + value := firstNonEmpty(priority, severity) + switch strings.ToLower(strings.TrimSpace(value)) { + case "urgent", "blocking": + return "紧急" + case "high", "major": + return "高" + case "low", "minor": + return "低" + default: + return "普通" + } +} + +func htmlEscape(value string) string { + value = strings.ReplaceAll(value, "&", "&") + value = strings.ReplaceAll(value, "<", "<") + value = strings.ReplaceAll(value, ">", ">") + value = strings.ReplaceAll(value, `"`, """) + return strings.ReplaceAll(value, "'", "'") +} + +func mimeAddress(address, name string) string { + if name == "" { + return address + } + return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">" +} + +func extractEmail(value string) string { + re := regexp.MustCompile(`<([^>]+)>`) + if match := re.FindStringSubmatch(value); len(match) == 2 { + return strings.TrimSpace(match[1]) + } + return strings.TrimSpace(value) +} + +func wrapBase64(data []byte) string { + encoded := base64.StdEncoding.EncodeToString(data) + var builder strings.Builder + for len(encoded) > 76 { + builder.WriteString(encoded[:76]) + builder.WriteString("\r\n") + encoded = encoded[76:] + } + builder.WriteString(encoded) + return builder.String() +} + +func randomish() string { + return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "") +} + +func truncate(value string, max int) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= max { + return string(runes) + } + return string(runes[:max]) +} + +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/notices/notices.go b/server/unified-management/internal/notices/notices.go index 4eaa04a..8f97a57 100644 --- a/server/unified-management/internal/notices/notices.go +++ b/server/unified-management/internal/notices/notices.go @@ -139,6 +139,34 @@ func (s *Service) Save(ctx context.Context, version string, req SaveRequest, act return s.Get(saved.Version) } +func (s *Service) SyncFromLegacyUpdateInfo(ctx context.Context, raw string, actor string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + item, parsed, formatted, err := parseNotice([]byte(raw), "", "") + if err != nil { + return err + } + item.RawJSON = formatted + current, err := s.store.GetReleaseNotice(item.Version) + if err == nil && current.RawJSON != "" && current.RawJSON != formatted { + _, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before legacy update-info sync", actor) + } + saved, err := s.store.UpsertReleaseNotice(item) + if err != nil { + return err + } + _, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, "synced from update-info.json", actor) + if err := s.writeNoticeFile(saved, formatted); err != nil { + return err + } + if err := s.writeTotalIndex(saved, parsed); err != nil { + return err + } + _ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.synced", Target: saved.Version, Message: "版本日志已从兼容 update-info.json 同步"}) + return nil +} + func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) { revision, err := s.store.GetReleaseNoticeRevision(version, revisionID) if err != nil { @@ -227,10 +255,7 @@ func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any) func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error { path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") - payload := map[string]any{} - if data, err := os.ReadFile(path); err == nil { - _ = json.Unmarshal(data, &payload) - } + payload := s.legacyUpdateBase(path) payload["app_version"] = item.Version setNonEmpty(payload, "build", item.Build) setNonEmpty(payload, "channel", item.Channel) @@ -256,6 +281,36 @@ func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string] return atomicWrite(path, append(data, '\n')) } +func (s *Service) legacyUpdateBase(currentPath string) map[string]any { + payload := map[string]any{} + for _, path := range []string{ + filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"), + currentPath, + } { + if data, err := os.ReadFile(path); err == nil { + var doc map[string]any + if json.Unmarshal(data, &doc) == nil { + for key, value := range doc { + payload[key] = value + } + } + } + } + if payload["app_version"] == nil { + if value, ok := payload["appVersion"]; ok { + payload["app_version"] = value + } else if value, ok := payload["latestVersion"]; ok { + payload["app_version"] = value + } + } + if payload["manifest_version"] == nil { + if value, ok := payload["manifestVersion"]; ok { + payload["manifest_version"] = value + } + } + return payload +} + func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) { _, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile) return parsed, formatted, err @@ -270,7 +325,7 @@ func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNot } version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion) if version == "" { - return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required") + return db.ReleaseNotice{}, nil, "", errors.New("版本日志需要填写 version 或 app_version") } if noticeFile == "" { noticeFile = version + ".json" diff --git a/server/unified-management/internal/notices/notices_test.go b/server/unified-management/internal/notices/notices_test.go index 17b52f6..0035d5c 100644 --- a/server/unified-management/internal/notices/notices_test.go +++ b/server/unified-management/internal/notices/notices_test.go @@ -69,6 +69,56 @@ func TestSaveNoticeSyncsFilesAndLegacyUpdateInfo(t *testing.T) { } } +func TestSyncFromLegacyUpdateInfoUpdatesNoticeIndex(t *testing.T) { + root := t.TempDir() + public := filepath.Join(root, "public") + noticeDir := filepath.Join(root, "update-notice") + if err := os.MkdirAll(public, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(noticeDir, 0o755); err != nil { + t.Fatal(err) + } + writeJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{"schema_version": 1, "versions": []any{}}) + + cfg := &config.Config{ + StorageDir: filepath.Join(root, "storage"), + UpdatePublicDir: public, + UpdateNoticeDir: noticeDir, + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), + FailoverEnabled: true, + HealthIntervalSec: 3600, + MaxOpenConns: 1, + MaxIdleConns: 1, + ConnMaxLifetimeSeconds: 60, + }, + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + service := NewService(cfg, store) + raw := `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览","download_url":"https://update.ymhut.cn/downloads/app.exe"}` + if err := service.SyncFromLegacyUpdateInfo(context.Background(), raw, "admin"); err != nil { + t.Fatal(err) + } + items, err := service.List(10) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Version != "2.0.7.5" || items[0].Title != "YMhut Box 2.0.7.5" { + t.Fatalf("notice list not synced: %#v", items) + } + total := readJSONFile(t, filepath.Join(noticeDir, "total.json")) + if total["latest_version"] != "2.0.7.5" { + t.Fatalf("total index not updated: %#v", total) + } +} + func writeJSON(t *testing.T, path string, payload any) { t.Helper() data, err := json.Marshal(payload) diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 5361635..d88b5e7 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -61,7 +61,7 @@ func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.S } func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any { - payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")) + payload := s.legacyUpdateBase() manifest := s.Manifest(r) for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} { if value, ok := manifest[key]; ok { @@ -72,7 +72,7 @@ func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any { } func (s *Service) Manifest(r *http.Request) map[string]any { - payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")) + payload := s.legacyUpdateBase() packages := s.ScanPackages(r) modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"] if modules == nil { @@ -116,6 +116,20 @@ func (s *Service) Manifest(r *http.Request) map[string]any { return payload } +func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error { + payload := s.LegacyUpdateInfo(r) + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") + if err := atomicWrite(path, append(data, '\n')); err != nil { + return err + } + _, _ = s.store.SaveLegacyRevision("update-info", string(append(data, '\n')), "generated from release database", firstNonEmpty(actor, "system")) + return nil +} + func setIfMissing(payload map[string]any, key, value string) { if strings.TrimSpace(value) == "" { return @@ -244,7 +258,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error { path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json") - payload := readJSON(path) + payload := s.legacyUpdateBase() payload["app_version"] = pkg.Version payload["download_url"] = pkg.URL payload["package_sha256"] = pkg.SHA256 @@ -265,10 +279,32 @@ func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { - return err + return atomicWrite(path, append(data, '\n')) +} + +func (s *Service) legacyUpdateBase() map[string]any { + payload := map[string]any{} + for _, path := range []string{ + filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"), + filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"), + } { + for key, value := range readJSON(path) { + payload[key] = value + } } - return os.WriteFile(path, append(data, '\n'), 0o640) + if payload["app_version"] == nil { + if value, ok := payload["appVersion"]; ok { + payload["app_version"] = value + } else if value, ok := payload["latestVersion"]; ok { + payload["app_version"] = value + } + } + if payload["manifest_version"] == nil { + if value, ok := payload["manifestVersion"]; ok { + payload["manifest_version"] = value + } + } + return payload } func readJSON(path string) map[string]any { @@ -283,6 +319,30 @@ func readJSON(path string) map[string]any { return payload } +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o640); err != nil { + return err + } + return os.Rename(tmpName, path) +} + func requestBaseURL(r *http.Request, fallback string) string { if r != nil { scheme := r.Header.Get("X-Forwarded-Proto") diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index 598e0f4..272400f 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "time" @@ -44,10 +46,28 @@ type CheckJob struct { LastError string `json:"lastError"` } +type mediaResolution struct { + URL string + Key string + MediaType string + Direct bool +} + +type mediaCandidate struct { + Resolution mediaResolution + Score int + Depth int + Order int +} + type legacyMedia struct { Categories []legacyCategory `json:"categories"` } +const maxSourceProbeBytes int64 = 2 * 1024 * 1024 + +var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`) + type legacyCategory struct { ID string `json:"id"` Name string `json:"name"` @@ -152,6 +172,42 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error { return nil } +func (s *Service) DeleteSourceAndPublishCompatibility(ctx context.Context, sourceID, actor string) error { + sourceID = strings.TrimSpace(sourceID) + if sourceID == "" || strings.ContainsAny(sourceID, `/\`) || strings.Contains(sourceID, "..") { + return errors.New("invalid source id") + } + if _, err := s.store.GetSourceBySourceID(sourceID); err != nil { + return err + } + if err := s.store.DeleteSource(sourceID); err != nil { + return err + } + if err := s.PublishLegacyMediaTypes(ctx, actor); err != nil { + return err + } + _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "source.deleted", Target: sourceID, Message: "客户端接口已删除并同步兼容 media-types.json"}) + return nil +} + +func (s *Service) PublishLegacyMediaTypes(ctx context.Context, actor string) error { + catalog, err := s.Catalog(false) + if err != nil { + return err + } + data, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return err + } + formatted := append(data, '\n') + path := filepath.Join(s.cfg.UpdatePublicDir, "media-types.json") + if err := atomicWrite(path, formatted); err != nil { + return err + } + _, _ = s.store.SaveLegacyRevision("media-types", string(formatted), "generated from source database", firstNonEmpty(actor, "system")) + return nil +} + func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { items, err := s.store.ListSources(includeHidden) if err != nil { @@ -194,6 +250,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { "meta": parseHealthMeta(item.LastError), }, } + applyResolvedFields(sub, item.LastError) cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub) } out := []map[string]any{} @@ -216,7 +273,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { for _, item := range items { var formats []string _ = json.Unmarshal([]byte(item.SupportedFormats), &formats) - out = append(out, map[string]any{ + endpoint := map[string]any{ "id": item.SourceID, "category": item.CategoryID, "name": item.Name, @@ -235,7 +292,9 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { "consecutiveFailure": item.ConsecutiveFailure, "meta": parseHealthMeta(item.LastError), }, - }) + } + applyResolvedFields(endpoint, item.LastError) + out = append(out, endpoint) } return out, nil } @@ -434,6 +493,18 @@ func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, e "error": resp.Status, }) } + if resp.StatusCode < 400 { + meta := parseHealthMeta(message) + meta["finalUrl"] = resp.Request.URL.String() + meta["finalStatus"] = resp.StatusCode + if resolution := resolveMediaFromResponse(resp); resolution.URL != "" { + meta["resolvedUrl"] = resolution.URL + meta["resolvedKey"] = resolution.Key + meta["mediaType"] = resolution.MediaType + meta["directMedia"] = resolution.Direct + } + message = healthMetaMessage(meta) + } if err := s.store.RecordSourceCheck(item.ID, status, latency, message); err != nil { return status, err } @@ -486,6 +557,259 @@ func isHTTPURL(value *url.URL) bool { return scheme == "http" || scheme == "https" } +func resolveMediaFromResponse(resp *http.Response) mediaResolution { + if resp == nil || resp.Request == nil || resp.Request.URL == nil { + return mediaResolution{} + } + finalURL := resp.Request.URL + contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])) + if mediaType := mediaTypeFromContentType(contentType); mediaType != "" || looksLikeMediaURL(finalURL) { + return mediaResolution{URL: finalURL.String(), Key: "response", MediaType: firstNonEmpty(mediaType, mediaTypeFromURL(finalURL)), Direct: true} + } + if !canProbeText(contentType, resp.ContentLength) { + return mediaResolution{} + } + reader := io.LimitReader(resp.Body, maxSourceProbeBytes+1) + data, err := io.ReadAll(reader) + if err != nil || int64(len(data)) > maxSourceProbeBytes { + return mediaResolution{} + } + text := strings.TrimSpace(string(data)) + if text == "" { + return mediaResolution{} + } + var decoded any + if json.Unmarshal(data, &decoded) == nil { + if candidate, ok := bestJSONMediaCandidate(decoded, finalURL); ok { + return candidate.Resolution + } + } + if candidate, ok := bestTextMediaCandidate(text, finalURL); ok { + return candidate.Resolution + } + return mediaResolution{} +} + +func canProbeText(contentType string, length int64) bool { + if length > maxSourceProbeBytes { + return false + } + if contentType == "" || strings.Contains(contentType, "json") { + return true + } + return strings.HasPrefix(contentType, "text/") || + strings.Contains(contentType, "javascript") || + strings.Contains(contentType, "xml") || + strings.Contains(contentType, "form") +} + +func bestJSONMediaCandidate(value any, base *url.URL) (mediaCandidate, bool) { + candidates := []mediaCandidate{} + order := 0 + collectJSONMediaCandidates(value, "", base, 0, &order, &candidates) + return bestCandidate(candidates) +} + +func collectJSONMediaCandidates(value any, key string, base *url.URL, depth int, order *int, candidates *[]mediaCandidate) { + switch typed := value.(type) { + case map[string]any: + for childKey, childValue := range typed { + nextKey := childKey + if key != "" { + nextKey = key + "." + childKey + } + collectJSONMediaCandidates(childValue, nextKey, base, depth+1, order, candidates) + } + case []any: + for _, childValue := range typed { + collectJSONMediaCandidates(childValue, key, base, depth+1, order, candidates) + } + case string: + *order = *order + 1 + if candidate, ok := candidateFromString(key, typed, base, depth, *order); ok { + *candidates = append(*candidates, candidate) + } + } +} + +func bestTextMediaCandidate(text string, base *url.URL) (mediaCandidate, bool) { + candidates := []mediaCandidate{} + matches := absoluteURLPattern.FindAllString(text, 30) + for index, match := range matches { + if candidate, ok := candidateFromString("text", strings.TrimRight(match, ".,);]}'\""), base, 0, index+1); ok { + candidates = append(candidates, candidate) + } + } + return bestCandidate(candidates) +} + +func candidateFromString(key, value string, base *url.URL, depth, order int) (mediaCandidate, bool) { + raw := strings.TrimSpace(value) + if raw == "" { + return mediaCandidate{}, false + } + urls := []string{raw} + if !strings.Contains(raw, "://") { + urls = append(urls, absoluteURLPattern.FindAllString(raw, 10)...) + } + for _, candidate := range urls { + resolved, ok := resolveCandidateURL(candidate, base) + if !ok { + continue + } + mediaType := mediaTypeFromURL(resolved) + if mediaType == "" { + continue + } + keyScore := mediaKeyScore(key) + score := 100 + keyScore - depth + return mediaCandidate{ + Resolution: mediaResolution{ + URL: resolved.String(), + Key: key, + MediaType: mediaType, + }, + Score: score, + Depth: depth, + Order: order, + }, true + } + return mediaCandidate{}, false +} + +func resolveCandidateURL(value string, base *url.URL) (*url.URL, bool) { + value = strings.TrimSpace(strings.Trim(value, `"'`)) + if value == "" { + return nil, false + } + parsed, err := url.Parse(value) + if err != nil { + return nil, false + } + if !parsed.IsAbs() { + if base == nil { + return nil, false + } + parsed = base.ResolveReference(parsed) + } + if !isHTTPURL(parsed) { + return nil, false + } + return parsed, true +} + +func bestCandidate(candidates []mediaCandidate) (mediaCandidate, bool) { + if len(candidates) == 0 { + return mediaCandidate{}, false + } + best := candidates[0] + for _, candidate := range candidates[1:] { + if candidate.Score > best.Score || + (candidate.Score == best.Score && candidate.Depth < best.Depth) || + (candidate.Score == best.Score && candidate.Depth == best.Depth && candidate.Order < best.Order) { + best = candidate + } + } + return best, true +} + +func mediaKeyScore(key string) int { + last := key + if index := strings.LastIndex(last, "."); index >= 0 { + last = last[index+1:] + } + normalized := strings.ToLower(strings.TrimSpace(last)) + switch normalized { + case "url", "src", "image", "img", "pic", "cover", "thumbnail", "video", "file", "media": + return 80 + case "href", "poster", "preview", "download", "play", "audio": + return 60 + } + for _, token := range []string{"url", "src", "image", "img", "pic", "cover", "thumb", "video", "file", "media"} { + if strings.Contains(normalized, token) { + return 40 + } + } + return 0 +} + +func looksLikeMediaURL(value *url.URL) bool { + return mediaTypeFromURL(value) != "" +} + +func mediaTypeFromURL(value *url.URL) string { + if value == nil { + return "" + } + extension := strings.ToLower(strings.TrimPrefix(filepath.Ext(value.Path), ".")) + switch extension { + case "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff": + return "image" + case "mp4", "webm", "m3u8", "mkv", "mov", "m4v", "avi", "wmv": + return "video" + case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma": + return "audio" + default: + return "" + } +} + +func mediaTypeFromContentType(value string) string { + switch { + case strings.HasPrefix(value, "image/"): + return "image" + case strings.HasPrefix(value, "video/") || value == "application/vnd.apple.mpegurl" || value == "application/x-mpegurl": + return "video" + case strings.HasPrefix(value, "audio/"): + return "audio" + default: + return "" + } +} + +func applyResolvedFields(target map[string]any, message string) { + meta := parseHealthMeta(message) + resolvedURL, _ := meta["resolvedUrl"].(string) + resolvedKey, _ := meta["resolvedKey"].(string) + mediaType, _ := meta["mediaType"].(string) + if strings.TrimSpace(resolvedURL) != "" { + target["resolvedUrl"] = resolvedURL + target["resolved_url"] = resolvedURL + } + if strings.TrimSpace(resolvedKey) != "" { + target["resolvedKey"] = resolvedKey + target["resolved_key"] = resolvedKey + } + if strings.TrimSpace(mediaType) != "" { + target["mediaType"] = mediaType + target["media_type"] = mediaType + } +} + +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o640); err != nil { + return err + } + return os.Rename(tmpName, path) +} + func healthMetaMessage(meta map[string]any) string { data, err := json.Marshal(meta) if err != nil { diff --git a/server/unified-management/internal/sources/sources_test.go b/server/unified-management/internal/sources/sources_test.go index 69cb26f..6106f56 100644 --- a/server/unified-management/internal/sources/sources_test.go +++ b/server/unified-management/internal/sources/sources_test.go @@ -2,6 +2,7 @@ package sources import ( "context" + "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -119,6 +120,115 @@ func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) { assertEvent("subscriber B", eventsB) } +func TestCheckOneResolvesNestedJSONMediaURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "data": map[string]any{ + "ignored": "https://example.test/readme.txt", + "items": []map[string]any{ + {"name": "first"}, + {"cover": "/media/poster.webp"}, + }, + }, + }) + })) + defer server.Close() + cfg, store := testStore(t) + service := NewService(cfg, store) + item, err := store.UpsertSource(db.Source{ + CategoryID: "image", + CategoryName: "Image", + SourceID: "json-cover", + Name: "JSON Cover", + Method: "GET", + APIURL: server.URL + "/api/random", + 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("json-cover") + if err != nil { + t.Fatal(err) + } + meta := parseHealthMeta(checked.LastError) + if meta["resolvedUrl"] != server.URL+"/media/poster.webp" { + t.Fatalf("resolvedUrl = %#v, want relative media URL", meta["resolvedUrl"]) + } + if meta["resolvedKey"] != "data.items.cover" { + t.Fatalf("resolvedKey = %#v", meta["resolvedKey"]) + } + if meta["mediaType"] != "image" { + t.Fatalf("mediaType = %#v, want image", meta["mediaType"]) + } + catalog, err := service.Catalog(false) + if err != nil { + t.Fatal(err) + } + categories := catalog["categories"].([]map[string]any) + sub := categories[0]["subcategories"].([]map[string]any)[0] + if sub["resolvedUrl"] != server.URL+"/media/poster.webp" { + t.Fatalf("catalog resolvedUrl = %#v", sub["resolvedUrl"]) + } + endpoints, err := service.Endpoints(false) + if err != nil { + t.Fatal(err) + } + if endpoints[0]["resolvedUrl"] != server.URL+"/media/poster.webp" { + t.Fatalf("endpoint resolvedUrl = %#v", endpoints[0]["resolvedUrl"]) + } + if endpoints[0]["urlTemplate"] != server.URL+"/api/random" { + t.Fatalf("urlTemplate changed: %#v", endpoints[0]["urlTemplate"]) + } +} + +func TestCheckOneResolvesTextMediaURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(`play: https://cdn.example.test/video/sample.mp4`)) + })) + defer server.Close() + cfg, store := testStore(t) + service := NewService(cfg, store) + item, err := store.UpsertSource(db.Source{ + CategoryID: "video", + CategoryName: "Video", + SourceID: "text-video", + Name: "Text Video", + Method: "GET", + APIURL: server.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("text-video") + if err != nil { + t.Fatal(err) + } + meta := parseHealthMeta(checked.LastError) + if meta["resolvedUrl"] != "https://cdn.example.test/video/sample.mp4" { + t.Fatalf("resolvedUrl = %#v", meta["resolvedUrl"]) + } + if meta["mediaType"] != "video" { + t.Fatalf("mediaType = %#v, want video", meta["mediaType"]) + } +} + func testStore(t *testing.T) (*config.Config, *db.Store) { t.Helper() dir := t.TempDir() diff --git a/server/unified-management/internal/web/admin_feedback_routes.go b/server/unified-management/internal/web/admin_feedback_routes.go index 5344932..5c72550 100644 --- a/server/unified-management/internal/web/admin_feedback_routes.go +++ b/server/unified-management/internal/web/admin_feedback_routes.go @@ -117,10 +117,21 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment}) return } + if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/mail/retry") { + code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/mail/retry") + if err := r.feedback.RetryMail(code); err != nil { + writeError(w, http.StatusBadGateway, "MAIL_RETRY_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "feedback.mail.retry", Target: code, Message: "反馈邮件已重试发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") { code := strings.TrimPrefix(path, "/api/admin/feedbacks/") var body struct { Status string `json:"status"` + Priority string `json:"priority"` StatusDetail string `json:"statusDetail"` PublicReply string `json:"publicReply"` } @@ -128,7 +139,7 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) return } - if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil { + if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), Priority: body.Priority, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil { writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err) return } diff --git a/server/unified-management/internal/web/admin_legacy_routes.go b/server/unified-management/internal/web/admin_legacy_routes.go index 78883dd..bf46c23 100644 --- a/server/unified-management/internal/web/admin_legacy_routes.go +++ b/server/unified-management/internal/web/admin_legacy_routes.go @@ -50,6 +50,9 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) { if name == "media-types" { _ = r.sources.ImportLegacyMediaTypes(req.Context()) } + if name == "update-info" { + r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw) + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc}) return } @@ -83,8 +86,18 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) { if name == "media-types" { _ = r.sources.ImportLegacyMediaTypes(req.Context()) } + if name == "update-info" { + r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw) + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc}) return } http.NotFound(w, req) } + +func (r *router) syncNoticeFromLegacyUpdateInfo(req *http.Request, raw string) { + if r.notices == nil { + return + } + _ = r.notices.SyncFromLegacyUpdateInfo(req.Context(), raw, "admin") +} diff --git a/server/unified-management/internal/web/admin_source_routes.go b/server/unified-management/internal/web/admin_source_routes.go index d9ea229..d852731 100644 --- a/server/unified-management/internal/web/admin_source_routes.go +++ b/server/unified-management/internal/web/admin_source_routes.go @@ -56,13 +56,17 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) { 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.store.DeleteSource(sourceID); err != nil { + 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) diff --git a/server/unified-management/internal/web/admin_system_routes.go b/server/unified-management/internal/web/admin_system_routes.go index e135684..91f324a 100644 --- a/server/unified-management/internal/web/admin_system_routes.go +++ b/server/unified-management/internal/web/admin_system_routes.go @@ -4,38 +4,57 @@ import ( "encoding/json" "errors" "net/http" + "path/filepath" + "strconv" "time" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/health" + feedbackmail "ymhut-box/server/unified-management/internal/mail" ) func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) switch { + case req.Method == http.MethodGet && path == "/api/admin/database/config": + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)}) case req.Method == http.MethodGet && path == "/api/admin/database/status": - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()}) + 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/test": - var body config.DatabaseConfig - if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true) + if err != nil { writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) return } - if body.Provider == "" { - body.Provider = r.cfg.Database.Provider - } - if body.SQLitePath == "" { - body.SQLitePath = r.cfg.Database.SQLitePath - } - if body.MySQLDSN == "" { - body.MySQLDSN = r.cfg.Database.MySQLDSN - } if err := db.TestDatabase(body); err != nil { writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, body)}) + case req.Method == http.MethodPost && path == "/api/admin/database/save": + body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) + return + } + if err := db.TestDatabase(body); err != nil { + writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err) + return + } + next := *r.cfg + next.Database = body + if err := config.Save(&next); err != nil { + writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err) + return + } + r.cfg.Database = next.Database + if err := r.store.ReconfigureDatabase(r.cfg); err != nil { + writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err) + return + } + _ = 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/import-sqlite": result, err := r.store.ImportSQLiteToRemote() if err != nil { @@ -55,6 +74,52 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) { } } +type adminDatabaseRequest struct { + Provider string `json:"provider"` + SQLitePath string `json:"sqlite_path"` + SQLitePathAlt string `json:"sqlitePath"` + MySQLDSN string `json:"mysql_dsn"` + MySQLDSNAlt string `json:"mysqlDsn"` + MySQLHost string `json:"mysql_host"` + MySQLHostAlt string `json:"mysqlHost"` + MySQLPort int `json:"mysql_port"` + MySQLPortAlt int `json:"mysqlPort"` + MySQLDatabase string `json:"mysql_database"` + MySQLDBAlt string `json:"mysqlDatabase"` + MySQLUser string `json:"mysql_user"` + MySQLUserAlt string `json:"mysqlUser"` + MySQLPassword string `json:"mysql_password"` + MySQLPassAlt string `json:"mysqlPassword"` + MySQL config.MySQLInput `json:"mysql"` +} + +func decodeAdminDatabaseConfig(req *http.Request, baseDir string, current config.DatabaseConfig, keepPassword bool) (config.DatabaseConfig, error) { + var body adminDatabaseRequest + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return config.DatabaseConfig{}, err + } + incoming := config.DatabaseConfig{ + Provider: body.Provider, + SQLitePath: firstNonEmpty(body.SQLitePath, body.SQLitePathAlt), + MySQLDSN: firstNonEmpty(body.MySQLDSN, body.MySQLDSNAlt), + MySQLHost: firstNonEmpty(body.MySQLHost, body.MySQLHostAlt, body.MySQL.Host), + MySQLPort: firstPositive(body.MySQLPort, body.MySQLPortAlt, body.MySQL.Port), + MySQLDatabase: firstNonEmpty(body.MySQLDatabase, body.MySQLDBAlt, body.MySQL.Database), + MySQLUser: firstNonEmpty(body.MySQLUser, body.MySQLUserAlt, body.MySQL.Username), + MySQLPassword: firstNonEmpty(body.MySQLPassword, body.MySQLPassAlt, body.MySQL.Password), + } + return config.NormalizeDatabase(baseDir, current, incoming, keepPassword) +} + +func firstPositive(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" { @@ -141,12 +206,26 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { case "/api/admin/system/health": writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store)) case "/api/admin/system/audit": - items, err := r.store.ListAuditLogs(100) + page, err := r.store.ListAuditLogsPage(db.AuditFilters{ + Page: queryInt(req, "page", 1), + PerPage: queryInt(req, "perPage", 35), + Type: req.URL.Query().Get("type"), + Target: req.URL.Query().Get("target"), + Query: req.URL.Query().Get("q"), + }) if err != nil { writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items}) + 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": + r.handleMailTest(w, req) + case "/api/admin/system/branding": + r.handleBranding(w, req) + case "/api/admin/system/migration": + r.handleMigrationStatus(w, req) case "/api/admin/system/database/sync": if req.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) @@ -162,3 +241,188 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { http.NotFound(w, req) } } + +func queryInt(req *http.Request, key string, fallback int) int { + value, err := strconv.Atoi(req.URL.Query().Get(key)) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required")) + return + } + status := r.store.Status() + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "migration": map[string]any{ + "strategy": "database_first_with_file_assets", + "databaseCovers": []string{ + "系统设置与品牌", + "管理员与会话元数据", + "反馈工单、附件元数据与邮件记录", + "来源目录、客户端接口与健康记录", + "发布元数据、版本公告与兼容 JSON 修订", + "审计日志、旧项目同步记录与数据库同步状态", + }, + "fileAssets": []map[string]string{ + {"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"}, + {"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"}, + {"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback-packages"), "description": "反馈附件包"}, + }, + "sqlitePath": r.store.Path(), + "mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database), + "lastSyncAt": status.LastSyncAt, + "lastSyncError": status.LastSyncError, + "activeProvider": status.ActiveProvider, + }, + }) +} + +func (r *router) handleBranding(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())}) + case http.MethodPost: + var body struct { + SiteIconURL string `json:"siteIconUrl"` + SiteIconURLSnake string `json:"site_icon_url"` + DeveloperAvatarURL string `json:"developerAvatarUrl"` + DeveloperAvatarAlt string `json:"developer_avatar_url"` + DeveloperName string `json:"developerName"` + DeveloperNameSnake string `json:"developer_name"` + FeedbackEmail string `json:"feedbackEmail"` + FeedbackEmailSnake string `json:"feedback_email"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) + return + } + next := config.BrandingConfig{ + SiteIconURL: firstNonEmpty(body.SiteIconURL, body.SiteIconURLSnake), + DeveloperAvatarURL: firstNonEmpty(body.DeveloperAvatarURL, body.DeveloperAvatarAlt), + DeveloperName: firstNonEmpty(body.DeveloperName, body.DeveloperNameSnake), + FeedbackEmail: firstNonEmpty(body.FeedbackEmail, body.FeedbackEmailSnake), + } + if err := r.saveBranding(next); err != nil { + writeError(w, http.StatusInternalServerError, "BRANDING_SAVE_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.branding.saved", Target: r.cfg.Branding.DeveloperName, Message: "站点品牌信息已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())}) + default: + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required")) + } +} + +func (r *router) handleMailConfig(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)}) + case http.MethodPost: + nextMail, err := decodeMailConfig(req, r.cfg.Mail) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) + return + } + next := *r.cfg + next.Mail = nextMail + if err := config.Save(&next); err != nil { + writeError(w, http.StatusInternalServerError, "MAIL_CONFIG_FAILED", err) + return + } + r.cfg.Mail = next.Mail + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.saved", Target: nextMail.Host, Message: "邮件通知配置已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)}) + default: + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required")) + } +} + +func (r *router) handleMailTest(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) + return + } + message, err := feedbackmail.BuildTestMessage(r.cfg) + if err != nil { + writeError(w, http.StatusBadRequest, "MAIL_TEST_FAILED", err) + return + } + if err := feedbackmail.Send(r.cfg, message); err != nil { + writeError(w, http.StatusBadGateway, "MAIL_TEST_FAILED", err) + return + } + _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.test", Target: message.To, Message: "测试邮件已发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +type mailConfigRequest struct { + Host string `json:"host"` + Port int `json:"port"` + Secure string `json:"secure"` + Username string `json:"username"` + Password string `json:"password"` + FromAddress string `json:"from_address"` + FromAddressAlt string `json:"fromAddress"` + FromName string `json:"from_name"` + FromNameAlt string `json:"fromName"` + DeveloperAddress string `json:"developer_address"` + DeveloperAlt string `json:"developerAddress"` + TimeoutSeconds int `json:"timeout_seconds"` + TimeoutAlt int `json:"timeoutSeconds"` +} + +func decodeMailConfig(req *http.Request, current config.MailConfig) (config.MailConfig, error) { + var body mailConfigRequest + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return config.MailConfig{}, err + } + next := current + if body.Host != "" { + next.Host = body.Host + } + if body.Port > 0 { + next.Port = body.Port + } + if body.Secure != "" { + next.Secure = body.Secure + } + if body.Username != "" { + next.Username = body.Username + } + if body.Password != "" { + next.Password = body.Password + } + if value := firstNonEmpty(body.FromAddress, body.FromAddressAlt); value != "" { + next.FromAddress = value + } + if value := firstNonEmpty(body.FromName, body.FromNameAlt); value != "" { + next.FromName = value + } + if value := firstNonEmpty(body.DeveloperAddress, body.DeveloperAlt); value != "" { + next.DeveloperAddress = value + } + if timeout := firstPositive(body.TimeoutSeconds, body.TimeoutAlt); timeout > 0 { + next.TimeoutSeconds = timeout + } + if next.Port <= 0 { + next.Port = 465 + } + if next.Secure == "" { + next.Secure = "ssl" + } + if next.FromName == "" { + next.FromName = "YMhut Box Feedback" + } + if next.FromAddress == "" { + next.FromAddress = next.Username + } + if next.TimeoutSeconds <= 0 { + next.TimeoutSeconds = 20 + } + return next, nil +} diff --git a/server/unified-management/internal/web/branding.go b/server/unified-management/internal/web/branding.go new file mode 100644 index 0000000..b1722b9 --- /dev/null +++ b/server/unified-management/internal/web/branding.go @@ -0,0 +1,42 @@ +package web + +import ( + "encoding/json" + + "ymhut-box/server/unified-management/internal/config" +) + +const brandingSettingKey = "branding" + +func (r *router) effectiveBranding() config.BrandingConfig { + branding := config.NormalizeBranding(config.BrandingConfig{}, r.cfg.Branding) + if r.store == nil { + return branding + } + raw, err := r.store.GetSetting(brandingSettingKey) + if err != nil || raw == "" { + return branding + } + var stored config.BrandingConfig + if json.Unmarshal([]byte(raw), &stored) != nil { + return branding + } + return config.NormalizeBranding(branding, stored) +} + +func (r *router) saveBranding(branding config.BrandingConfig) error { + branding = config.NormalizeBranding(r.cfg.Branding, branding) + next := *r.cfg + next.Branding = branding + next.Mail.DeveloperAddress = firstNonEmpty(next.Mail.DeveloperAddress, branding.FeedbackEmail) + if err := config.Save(&next); err != nil { + return err + } + r.cfg.Branding = next.Branding + r.cfg.Mail = next.Mail + data, err := json.Marshal(r.cfg.Branding) + if err != nil { + return err + } + return r.store.UpsertSetting(brandingSettingKey, string(data)) +} diff --git a/server/unified-management/internal/web/client_routes.go b/server/unified-management/internal/web/client_routes.go index 3d949db..2df7d21 100644 --- a/server/unified-management/internal/web/client_routes.go +++ b/server/unified-management/internal/web/client_routes.go @@ -42,6 +42,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) "release": release, "sources": sourceCatalog, "feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"}, + "branding": config.SafeBranding(r.effectiveBranding()), "health": health.Snapshot(r.cfg, r.store), }) } diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go index 875a413..1f5cb3f 100644 --- a/server/unified-management/internal/web/response.go +++ b/server/unified-management/internal/web/response.go @@ -60,10 +60,18 @@ func localizedErrorMessage(code, message string) string { "database is not available": "数据库当前不可用", "provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL", "mysql connection is required": "请填写 MySQL 连接信息", + "mysql database is required": "请填写 MySQL 数据库名", + "mysql username is required": "请填写 MySQL 数据库用户", "sqlite path is required": "请填写 SQLite 路径", "mysql_dsn is required": "请填写 MySQL DSN", + "remote database is not configured": "远端 MySQL 未配置", + "database sync is already running": "数据库同步正在执行,请稍后再试", + "mail is not configured": "邮件通知尚未配置完整", "release notices are not configured": "版本日志功能尚未配置", "legacy sync service is not configured": "旧项目同步服务尚未配置", + "update-info requires app_version or title": "更新 JSON 需要填写 app_version 或 title", + "media-types requires categories array": "媒体源 JSON 需要包含 categories 数组", + "version or app_version is required": "版本日志需要填写 version 或 app_version", } if translated, ok := exact[lower]; ok { return translated @@ -74,6 +82,7 @@ func localizedErrorMessage(code, message string) string { "PASSWORD_CHANGE_FAILED": "密码修改失败", "INVALID_PAYLOAD": "提交内容格式不正确", "DATABASE_TEST_FAILED": "数据库连接测试失败", + "DATABASE_SAVE_FAILED": "数据库配置保存失败", "DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败", "DATABASE_SYNC_FAILED": "远端库同步回本地失败", "LEGACY_SAVE_FAILED": "兼容 JSON 保存失败", @@ -97,6 +106,9 @@ func localizedErrorMessage(code, message string) string { "AUDIT_FAILED": "审计日志加载失败", "FEEDBACK_LIST_FAILED": "反馈列表加载失败", "FEEDBACK_UPDATE_FAILED": "反馈工单更新失败", + "MAIL_CONFIG_FAILED": "邮件配置保存失败", + "MAIL_TEST_FAILED": "测试邮件发送失败", + "MAIL_RETRY_FAILED": "反馈邮件重试失败", "NOTICE_NOT_FOUND": "未找到版本日志", "NOTICES_FAILED": "版本日志加载失败", "MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败", diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index 2ea6fa8..ca01973 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -130,12 +130,111 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) { } continue } + if path == "/api/client/bootstrap" { + branding, ok := payload["branding"].(map[string]any) + if !ok { + t.Fatalf("bootstrap missing branding: %#v", payload) + } + if branding["developerName"] != "YMhut" || branding["feedbackEmail"] != "support@ymhut.cn" { + t.Fatalf("unexpected branding defaults: %#v", branding) + } + } if payload["ok"] != true { t.Fatalf("%s missing ok=true: %#v", path, payload) } } } +func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/demo", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + req.Header.Set("X-CSRF-Token", csrf) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("delete source returned %d: %s", res.Code, res.Body.String()) + } + + mediaReq := httptest.NewRequest(http.MethodGet, "/media-types.json", nil) + mediaRes := httptest.NewRecorder() + handler.ServeHTTP(mediaRes, mediaReq) + if mediaRes.Code != http.StatusOK { + t.Fatalf("media-types returned %d: %s", mediaRes.Code, mediaRes.Body.String()) + } + if strings.Contains(mediaRes.Body.String(), `"demo"`) { + t.Fatalf("deleted source leaked into media-types.json: %s", mediaRes.Body.String()) + } + + updateReq := httptest.NewRequest(http.MethodGet, "/update-info.json", nil) + updateRes := httptest.NewRecorder() + handler.ServeHTTP(updateRes, updateReq) + if updateRes.Code != http.StatusOK { + t.Fatalf("update-info returned %d: %s", updateRes.Code, updateRes.Body.String()) + } + var updatePayload map[string]any + if err := json.Unmarshal(updateRes.Body.Bytes(), &updatePayload); err != nil { + t.Fatal(err) + } + assertJSONKeys(t, "update-info after source delete", updatePayload, []string{"app_version", "manifest_version", "packages", "modules"}) +} + +func TestAdminAuditPaginationAndBranding(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + for i := 0; i < 40; i++ { + body := strings.NewReader(`{"developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`) + req := httptest.NewRequest(http.MethodPost, "/api/admin/system/branding", body) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + req.Header.Set("X-CSRF-Token", csrf) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("branding save %d returned %d: %s", i, res.Code, res.Body.String()) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/system/audit?page=1&perPage=35&type=system.branding.saved", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("audit returned %d: %s", res.Code, res.Body.String()) + } + var payload struct { + Items []any `json:"items"` + Page struct { + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"perPage"` + } `json:"page"` + } + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Page.Page != 1 || payload.Page.PerPage != 35 { + t.Fatalf("unexpected audit page metadata: %#v", payload.Page) + } + if payload.Page.Total < 40 { + t.Fatalf("expected at least 40 branding audit records, got %d", payload.Page.Total) + } + if len(payload.Items) > 35 { + t.Fatalf("expected at most 35 audit items, got %d", len(payload.Items)) + } +} + func TestLegacyFeedbackStatusDTOContract(t *testing.T) { payload := legacyFeedbackStatus(db.Feedback{ Code: "FB-20260626-ABCDEF", @@ -419,6 +518,80 @@ func TestAdminLegacyRequiresAuth(t *testing.T) { } } +func TestAdminLegacyUpdateInfoSyncsReleaseNotice(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]string{ + "raw": `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览"}`, + }) + req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/update-info", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("save update-info returned %d: %s", res.Code, res.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/admin/releases/notices", nil) + listReq.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + listRes := httptest.NewRecorder() + handler.ServeHTTP(listRes, listReq) + if listRes.Code != http.StatusOK { + t.Fatalf("notice list returned %d: %s", listRes.Code, listRes.Body.String()) + } + var payload struct { + Items []struct { + Version string `json:"version"` + Title string `json:"title"` + } `json:"items"` + } + if err := json.Unmarshal(listRes.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + found := false + for _, item := range payload.Items { + if item.Version == "2.0.7.5" && item.Title == "YMhut Box 2.0.7.5" { + found = true + } + } + if !found { + t.Fatalf("synced release notice not found: %#v", payload.Items) + } +} + +func TestAdminLegacyValidationErrorIsChinese(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]string{"raw": `{"message":"missing version and title"}`}) + req := httptest.NewRequest(http.MethodPost, "/api/admin/legacy/update-info/validate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusBadRequest { + t.Fatalf("expected validation failure, got %d: %s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), "update-info requires app_version or title") { + t.Fatalf("english validation leaked: %s", res.Body.String()) + } + if !strings.Contains(res.Body.String(), "更新 JSON 需要填写 app_version 或 title") { + t.Fatalf("missing chinese validation message: %s", res.Body.String()) + } +} + func TestAdminWriteRequiresCSRF(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() @@ -599,9 +772,12 @@ func testRouter(t *testing.T) (http.Handler, func()) { }) mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"}) cfg := &config.Config{ + BaseDir: root, + ConfigPath: filepath.Join(root, "config.json"), Listen: ":0", BaseURL: "https://update.ymhut.cn", StorageDir: filepath.Join(root, "storage"), + DataDir: filepath.Join(root, "data"), UpdatePublicDir: public, UpdateNoticeDir: noticeDir, DownloadsDir: filepath.Join(public, "downloads"), diff --git a/server/unified-management/internal/web/setup.go b/server/unified-management/internal/web/setup.go index e09a0af..ff86a6b 100644 --- a/server/unified-management/internal/web/setup.go +++ b/server/unified-management/internal/web/setup.go @@ -3,11 +3,8 @@ package web import ( "encoding/json" "errors" - "fmt" "net/http" - "net/url" "path/filepath" - "strconv" "strings" "time" @@ -24,18 +21,7 @@ type setupRequest struct { BaseURL string `json:"baseUrl"` SQLitePath string `json:"sqlitePath"` MySQLDSN string `json:"mysqlDsn"` - MySQL setupMySQLConfig `json:"mysql"` -} - -type setupMySQLConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Database string `json:"database"` - Username string `json:"username"` - Password string `json:"password"` - Charset string `json:"charset"` - ParseTime bool `json:"parseTime"` - TLS string `json:"tls"` + MySQL config.MySQLInput `json:"mysql"` } func NewSetupRouter(cfg *config.Config) http.Handler { @@ -73,7 +59,7 @@ func (r *setupRouter) status() map[string]any { "defaults": map[string]any{ "provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"), "sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath), - "mysqlDsn": maskDSN(r.cfg.Database.MySQLDSN), + "mysqlDsn": config.MaskDSN(r.cfg.Database.MySQLDSN), "baseUrl": r.cfg.BaseURL, }, } @@ -103,7 +89,7 @@ func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Reques "provider": next.Provider, "baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL), "sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath), - "mysqlDsn": maskDSN(next.MySQLDSN), + "mysqlDsn": config.MaskDSN(next.MySQLDSN), }, }) } @@ -153,44 +139,18 @@ func (r *setupRouter) decodeSetupDatabase(req *http.Request) (config.DatabaseCon if err := json.NewDecoder(req.Body).Decode(&body); err != nil { return config.DatabaseConfig{}, body, err } - next := r.cfg.Database - next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite"))) - if body.SQLitePath != "" { - next.SQLitePath = body.SQLitePath + incoming := config.DatabaseConfig{ + Provider: body.Provider, + SQLitePath: body.SQLitePath, + MySQLDSN: body.MySQLDSN, + MySQLHost: body.MySQL.Host, + MySQLPort: body.MySQL.Port, + MySQLDatabase: body.MySQL.Database, + MySQLUser: body.MySQL.Username, + MySQLPassword: body.MySQL.Password, } - if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") { - next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath) - } - if next.Provider == "sqlite" { - next.MySQLDSN = "" - } else if body.MySQLDSN != "" { - next.MySQLDSN = body.MySQLDSN - } else if body.MySQL.Host != "" || body.MySQL.Database != "" || body.MySQL.Username != "" { - dsn, err := buildMySQLDSN(body.MySQL) - if err != nil { - return config.DatabaseConfig{}, body, err - } - next.MySQLDSN = dsn - } - if next.Provider != "sqlite" && next.Provider != "mysql" { - return config.DatabaseConfig{}, body, errors.New("provider must be sqlite or mysql") - } - if next.Provider == "mysql" && strings.TrimSpace(next.MySQLDSN) == "" { - return config.DatabaseConfig{}, body, errors.New("mysql connection is required") - } - if next.MaxOpenConns <= 0 { - next.MaxOpenConns = 10 - } - if next.MaxIdleConns <= 0 { - next.MaxIdleConns = 4 - } - if next.ConnMaxLifetimeSeconds <= 0 { - next.ConnMaxLifetimeSeconds = 300 - } - if next.HealthIntervalSec <= 0 { - next.HealthIntervalSec = 30 - } - return next, body, nil + next, err := config.NormalizeDatabase(r.cfg.BaseDir, r.cfg.Database, incoming, false) + return next, body, err } func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) { @@ -205,48 +165,9 @@ func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(`YMhut Setup

YMhut Setup

Setup frontend is not built. Run npm install && npm run build in web/setup.

` + index + `

`)) } -func buildMySQLDSN(input setupMySQLConfig) (string, error) { - host := strings.TrimSpace(input.Host) - if host == "" { - host = "127.0.0.1" - } - port := input.Port - if port <= 0 { - port = 3306 - } - database := strings.TrimSpace(input.Database) - username := strings.TrimSpace(input.Username) - if database == "" { - return "", errors.New("mysql database is required") - } - if username == "" { - return "", errors.New("mysql username is required") - } - params := url.Values{} - params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4")) - params.Set("parseTime", strconv.FormatBool(input.ParseTime)) - if tls := strings.TrimSpace(input.TLS); tls != "" { - params.Set("tls", tls) - } - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil -} - -func maskDSN(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - at := strings.Index(value, "@") - colon := strings.Index(value, ":") - if at > -1 && colon > -1 && colon < at { - return value[:colon+1] + "******" + value[at:] - } - return value -} - func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string { if strings.EqualFold(cfg.Provider, "mysql") { - return maskDSN(cfg.MySQLDSN) + return config.MaskDSN(cfg.MySQLDSN) } return relativeToBase(base, cfg.SQLitePath) } diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue index c8048e8..f679e10 100644 --- a/server/unified-management/web/admin/src/App.vue +++ b/server/unified-management/web/admin/src/App.vue @@ -31,8 +31,11 @@ import { createSystemStore } from "./stores/system"; const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue")); -type SystemTab = "database" | "sync" | "security" | "health" | "audit"; +type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "audit"; type ToastState = { message: string; type: "success" | "warn" | "error" }; +type LoadSystemOptions = { preserveForms?: boolean }; +type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean }; +type LoadMailOptions = { preserveForm?: boolean }; type Captcha = { captchaId: string; @@ -58,6 +61,8 @@ const currentPath = computed(() => normalizeAdminPath(route.path)); const loading = ref(false); const toast = ref(null); const autoRefreshPaused = ref(false); +const databaseFormEditing = ref(false); +const mailConfigEditing = ref(false); let refreshTimer: number | undefined; let toastTimer: number | undefined; let events: EventSource | null = null; @@ -74,9 +79,9 @@ const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = aut const { dashboard, sourceCheckJobs } = dashboardStore; const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore; const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore; -const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts } = legacyStore; +const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore; const { sources, endpoints, draft: sourceDraft } = sourceStore; -const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore; +const { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore; const routes: RouteItem[] = [ { path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard }, @@ -114,25 +119,59 @@ const visibleEndpointCount = computed(() => endpoints.value.filter((item) => ite const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length); const latestNotice = computed(() => releaseNotices.value[0] || null); const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json"); +const activeMediaCategory = computed(() => { + const categories = legacyDrafts["media-types"].form.categories || []; + return categories[activeMediaCategoryIndex.value] || null; +}); const systemTab = computed(() => normalizeSystemTab(route.query.tab)); +const heartbeatChartRows = computed(() => { + const rows = heartbeats.value + .slice() + .reverse() + .map((item: any) => ({ + time: heartbeatTimeValue(item.checkedAt), + label: timeLabel(item.checkedAt), + latency: Number(item.latencyMs ?? item.latency_ms ?? 0), + name: item.name || item.sourceId || "未知接口", + status: labelStatus(item.status), + })) + .filter((item: any) => Number.isFinite(item.latency)); + return rows.length ? rows : [{ time: Date.now(), label: "暂无", latency: 0, name: "暂无检测记录", status: "未检测" }]; +}); +const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0); const heartbeatOption = computed(() => ({ + animation: true, tooltip: { trigger: "axis" }, - grid: { left: 44, right: 18, top: 28, bottom: 34 }, + grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true }, xAxis: { type: "category", - data: heartbeats.value.slice().reverse().map((item: any) => timeLabel(item.checkedAt)), + boundaryGap: heartbeatChartRows.value.length <= 1, + data: heartbeatChartRows.value.map((item: any) => item.label), axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisLabel: { color: "#64748b" }, + }, + yAxis: { + type: "value", + name: "ms", + min: 0, + axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisLabel: { color: "#64748b" }, + splitLine: { lineStyle: { color: "#e5e7eb" } }, }, - yAxis: { type: "value", name: "ms", axisLine: { lineStyle: { color: "#cbd5e1" } }, splitLine: { lineStyle: { color: "#e5e7eb" } } }, series: [ { name: "接口延迟", type: "line", smooth: true, + showSymbol: true, + symbolSize: 7, + connectNulls: true, areaStyle: { opacity: 0.18 }, - data: heartbeats.value.slice().reverse().map((item: any) => item.latencyMs || 0), + data: heartbeatChartRows.value.map((item: any) => item.latency), color: "#2563eb", + lineStyle: { width: 3 }, + emphasis: { focus: "series" }, }, ], })); @@ -199,19 +238,29 @@ const viewContext = computed(() => ({ addMediaCategory, addMediaSubcategory, addUpdateMirror, + applyLegacyModal, + auditPage, auditLogs: auditLogs.value, autoRefreshPaused: autoRefreshPaused.value, availabilityOption: availabilityOption.value, + branding, changePassword, checkSources, clientCalls: clientCalls.value, commentDraft, copyEndpointToSource, database: database.value, + databaseConfig: databaseConfig.value, + databaseConfigCollapsed: databaseConfigCollapsed.value, + databaseFormEditing: databaseFormEditing.value, databaseForm, databaseLastSync: databaseLastSync.value, + databaseSyncStatusLabel, databaseSyncDirectionLabel, databaseSyncTableCount, + databaseConfigSummary, + deleteEndpoint, + editDatabaseConfig, endpointStatus, endpoints: endpoints.value, feedbackFilters, @@ -224,16 +273,36 @@ const viewContext = computed(() => ({ healthyEndpointCount: healthyEndpointCount.value, heartbeatOption: heartbeatOption.value, heartbeats: heartbeats.value, + isHeartbeatChartEmpty: isHeartbeatChartEmpty.value, importNotices, kpis: kpis.value, labelStatus, + labelPriority, latestNotice: latestNotice.value, legacyDocuments, legacyDrafts, + legacyModal, + activeMediaCategoryIndex: activeMediaCategoryIndex.value, + activeMediaCategory: activeMediaCategory.value, legacySync: legacySync.value, legacySyncMode: legacySyncMode.value, loadAudit, + loadBranding, loadFeedbacks, + loadMigrationStatus, + mailConfig, + mailConfigEditing: mailConfigEditing.value, + markDatabaseFormEditing, + markMailConfigEditing, + migrationStatus: migrationStatus.value, + loadMailConfig, + reloadDatabaseConfig, + reloadMailConfig, + saveDatabase, + saveBranding, + saveMailConfig, + testMail, + retryFeedbackMail, navigate, noticeDraft, onPackageSelected, @@ -262,8 +331,15 @@ const viewContext = computed(() => ({ syncDatabase, systemTab: systemTab.value, setSystemTab, + setAuditPage, + selectAuditLog, testDatabase, toggleAutoRefresh, + openMediaCategoryModal, + openMediaSubcategoryModal, + openUpdateMirrorModal, + selectMediaCategory, + closeLegacyModal, updateLegacyRawFromForm, uploadDraft, uploadPackage, @@ -291,7 +367,7 @@ function normalizeAdminPath(value: string) { function normalizeSystemTab(value: unknown): SystemTab { const tab = Array.isArray(value) ? value[0] : value; - if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab; + if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab; return "database"; } @@ -392,7 +468,7 @@ async function load() { if (currentPath.value === "/admin/releases") await loadReleases(); if (currentPath.value === "/admin/sources") await loadSources(); if (currentPath.value === "/admin/endpoints") await loadEndpoints(); - if (currentPath.value === "/admin/system") await loadSystem(); + if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true }); const legacyName = activeLegacyName.value; if (legacyName) await loadLegacy(legacyName); connectAdminEvents(); @@ -403,14 +479,22 @@ async function loadDashboard() { dashboard.value = await api("/api/admin/dashboard/overview?window=24h"); } -async function loadSystem() { - await Promise.all([loadDatabase(), loadHealth(), loadAudit()]); +async function loadSystem(options: LoadSystemOptions = {}) { + await Promise.all([ + loadDatabase({ preserveForm: options.preserveForms }), + loadMailConfig({ preserveForm: options.preserveForms }), + loadHealth(), + loadAudit(), + loadMigrationStatus(), + loadBranding(), + ]); } async function loadFeedbacks() { const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) }); if (feedbackFilters.q) params.set("q", feedbackFilters.q); if (feedbackFilters.status) params.set("status", feedbackFilters.status); + if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority); const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`); feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 }; } @@ -419,6 +503,7 @@ async function openFeedback(item: any) { const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`); selectedFeedback.value = data.feedback; feedbackUpdate.status = data.feedback.status || "new"; + feedbackUpdate.priority = data.feedback.priority || "normal"; feedbackUpdate.statusDetail = data.feedback.statusDetail || ""; feedbackUpdate.publicReply = data.feedback.publicReply || ""; } @@ -445,14 +530,17 @@ async function addFeedbackComment() { }); } -async function loadReleases() { +async function loadReleases(preferredVersion = noticeDraft.version) { const [releaseData, noticeData] = await Promise.all([ api<{ manifest: any }>("/api/admin/releases"), api<{ items: any[] }>("/api/admin/releases/notices"), ]); releases.value = releaseData.manifest; releaseNotices.value = noticeData.items || []; - if (releaseNotices.value.length && !noticeDraft.version) await openNotice(releaseNotices.value[0].version); + const target = preferredVersion && releaseNotices.value.some((item: any) => item.version === preferredVersion) + ? preferredVersion + : releaseNotices.value[0]?.version; + if (target && noticeDraft.version !== target) await openNotice(target); } async function importNotices() { @@ -492,7 +580,7 @@ async function saveNotice() { selectedNotice.value = data.document; noticeDraft.note = ""; setToast("版本日志已保存并同步兼容更新信息"); - await loadReleases(); + await loadReleases(data.document?.notice?.version || noticeDraft.version); }); } @@ -516,6 +604,7 @@ async function loadLegacy(name: LegacyName) { legacyDrafts[name].raw = data.document.raw || ""; legacyDrafts[name].preview = data.document.parsed || null; legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {}); + if (name === "media-types") clampMediaCategoryIndex(); } function onPackageSelected(event: Event) { @@ -597,6 +686,12 @@ async function saveLegacy(name: LegacyName) { legacyDrafts[name].preview = data.document.parsed; legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {}); legacyDrafts[name].note = ""; + if (name === "media-types") { + clampMediaCategoryIndex(); + } + if (name === "update-info") { + await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version); + } setToast("兼容 JSON 已保存并发布到旧路径"); }); } @@ -611,6 +706,8 @@ async function restoreLegacy(name: LegacyName, revisionId: number) { legacyDrafts[name].raw = data.document.raw; legacyDrafts[name].preview = data.document.parsed; legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {}); + if (name === "media-types") clampMediaCategoryIndex(); + if (name === "update-info") await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version); setToast("兼容 JSON 已恢复"); }); } @@ -648,6 +745,7 @@ function makeLegacyForm(name: LegacyName, parsed: any) { release_notes_md: parsed.release_notes_md || "", update_notes: JSON.stringify(parsed.update_notes || {}, null, 2), last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2), + download_mirrors: clone(parsed.download_mirrors || []), package_sha256: parsed.package_sha256 || "", package_size: parsed.package_size || "", updated_at: parsed.updated_at || parsed.last_updated || "", @@ -683,6 +781,7 @@ function updateLegacyRawFromForm(name: LegacyName) { if (form[key] !== undefined) current[key] = form[key]; } if (form.package_size !== "") current.package_size = Number(form.package_size || 0); + current.download_mirrors = clone(form.download_mirrors || current.download_mirrors || []); current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {}); current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {}); } @@ -710,8 +809,104 @@ function addMediaSubcategory(category: any) { category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true }); } +function openMediaCategoryModal(index = -1) { + const categories = legacyDrafts["media-types"].form.categories || []; + const existing = index >= 0 ? categories[index] : null; + Object.assign(legacyModal, { + open: true, + type: "media-category", + categoryIndex: index, + itemIndex: -1, + draft: clone(existing || { id: `category-${categories.length + 1}`, name: "新分类", enabled: true }), + }); +} + +function selectMediaCategory(index: number) { + activeMediaCategoryIndex.value = Math.max(0, index); + clampMediaCategoryIndex(); +} + +function clampMediaCategoryIndex() { + const categories = legacyDrafts["media-types"].form.categories || []; + if (categories.length === 0) { + activeMediaCategoryIndex.value = 0; + return; + } + activeMediaCategoryIndex.value = Math.min(Math.max(0, activeMediaCategoryIndex.value), categories.length - 1); +} + +function openMediaSubcategoryModal(categoryIndex = activeMediaCategoryIndex.value, itemIndex = -1) { + const categories = legacyDrafts["media-types"].form.categories || []; + const category = categories[categoryIndex]; + if (!category) return; + activeMediaCategoryIndex.value = categoryIndex; + const subcategories = category.subcategories || []; + const existing = itemIndex >= 0 ? subcategories[itemIndex] : null; + Object.assign(legacyModal, { + open: true, + type: "media-subcategory", + categoryIndex, + itemIndex, + draft: clone(existing || { id: `source-${subcategories.length + 1}`, name: "新接口", api_url: "", thumbnail_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true, description: "" }), + }); +} + +function openUpdateMirrorModal(index = -1) { + const form = legacyDrafts["update-info"].form; + if (!Array.isArray(form.download_mirrors)) form.download_mirrors = []; + const mirrors = form.download_mirrors; + const existing = index >= 0 ? mirrors[index] : null; + Object.assign(legacyModal, { + open: true, + type: "update-mirror", + categoryIndex: -1, + itemIndex: index, + draft: clone(existing || { id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true }), + }); +} + +function closeLegacyModal() { + Object.assign(legacyModal, { open: false, type: "", categoryIndex: -1, itemIndex: -1, draft: {} }); +} + +function applyLegacyModal() { + if (legacyModal.type === "media-category") { + const form = legacyDrafts["media-types"].form; + if (!Array.isArray(form.categories)) form.categories = []; + const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false, subcategories: legacyModal.draft.subcategories || [] }; + if (legacyModal.categoryIndex >= 0) { + next.subcategories = form.categories[legacyModal.categoryIndex]?.subcategories || []; + form.categories.splice(legacyModal.categoryIndex, 1, next); + activeMediaCategoryIndex.value = legacyModal.categoryIndex; + } else { + form.categories.push(next); + activeMediaCategoryIndex.value = form.categories.length - 1; + } + clampMediaCategoryIndex(); + } + if (legacyModal.type === "media-subcategory") { + const category = legacyDrafts["media-types"].form.categories?.[legacyModal.categoryIndex]; + if (!category) return; + if (!Array.isArray(category.subcategories)) category.subcategories = []; + const next = { ...legacyModal.draft, refresh_interval: Number(legacyModal.draft.refresh_interval || 300), downloadable: legacyModal.draft.downloadable !== false }; + if (legacyModal.itemIndex >= 0) category.subcategories.splice(legacyModal.itemIndex, 1, next); + else category.subcategories.push(next); + } + if (legacyModal.type === "update-mirror") { + const form = legacyDrafts["update-info"].form; + if (!Array.isArray(form.download_mirrors)) form.download_mirrors = []; + const mirrors = form.download_mirrors; + const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false }; + if (legacyModal.itemIndex >= 0) mirrors.splice(legacyModal.itemIndex, 1, next); + else mirrors.push(next); + updateLegacyRawFromForm("update-info"); + } + closeLegacyModal(); +} + function removeItem(list: any[], index: number) { list.splice(index, 1); + clampMediaCategoryIndex(); } async function loadSources() { @@ -731,10 +926,10 @@ async function checkSources() { await guarded(async () => { const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" }); if (data.job) sourceCheckJobs.value = [data.job, ...sourceCheckJobs.value.filter((item) => item.id !== data.job.id)].slice(0, 5); - setToast(`接口心跳检测已进入队列:${data.jobId}`); + setToast(`服务端接口检测已进入队列:${data.jobId}`); if (currentPath.value === "/admin/dashboard") await loadDashboard(); if (currentPath.value === "/admin/sources") await loadSources(); - if (currentPath.value === "/admin/system") await loadSystem(); + if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true }); }); } @@ -767,32 +962,226 @@ function copyEndpointToSource(item: any) { navigate("/admin/sources"); } -async function loadDatabase(options: { previewLegacy?: boolean } = {}) { - const data = await api<{ database: any }>("/api/admin/database/status"); +async function deleteEndpoint(item: any) { + const sourceID = item.id || item.sourceId; + if (!sourceID) return; + if (!window.confirm(`确认删除客户端接口「${sourceID}」?删除后会同步兼容 media-types.json 和 update-info.json。`)) return; + await guarded(async () => { + await api(`/api/admin/sources/${encodeURIComponent(sourceID)}`, { method: "DELETE" }); + setToast("客户端接口已删除,兼容 JSON 已同步"); + await Promise.all([loadSources().catch(() => undefined), loadEndpoints()]); + }); +} + +async function loadDatabase(options: LoadDatabaseOptions = {}) { + const data = await api<{ database: any; config?: any }>("/api/admin/database/status"); database.value = data.database; - databaseForm.provider = data.database?.configProvider || "sqlite"; + databaseConfig.value = data.config || null; + if (!options.preserveForm || !databaseFormEditing.value) { + applyDatabaseConfig(data.config || {}, data.database || {}); + databaseFormEditing.value = false; + } if (options.previewLegacy !== false) await previewLegacySync(); } +function applyDatabaseConfig(config: any, status: any = {}) { + databaseForm.provider = config.provider || status.configProvider || "sqlite"; + databaseForm.sqlitePath = config.sqlitePath || ""; + databaseForm.mysqlHost = config.mysqlHost || "127.0.0.1"; + databaseForm.mysqlPort = Number(config.mysqlPort || 3306); + databaseForm.mysqlDatabase = config.mysqlDatabase || ""; + databaseForm.mysqlUser = config.mysqlUser || ""; + databaseForm.mysqlPassword = ""; + databaseForm.mysqlDsn = config.mysqlDsn || ""; + databaseConfigCollapsed.value = databaseForm.provider === "mysql" && Boolean(config.mysqlHost || config.mysqlDatabase || config.mysqlDsn); +} + +function databasePayload() { + return { + provider: databaseForm.provider, + sqlite_path: databaseForm.sqlitePath, + mysql_host: databaseForm.mysqlHost, + mysql_port: Number(databaseForm.mysqlPort || 3306), + mysql_database: databaseForm.mysqlDatabase, + mysql_user: databaseForm.mysqlUser, + mysql_password: databaseForm.mysqlPassword, + }; +} + async function testDatabase() { await guarded(async () => { await api("/api/admin/database/test", { method: "POST", - body: JSON.stringify({ provider: databaseForm.provider, sqlite_path: databaseForm.sqlitePath, mysql_dsn: databaseForm.mysqlDsn }), + body: JSON.stringify(databasePayload()), }); setToast("数据库连接测试通过"); }); } +async function saveDatabase() { + await guarded(async () => { + const data = await api<{ database: any; config: any }>("/api/admin/database/save", { + method: "POST", + body: JSON.stringify(databasePayload()), + }); + database.value = data.database; + databaseConfig.value = data.config; + databaseFormEditing.value = false; + applyDatabaseConfig(data.config || {}, data.database || {}); + databaseConfigCollapsed.value = true; + setToast("数据库配置已测试、保存并热切换"); + await loadDatabase({ previewLegacy: false, preserveForm: true }); + }); +} + async function syncDatabase(direction: "import" | "sync") { await guarded(async () => { const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" }); databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt }; - setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地"); + const result = databaseLastSync.value || {}; + if (result.skipped) { + setToast(result.warnings?.[0] || "远端 MySQL 未配置,同步已跳过", "warn"); + } else { + setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地"); + } await loadDatabase({ previewLegacy: false }); }); } +function editDatabaseConfig() { + databaseFormEditing.value = true; + databaseConfigCollapsed.value = false; +} + +function markDatabaseFormEditing() { + databaseFormEditing.value = true; +} + +async function reloadDatabaseConfig() { + databaseFormEditing.value = false; + await guarded(async () => { + await loadDatabase({ previewLegacy: false }); + setToast("数据库配置已从服务端重新读取"); + }); +} + +function databaseConfigSummary() { + const config = databaseConfig.value || {}; + if (databaseForm.provider === "mysql") { + const host = config.mysqlHost || databaseForm.mysqlHost || "127.0.0.1"; + const port = config.mysqlPort || databaseForm.mysqlPort || 3306; + const databaseName = config.mysqlDatabase || databaseForm.mysqlDatabase || "-"; + const user = config.mysqlUser || databaseForm.mysqlUser || "-"; + return `${host}:${port} / ${databaseName} / ${user}${config.hasPassword ? " / 已保存密码" : ""}`; + } + return config.sqlitePath || databaseForm.sqlitePath || "使用默认 SQLite 路径"; +} + +async function loadMigrationStatus() { + const data = await api<{ migration: any }>("/api/admin/system/migration"); + migrationStatus.value = data.migration || null; +} + +async function loadBranding() { + const data = await api<{ branding: any }>("/api/admin/system/branding"); + Object.assign(branding, { + siteIconUrl: data.branding?.siteIconUrl || branding.siteIconUrl, + developerAvatarUrl: data.branding?.developerAvatarUrl || branding.developerAvatarUrl, + developerName: data.branding?.developerName || "YMhut", + feedbackEmail: data.branding?.feedbackEmail || "support@ymhut.cn", + }); +} + +async function saveBranding() { + await guarded(async () => { + const data = await api<{ branding: any }>("/api/admin/system/branding", { + method: "POST", + body: JSON.stringify({ + siteIconUrl: branding.siteIconUrl, + developerAvatarUrl: branding.developerAvatarUrl, + developerName: branding.developerName, + feedbackEmail: branding.feedbackEmail, + }), + }); + Object.assign(branding, data.branding || {}); + if (!mailConfig.developerAddress) mailConfig.developerAddress = branding.feedbackEmail; + setToast("站点品牌信息已保存"); + }); +} + +async function loadMailConfig(options: LoadMailOptions = {}) { + const data = await api<{ config: any }>("/api/admin/system/mail/config"); + if (!options.preserveForm || !mailConfigEditing.value) { + Object.assign(mailConfig, { + host: data.config?.host || "", + port: Number(data.config?.port || 465), + secure: data.config?.secure || "ssl", + username: data.config?.username || "", + password: "", + fromAddress: data.config?.fromAddress || "", + fromName: data.config?.fromName || "YMhut Box Feedback", + developerAddress: data.config?.developerAddress || "", + timeoutSeconds: Number(data.config?.timeoutSeconds || 20), + hasPassword: Boolean(data.config?.hasPassword), + configured: Boolean(data.config?.configured), + }); + mailConfigEditing.value = false; + } +} + +function mailPayload() { + return { + host: mailConfig.host, + port: Number(mailConfig.port || 465), + secure: mailConfig.secure, + username: mailConfig.username, + password: mailConfig.password, + from_address: mailConfig.fromAddress, + from_name: mailConfig.fromName, + developer_address: mailConfig.developerAddress, + timeout_seconds: Number(mailConfig.timeoutSeconds || 20), + }; +} + +async function saveMailConfig() { + await guarded(async () => { + const data = await api<{ config: any }>("/api/admin/system/mail/config", { method: "POST", body: JSON.stringify(mailPayload()) }); + mailConfigEditing.value = false; + Object.assign(mailConfig, { ...data.config, password: "" }); + setToast("邮件通知配置已保存"); + }); +} + +async function testMail() { + await guarded(async () => { + await api("/api/admin/system/mail/test", { method: "POST", body: "{}" }); + setToast("测试邮件已发送"); + await loadMailConfig({ preserveForm: true }); + }); +} + +function markMailConfigEditing() { + mailConfigEditing.value = true; +} + +async function reloadMailConfig() { + mailConfigEditing.value = false; + await guarded(async () => { + await loadMailConfig(); + setToast("邮件配置已从服务端重新读取"); + }); +} + +async function retryFeedbackMail() { + if (!selectedFeedback.value) return; + await guarded(async () => { + await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/mail/retry`, { method: "POST", body: "{}" }); + setToast("反馈邮件已重新发送"); + await openFeedback(selectedFeedback.value); + await loadFeedbacks(); + }); +} + async function previewLegacySync() { legacySyncMode.value = "preview"; legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] })); @@ -803,7 +1192,7 @@ async function runLegacySync() { legacySyncMode.value = "run"; legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" }); setToast("旧项目同步已完成"); - await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]); + await Promise.all([loadDatabase({ previewLegacy: false, preserveForm: true }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]); }); } @@ -812,8 +1201,31 @@ async function loadHealth() { } async function loadAudit() { - const data = await api<{ items: any[] }>("/api/admin/system/audit"); - auditLogs.value = data.items || []; + const params = new URLSearchParams({ + page: String(auditPage.page || 1), + perPage: String(auditPage.perPage || 35), + }); + if (auditPage.q) params.set("q", auditPage.q); + if (auditPage.type) params.set("type", auditPage.type); + if (auditPage.target) params.set("target", auditPage.target); + const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/audit?${params}`); + const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: auditPage.page, perPage: auditPage.perPage }; + auditLogs.value = page.items || []; + Object.assign(auditPage, { + items: page.items || [], + total: Number(page.total || 0), + page: Number(page.page || auditPage.page || 1), + perPage: Number(page.perPage || auditPage.perPage || 35), + }); +} + +function setAuditPage(page: number) { + auditPage.page = Math.max(1, page); + void loadAudit(); +} + +function selectAuditLog(item: any) { + auditPage.selected = item; } async function changePassword() { @@ -832,9 +1244,9 @@ function endpointStatus(item: any) { function statusTone(status: string) { const value = String(status || "").toLowerCase(); - if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good"; - if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn"; - if (["error", "failed", "closed", "offline"].includes(value)) return "bad"; + if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good"; + if (["redirected", "degraded", "pending", "processing", "queued", "missing", "skipped", "running", "normal"].includes(value)) return "warn"; + if (["error", "failed", "closed", "offline", "urgent", "high", "blocking", "major"].includes(value)) return "bad"; return "neutral"; } @@ -852,11 +1264,30 @@ function labelStatus(value: string) { new: "新建", processing: "处理中", closed: "已关闭", + pending: "待发送", + sent: "已发送", + skipped: "已跳过", + running: "执行中", + completed: "已完成", failed: "失败", }; return labels[value] || value || "未知"; } +function labelPriority(value: string) { + const labels: Record = { + low: "低", + minor: "低", + normal: "普通", + medium: "普通", + high: "高", + major: "高", + urgent: "紧急", + blocking: "紧急", + }; + return labels[String(value || "").toLowerCase()] || value || "普通"; +} + function auditTypeLabel(value: string) { const labels: Record = { "auth.login": "管理员登录", @@ -892,6 +1323,16 @@ function databaseSyncDirectionLabel(value: string) { return value || "-"; } +function databaseSyncStatusLabel(value: string) { + const labels: Record = { + completed: "已完成", + skipped: "已跳过", + running: "执行中", + failed: "失败", + }; + return labels[String(value || "").toLowerCase()] || value || "-"; +} + function databaseSyncTableCount(result: any) { const tables = result?.tables || {}; return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0); @@ -914,6 +1355,12 @@ function timeLabel(value: string) { return value.length > 10 ? value.slice(11, 19) : value; } +function heartbeatTimeValue(value: string) { + if (!value) return Date.now(); + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : value; +} + function pretty(value: any) { return JSON.stringify(value || {}, null, 2); } @@ -968,7 +1415,7 @@ function connectAdminEvents() { if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]); if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]); if (currentPath.value === "/admin/endpoints") void loadEndpoints(); - if (currentPath.value === "/admin/system") void loadSystem(); + if (currentPath.value === "/admin/system") void loadSystem({ preserveForms: true }); }; for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) { events.addEventListener(name, refreshCurrent); @@ -1017,8 +1464,11 @@ function connectAdminEvents() {