更新 update 门户站点界面和后台功能
build-winui / winui (push) Waiting to run

This commit is contained in:
QWQLwToo
2026-06-27 18:09:11 +08:00
parent 2513eb2903
commit 962a2f2143
56 changed files with 4564 additions and 714 deletions
@@ -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
}
@@ -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) {
@@ -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
}
@@ -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
@@ -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
}
@@ -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 {
@@ -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
@@ -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"`
+195 -184
View File
@@ -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)`,
@@ -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
}
@@ -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)
@@ -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()
@@ -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])
}
}
@@ -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) {
@@ -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"
@@ -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: "<p>这是一封来自 unified-management 的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
}, 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 := `<h2>YMhut Box 反馈工单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
for _, row := range rows {
html += `<tr><th align="left">` + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
}
html += "</table>"
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
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, "&", "&amp;")
value = strings.ReplaceAll(value, "<", "&lt;")
value = strings.ReplaceAll(value, ">", "&gt;")
value = strings.ReplaceAll(value, `"`, "&quot;")
return strings.ReplaceAll(value, "'", "&#39;")
}
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 ""
}
@@ -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"
@@ -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)
@@ -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")
@@ -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 {
@@ -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()
@@ -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
}
@@ -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")
}
@@ -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)
@@ -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
}
@@ -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))
}
@@ -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),
})
}
@@ -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 加载失败",
@@ -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"),
+15 -94
View File
@@ -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(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>YMhut Setup</title></head><body><main><h1>YMhut Setup</h1><p>Setup frontend is not built. Run npm install && npm run build in web/setup.</p><p>` + index + `</p></main></body></html>`))
}
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)
}
+480 -30
View File
@@ -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<ToastState | null>(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<SystemTab>(() => 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<string, string> = {
low: "低",
minor: "低",
normal: "普通",
medium: "普通",
high: "高",
major: "高",
urgent: "紧急",
blocking: "紧急",
};
return labels[String(value || "").toLowerCase()] || value || "普通";
}
function auditTypeLabel(value: string) {
const labels: Record<string, string> = {
"auth.login": "管理员登录",
@@ -892,6 +1323,16 @@ function databaseSyncDirectionLabel(value: string) {
return value || "-";
}
function databaseSyncStatusLabel(value: string) {
const labels: Record<string, string> = {
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() {
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark"><ShieldCheck :size="22" /></span>
<div><strong>YMhut</strong><small>统一管理台</small></div>
<span class="brand-mark">
<img v-if="branding.siteIconUrl" :src="branding.siteIconUrl" alt="YMhut" />
<ShieldCheck v-else :size="22" />
</span>
<div><strong>{{ branding.developerName || "YMhut" }}</strong><small>统一管理台</small></div>
</div>
<nav class="nav-groups">
<section v-for="group in navGroups" :key="group.label" class="nav-group">
@@ -24,7 +24,7 @@ const exactMessages: Record<string, string> = {
"file is required": "请选择要上传的文件",
"invalid filename": "文件名不合法",
"path escape rejected": "文件路径不合法",
"check job not found": "未找到心跳检测任务",
"check job not found": "未找到服务端检测任务",
"streaming is not supported": "当前运行环境不支持实时事件流",
};
@@ -3,8 +3,8 @@ import { reactive, ref } from "vue";
export function createFeedbackStore() {
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
const selected = ref<any | null>(null);
const filters = reactive({ q: "", status: "", page: 1, perPage: 20 });
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
const filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
const commentDraft = reactive({ body: "", internal: true });
return { page, selected, filters, update, commentDraft };
@@ -5,10 +5,18 @@ export type LegacyName = "update-info" | "media-types";
export function createLegacyStore() {
const sync = ref<any>(null);
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
const modal = reactive({
open: false,
type: "",
categoryIndex: -1,
itemIndex: -1,
draft: {} as any,
});
const activeMediaCategoryIndex = ref(0);
const drafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
});
return { sync, documents, drafts };
return { sync, documents, drafts, modal, activeMediaCategoryIndex };
}
@@ -2,11 +2,52 @@ import { reactive, ref } from "vue";
export function createSystemStore() {
const database = ref<any>(null);
const databaseConfig = ref<any>(null);
const databaseLastSync = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
const auditPage = reactive({
items: [] as any[],
total: 0,
page: 1,
perPage: 35,
q: "",
type: "",
target: "",
selected: null as any | null,
});
const migrationStatus = ref<any>(null);
const branding = reactive({
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",
});
const databaseForm = reactive({
provider: "sqlite",
sqlitePath: "",
mysqlHost: "127.0.0.1",
mysqlPort: 3306,
mysqlDatabase: "",
mysqlUser: "",
mysqlPassword: "",
mysqlDsn: "",
});
const databaseConfigCollapsed = ref(true);
const mailConfig = reactive({
host: "",
port: 465,
secure: "ssl",
username: "",
password: "",
fromAddress: "",
fromName: "YMhut Box Feedback",
developerAddress: "",
timeoutSeconds: 20,
hasPassword: false,
configured: false,
});
const legacySyncMode = ref<"preview" | "run">("preview");
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
return { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
}
@@ -115,6 +115,8 @@ input:focus, textarea:focus, select:focus {
.btn.ghost { background: transparent; }
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
.btn.full { width: 100%; }
.btn.danger { color: var(--bad); border-color: #f0b8b1; }
.btn.danger:hover { background: var(--bad-bg); color: var(--bad); }
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.alert-line, .notice {
@@ -172,6 +174,7 @@ input:focus, textarea:focus, select:focus {
}
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.brand strong { display: block; }
.brand small { display: block; color: var(--muted); margin-top: 2px; }
.brand > div { min-width: 0; overflow: hidden; }
@@ -229,6 +232,13 @@ input:focus, textarea:focus, select:focus {
padding: 16px;
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
}
.panel-soft {
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 14px;
}
.metric, .panel, .revision-list button, .nested-card {
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
@@ -242,9 +252,26 @@ input:focus, textarea:focus, select:focus {
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
.chart-panel-relative { position: relative; }
.chart { min-height: 260px; width: 100%; flex: 1; }
.chart-empty {
position: absolute;
inset: 56px 16px 16px;
display: grid;
place-content: center;
gap: 6px;
border: 1px dashed var(--line);
border-radius: 10px;
background: rgba(248, 250, 252, 0.84);
color: var(--muted);
text-align: center;
pointer-events: none;
}
.chart-empty strong { color: var(--ink); }
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
.legacy-media-editor { grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.05fr); }
.legacy-media-editor > * { min-width: 0; }
.search-box {
min-width: min(420px, 100%);
@@ -300,6 +327,48 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
.empty-state.compact { min-height: 96px; border: 1px dashed var(--line); border-radius: 6px; }
.source-group { margin-top: 12px; }
.source-group h3 { display: flex; align-items: center; gap: 8px; }
.table-scroll {
width: 100%;
max-width: 100%;
overflow-x: auto;
scrollbar-gutter: stable;
}
.media-subcategory-panel { min-width: 0; overflow: hidden; }
.media-subcategory-table table { table-layout: fixed; min-width: 680px; }
.media-subcategory-table th:nth-child(1), .media-subcategory-table td:nth-child(1) { width: 170px; }
.media-subcategory-table th:nth-child(2), .media-subcategory-table td:nth-child(2) { width: 120px; }
.media-subcategory-table th:nth-child(3), .media-subcategory-table td:nth-child(3) { width: 72px; }
.media-subcategory-table th:nth-child(5), .media-subcategory-table td:nth-child(5) { width: 150px; }
.media-subcategory-table .button-row { flex-wrap: nowrap; }
.url-cell {
max-width: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-list { display: flex; flex-direction: column; gap: 8px; }
.category-list button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
}
.category-list button:hover, .category-list button.active {
border-color: rgba(37, 99, 235, 0.35);
background: var(--primary-soft);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08);
}
.category-list span:first-child { min-width: 0; display: grid; gap: 2px; }
.category-list strong, .category-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
.compact-editor { min-height: 260px; }
details {
@@ -334,6 +403,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
}
.revision-list button:hover, .revision-list button.active { border-color: var(--primary); background: #f8fbff; }
.revision-list small { display: block; color: var(--muted); margin-top: 3px; }
.compact-side { gap: 10px; }
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
.kv-grid span { color: var(--muted); }
.kv-grid strong { overflow-wrap: anywhere; }
@@ -375,6 +445,63 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
line-height: 1.55;
}
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
.plain-list { margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.8; }
.asset-row {
display: grid;
gap: 4px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 10px;
}
.asset-row span { color: var(--muted); }
.asset-row code { overflow-wrap: anywhere; font-size: 12px; color: var(--primary-dark); }
.brand-preview {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 10px;
}
.brand-preview img {
width: 42px;
height: 42px;
border-radius: 10px;
object-fit: cover;
border: 1px solid var(--line);
}
.pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
color: var(--muted);
font-weight: 800;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 900;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 42, 0.42);
}
.modal-panel {
width: min(720px, calc(100vw - 32px));
max-height: calc(100dvh - 40px);
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
box-shadow: var(--shadow);
padding: 18px;
}
.tabs {
display: flex;
@@ -21,7 +21,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</div>
<div class="toolbar">
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即心跳检测</button>
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即服务端检测</button>
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
@@ -30,7 +30,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</div>
<section v-if="ctx.sourceCheckJobs.length" class="panel">
<div class="section-head"><h2>心跳检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
<div class="section-head"><h2>服务端检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
<table>
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
<tbody>
@@ -48,14 +48,21 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</section>
<div class="chart-grid">
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
<section class="panel chart-panel chart-panel-relative">
<h2>服务端接口延迟</h2>
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
<strong>暂无服务端检测记录</strong>
<span>点击立即服务端检测后会生成延迟曲线</span>
</div>
</section>
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
</div>
<section class="panel">
<div class="section-head"><h2>最近接口心跳</h2><span class="badge">{{ ctx.heartbeats.length }} </span></div>
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} </span></div>
<table>
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
<tbody>
@@ -66,7 +73,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
<td class="hash">{{ item.error || "-" }}</td>
<td>{{ item.checkedAt || "-" }}</td>
</tr>
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录点击立即心跳检测后会刷新</td></tr>
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录点击立即服务端检测后会刷新</td></tr>
</tbody>
</table>
</section>
@@ -1,12 +1,20 @@
<script setup lang="ts">
import { Pencil, Trash2 } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
<template>
<section class="panel page-stack">
<div class="section-head"><h2>客户端动态接口</h2><span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span></div>
<div class="section-head">
<div>
<h2>客户端动态接口</h2>
<p class="muted">删除接口后会由服务端重新生成兼容媒体源 JSON 和更新 JSON</p>
</div>
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
</div>
<table>
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th></th></tr></thead>
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
<td class="mono">{{ item.id || item.sourceId }}</td>
@@ -17,8 +25,13 @@ defineProps<{ ctx: any }>();
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
</td>
<td>{{ item.cacheSeconds || 0 }}s</td>
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
<td>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
</div>
</td>
</tr>
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口</td></tr>
</tbody>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Save, Search, UploadCloud } from "lucide-vue-next";
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
@@ -8,44 +8,107 @@ defineProps<{ ctx: any }>();
<section class="split">
<section class="panel page-stack">
<div class="toolbar">
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks"><option value="">全部状态</option><option value="new">new</option><option value="processing">processing</option><option value="closed">closed</option></select>
<label class="search-box">
<Search :size="16" />
<input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" />
</label>
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks">
<option value="">全部状态</option>
<option value="new">新建</option>
<option value="processing">处理中</option>
<option value="closed">已关闭</option>
</select>
<select v-model="ctx.feedbackFilters.priority" @change="ctx.loadFeedbacks">
<option value="">全部优先级</option>
<option value="low"></option>
<option value="normal">普通</option>
<option value="high"></option>
<option value="urgent">紧急</option>
</select>
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
</div>
<table>
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>最近活动</th></tr></thead>
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>邮件</th><th>最近活动</th></tr></thead>
<tbody>
<tr v-for="item in ctx.feedbackPage.items" :key="item.code" class="clickable" :class="{ selected: ctx.selectedFeedback?.code === item.code }" @click="ctx.openFeedback(item)">
<td class="mono">{{ item.code }}</td>
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
<td>{{ item.priority || "-" }}</td>
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
<td><span :class="['badge', ctx.statusTone(item.priority)]">{{ ctx.labelPriority(item.priority) }}</span></td>
<td><span :class="['badge', item.mailSent ? 'good' : 'warn']">{{ item.mailSent ? "已发送" : "未发送" }}</span></td>
<td>{{ item.lastActivityAt || item.createdAt }}</td>
</tr>
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单</td></tr>
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="6">暂无反馈工单</td></tr>
</tbody>
</table>
</section>
<aside class="panel detail-panel">
<template v-if="ctx.selectedFeedback">
<h2>{{ ctx.selectedFeedback.code }}</h2>
<div class="section-head">
<h2>{{ ctx.selectedFeedback.code }}</h2>
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">{{ ctx.selectedFeedback.mailSent ? "邮件已发送" : "邮件未发送" }}</span>
</div>
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
<label>状态<select v-model="ctx.feedbackUpdate.status"><option>new</option><option>processing</option><option>closed</option></select></label>
<div class="kv-grid">
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</strong>
<span>接收时间</span><strong>{{ ctx.selectedFeedback.createdAt || "-" }}</strong>
</div>
<label>状态
<select v-model="ctx.feedbackUpdate.status">
<option value="new">新建</option>
<option value="processing">处理中</option>
<option value="closed">已关闭</option>
</select>
</label>
<label>优先级
<select v-model="ctx.feedbackUpdate.priority">
<option value="low"></option>
<option value="normal">普通</option>
<option value="high"></option>
<option value="urgent">紧急</option>
</select>
</label>
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存状态</button>
<div class="button-row">
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
</div>
<hr />
<h3>评论</h3>
<div class="comment-list">
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment"><strong>{{ item.author }}</strong><p>{{ item.body }}</p></div>
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment">
<strong>{{ item.author }}</strong>
<p>{{ item.body }}</p>
</div>
</div>
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
<details>
<summary>旧反馈事件 / 邮件记录</summary>
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
<summary>邮件记录</summary>
<table>
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
<tbody>
<tr v-for="item in ctx.selectedFeedback.mailRecords || []" :key="item.id">
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
<td>{{ item.toAddress || "-" }}</td>
<td>{{ item.subject || "-" }}</td>
<td>{{ item.errorMessage || "-" }}</td>
</tr>
<tr v-if="!(ctx.selectedFeedback.mailRecords || []).length"><td colspan="4">暂无邮件记录</td></tr>
</tbody>
</table>
</details>
<details>
<summary>旧反馈事件</summary>
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents }) }}</pre>
</details>
</template>
<div v-else class="empty-state">选择一条工单查看详情</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
import { CheckCircle2, Pencil, Plus, Save, Trash2 } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
@@ -9,7 +9,7 @@ defineProps<{ ctx: any }>();
<div class="section-head">
<div>
<h2>{{ ctx.activeLegacyLabel }}</h2>
<p class="muted">以当前兼容 JSON 为基板表单保存会合并进原 JSON知字段保留</p>
<p class="muted">可视化表单只维护常用字段保存会合并回当前 JSON识别字段继续保留</p>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
@@ -18,73 +18,126 @@ defineProps<{ ctx: any }>();
</div>
<div class="tabs">
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化表单</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
</div>
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
生产环境不再自动依赖旧项目路径需要以 server/update/public/media-types.json 为基板时请切换到 Raw JSON 粘贴完整内容校验通过后保存发布
</p>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="page-stack">
<section class="form-grid">
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
<label> SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="4"></textarea></label>
</section>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
<label> SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
<div class="wide button-row">
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
</div>
<section class="nested-card">
<div class="section-head">
<h3>下载镜像</h3>
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal()"><Plus :size="14" />新增镜像</button>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>URL</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(mirror, index) in ctx.legacyDrafts['update-info'].form.download_mirrors || []" :key="mirror.id || index">
<td class="mono">{{ mirror.id }}</td>
<td>{{ mirror.name }}</td>
<td>{{ mirror.type || "direct" }}</td>
<td><span :class="['badge', mirror.enabled === false ? 'neutral' : 'good']">{{ mirror.enabled === false ? "停用" : "启用" }}</span></td>
<td class="hash">{{ mirror.url }}</td>
<td>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal(index)"><Pencil :size="14" />编辑</button>
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['update-info'].form.download_mirrors, index)"><Trash2 :size="14" />删除</button>
</div>
</td>
</tr>
<tr v-if="!(ctx.legacyDrafts['update-info'].form.download_mirrors || []).length"><td colspan="6">暂无镜像</td></tr>
</tbody>
</table>
</section>
<details>
<summary>高级 JSON 字段</summary>
<div class="form-grid">
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
</div>
</details>
<div class="button-row"><button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button></div>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
<div class="form-grid">
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
</div>
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
<div class="section-head">
<h3>分类 {{ cIndex + 1 }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
</div>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="split legacy-media-editor">
<section class="panel-soft page-stack">
<div class="form-grid">
<label>ID<input v-model="cat.id" /></label>
<label>名称<input v-model="cat.name" /></label>
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
</div>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
<div class="section-head">
<h3>分类</h3>
<button class="btn ghost compact" @click="ctx.openMediaCategoryModal()"><Plus :size="14" />新增分类</button>
</div>
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
<div class="section-head">
<h3>{{ sub.name || "子接口" }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
<div class="category-list" v-if="ctx.legacyDrafts['media-types'].form.categories.length">
<button
v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories"
:key="cat.id || cIndex"
type="button"
:class="{ active: ctx.activeMediaCategoryIndex === cIndex }"
@click="ctx.selectMediaCategory(cIndex)"
>
<span><strong>{{ cat.name || cat.id || `分类 ${cIndex + 1}` }}</strong><small class="mono">{{ cat.id || "-" }}</small></span>
<span class="badge">{{ cat.subcategories?.length || 0 }}</span>
</button>
</div>
<div v-else class="empty-state compact">暂无分类</div>
</section>
<aside class="panel-soft page-stack media-subcategory-panel">
<div class="section-head">
<div>
<h3>{{ ctx.activeMediaCategory?.name || ctx.activeMediaCategory?.id || "子接口" }}</h3>
<p class="muted">右侧仅显示当前选中分类下的子接口</p>
</div>
<div class="form-grid">
<label>ID<input v-model="sub.id" /></label>
<label>名称<input v-model="sub.name" /></label>
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
<div class="button-row">
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex)"><Plus :size="14" />新增子接口</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
</div>
</div>
<section v-if="ctx.activeMediaCategory" class="source-group">
<div class="button-row">
<span :class="['badge', ctx.activeMediaCategory.enabled === false ? 'neutral' : 'good']">{{ ctx.activeMediaCategory.enabled === false ? "停用" : "启用" }}</span>
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, ctx.activeMediaCategoryIndex)"><Trash2 :size="14" />删除分类</button>
</div>
<div class="table-scroll media-subcategory-table">
<table>
<thead><tr><th>ID</th><th>名称</th><th>刷新</th><th>URL</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(sub, sIndex) in ctx.activeMediaCategory.subcategories || []" :key="sub.id || sIndex">
<td class="mono">{{ sub.id }}</td>
<td>{{ sub.name }}</td>
<td>{{ sub.refresh_interval || 300 }}s</td>
<td class="hash url-cell" :title="sub.api_url">{{ sub.api_url }}</td>
<td>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex, sIndex)"><Pencil :size="14" />编辑</button>
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.activeMediaCategory.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
</div>
</td>
</tr>
<tr v-if="!(ctx.activeMediaCategory.subcategories || []).length"><td colspan="5">当前分类暂无子接口</td></tr>
</tbody>
</table>
</div>
</section>
</section>
<div v-else class="empty-state compact">请选择或新增一个分类</div>
</aside>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
@@ -102,5 +155,46 @@ defineProps<{ ctx: any }>();
</button>
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本</div>
</section>
<Teleport to="body">
<div v-if="ctx.legacyModal.open" class="modal-backdrop" @click.self="ctx.closeLegacyModal">
<section class="modal-panel">
<div class="section-head">
<h2>{{ ctx.legacyModal.type === 'media-category' ? '分类' : ctx.legacyModal.type === 'media-subcategory' ? '子接口' : '下载镜像' }}</h2>
<button class="btn ghost compact" @click="ctx.closeLegacyModal">关闭</button>
</div>
<div v-if="ctx.legacyModal.type === 'media-category'" class="form-grid">
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
<label class="checkbox wide"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用分类</label>
</div>
<div v-else-if="ctx.legacyModal.type === 'media-subcategory'" class="form-grid">
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
<label class="wide">接口 URL<input v-model="ctx.legacyModal.draft.api_url" /></label>
<label>缩略图<input v-model="ctx.legacyModal.draft.thumbnail_url" /></label>
<label>刷新间隔<input v-model.number="ctx.legacyModal.draft.refresh_interval" type="number" /></label>
<label>格式<input v-model="ctx.legacyModal.draft.supported_formats" placeholder="json, mp4, webp" /></label>
<label class="checkbox"><input v-model="ctx.legacyModal.draft.downloadable" type="checkbox" />可下载</label>
<label class="wide">描述<textarea v-model="ctx.legacyModal.draft.description" rows="2"></textarea></label>
</div>
<div v-else class="form-grid">
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
<label>类型<input v-model="ctx.legacyModal.draft.type" /></label>
<label class="wide">URL<input v-model="ctx.legacyModal.draft.url" /></label>
<label>SHA256<input v-model="ctx.legacyModal.draft.sha256" /></label>
<label class="checkbox"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用</label>
</div>
<div class="button-row">
<button class="btn primary" @click="ctx.applyLegacyModal">保存</button>
</div>
</section>
</div>
</Teleport>
</section>
</template>
@@ -56,13 +56,18 @@ defineProps<{ ctx: any }>();
</tbody>
</table>
</section>
<aside class="panel editor-panel">
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
<aside class="panel editor-panel compact-side">
<div class="section-head">
<div>
<h2>版本日志</h2>
<p class="muted"> update-info.json 模板为基础动态生成更新信息</p>
</div>
</div>
<div class="revision-list">
<button v-for="item in ctx.releaseNotices" :key="item.version" :class="{ active: ctx.noticeDraft.version === item.version }" @click="ctx.openNotice(item.version)">
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
</button>
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志先执行导入</div>
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志直接填写版本和 Raw JSON 后保存</div>
</div>
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
defineProps<{ ctx: any }>();
const tabs = [
{ id: "database", label: "数据库", icon: Database },
{ id: "migration", label: "迁移状态", icon: HardDrive },
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
{ id: "security", label: "安全设置", icon: ShieldCheck },
{ id: "security", label: "安全与邮件", icon: ShieldCheck },
{ id: "health", label: "健康快照", icon: Activity },
{ id: "audit", label: "审计日志", icon: ListChecks },
];
@@ -15,13 +16,7 @@ const tabs = [
<template>
<section class="page-stack">
<nav class="tabs" aria-label="系统运维标签">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: ctx.systemTab === tab.id }"
@click="ctx.setSystemTab(tab.id)"
>
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: ctx.systemTab === tab.id }" @click="ctx.setSystemTab(tab.id)">
<component :is="tab.icon" :size="15" />
{{ tab.label }}
</button>
@@ -43,35 +38,86 @@ const tabs = [
</div>
<div class="sync-summary">
<div>
<span><ArrowDownUp :size="15" />最近同步方向</span>
<strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong>
</div>
<div>
<span><ListChecks :size="15" />影响记录</span>
<strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
</div>
<div>
<span><Clock3 :size="15" />完成时间</span>
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
</div>
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
<div><span><Clock3 :size="15" />影响记录</span><strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong></div>
</div>
<div class="ops-note">
<div v-if="ctx.databaseLastSync?.warnings?.length" class="ops-note">
<AlertTriangle :size="16" />
<span>数据库同步是覆盖式全表 upsert执行前确认方向SQLite 导入远端会以本地库为源远端同步回本地会以 MySQL 为源</span>
<span>{{ ctx.databaseLastSync.warnings.join("") }}</span>
</div>
</section>
<aside class="panel editor-panel">
<h2>连接与同步</h2>
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
<div class="section-head">
<h2>连接与同步</h2>
<div class="button-row compact-row">
<span v-if="ctx.databaseFormEditing" class="badge warn">编辑中自动刷新不会覆盖表单</span>
<button v-if="ctx.databaseFormEditing" class="btn ghost compact" type="button" @click="ctx.reloadDatabaseConfig">重新读取配置</button>
<button v-if="ctx.databaseConfigCollapsed" class="btn ghost compact" type="button" @click="ctx.editDatabaseConfig">修改配置</button>
</div>
</div>
<div v-if="ctx.databaseConfigCollapsed" class="kv-grid">
<span>当前配置</span><strong>{{ ctx.databaseConfigSummary() }}</strong>
<span>密码状态</span><strong>{{ ctx.databaseConfig?.hasPassword ? "已保存,前端不回显" : "未保存" }}</strong>
</div>
<div v-else class="form-stack" @input="ctx.markDatabaseFormEditing" @change="ctx.markDatabaseFormEditing">
<label>Provider
<select v-model="ctx.databaseForm.provider">
<option value="sqlite">SQLite</option>
<option value="mysql">MySQL</option>
</select>
</label>
<label v-if="ctx.databaseForm.provider === 'sqlite'">SQLite 路径
<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" />
</label>
<div v-else class="form-grid">
<label>主机<input v-model="ctx.databaseForm.mysqlHost" placeholder="127.0.0.1" /></label>
<label>端口<input v-model.number="ctx.databaseForm.mysqlPort" type="number" min="1" placeholder="3306" /></label>
<label>数据库名<input v-model="ctx.databaseForm.mysqlDatabase" placeholder="ymhut_unified" /></label>
<label>数据库用户<input v-model="ctx.databaseForm.mysqlUser" autocomplete="username" /></label>
<label class="wide">数据库密码
<input v-model="ctx.databaseForm.mysqlPassword" type="password" autocomplete="new-password" :placeholder="ctx.databaseConfig?.hasPassword ? '留空沿用已保存密码' : '请输入数据库密码'" />
</label>
</div>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
</div>
</aside>
</section>
<section v-else-if="ctx.systemTab === 'migration'" class="split">
<section class="panel page-stack">
<div class="section-head"><h2>数据库优先迁移</h2><button class="btn ghost" @click="ctx.loadMigrationStatus"><RefreshCw :size="16" />刷新</button></div>
<div class="kv-grid">
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || "-" }}</strong>
</div>
<div class="ops-note">
<AlertTriangle :size="16" />
<span>数据库保存站点结构化状态发布包下载文件和反馈附件仍属于文件资产迁移时需要连同数据库一起备份</span>
</div>
</section>
<aside class="panel page-stack">
<h2>数据库覆盖范围</h2>
<ul class="plain-list">
<li v-for="item in ctx.migrationStatus?.databaseCovers || []" :key="item">{{ item }}</li>
</ul>
<h2>文件资产目录</h2>
<div v-for="asset in ctx.migrationStatus?.fileAssets || []" :key="asset.name" class="asset-row">
<strong>{{ asset.name }}</strong>
<span>{{ asset.description }}</span>
<code>{{ asset.path }}</code>
</div>
</aside>
</section>
@@ -80,37 +126,23 @@ const tabs = [
<div class="section-head">
<div>
<h2>旧项目同步</h2>
<p class="muted">预览只检查旧目录和影响范围执行会备份当前发布目录再复制旧项目数据并导入反馈记录</p>
<p class="muted">预览只检查旧目录和影响范围执行会备份当前兼容输出再复制旧项目数据并导入记录</p>
</div>
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
</div>
<div class="sync-summary">
<div>
<span>当前模式</span>
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
</div>
<div>
<span>状态</span>
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
</div>
<div>
<span>完成时间</span>
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
</div>
<div><span>当前模式</span><strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong></div>
<div><span>状态</span><strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong></div>
<div><span>完成时间</span><strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong></div>
</div>
<div class="kv-grid">
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
</div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
<div class="button-row">
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
</div>
<div class="button-row"><button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button></div>
</section>
<section v-else-if="ctx.systemTab === 'security'" class="split">
@@ -121,15 +153,52 @@ const tabs = [
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
</section>
<section class="panel page-stack">
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
<div class="kv-grid">
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
<span>Cookie</span><strong>HTTPS X-Forwarded-Proto=https 时自动 Secure</strong>
<span>会话范围</span><strong>后台 API SSE 事件流均要求登录</strong>
<span>密码规则</span><strong>至少 8 不能为 admin不能与当前密码相同</strong>
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容后续可平滑迁移到更强算法</strong>
<section class="panel editor-panel">
<div class="section-head">
<h2>站点品牌</h2>
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
</div>
<div class="brand-preview">
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
<strong>{{ ctx.branding.developerName }}</strong>
</div>
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
</section>
<section class="panel editor-panel">
<div class="section-head">
<h2>反馈邮件通知</h2>
<div class="button-row compact-row">
<span v-if="ctx.mailConfigEditing" class="badge warn">编辑中自动刷新不会覆盖表单</span>
<button v-if="ctx.mailConfigEditing" class="btn ghost compact" type="button" @click="ctx.reloadMailConfig">重新读取配置</button>
<span :class="['badge', ctx.mailConfig.configured ? 'good' : 'warn']">{{ ctx.mailConfig.configured ? "已配置" : "未完成" }}</span>
</div>
</div>
<div class="form-grid" @input="ctx.markMailConfigEditing" @change="ctx.markMailConfigEditing">
<label>SMTP 主机<input v-model="ctx.mailConfig.host" placeholder="smtp.example.com" /></label>
<label>端口<input v-model.number="ctx.mailConfig.port" type="number" min="1" /></label>
<label>加密方式
<select v-model="ctx.mailConfig.secure">
<option value="ssl">SSL/TLS</option>
<option value="starttls">STARTTLS</option>
<option value="none">不加密</option>
</select>
</label>
<label>账号<input v-model="ctx.mailConfig.username" autocomplete="username" /></label>
<label>密码<input v-model="ctx.mailConfig.password" type="password" autocomplete="new-password" :placeholder="ctx.mailConfig.hasPassword ? '留空沿用已保存密码' : '请输入 SMTP 密码'" /></label>
<label>超时秒数<input v-model.number="ctx.mailConfig.timeoutSeconds" type="number" min="3" /></label>
<label>发件地址<input v-model="ctx.mailConfig.fromAddress" /></label>
<label>发件名称<input v-model="ctx.mailConfig.fromName" /></label>
<label class="wide">开发者收件地址<input v-model="ctx.mailConfig.developerAddress" :placeholder="ctx.branding.feedbackEmail" /></label>
</div>
<div class="button-row">
<button class="btn primary" @click="ctx.saveMailConfig"><Mail :size="16" />保存邮件配置</button>
<button class="btn ghost" @click="ctx.testMail">发送测试邮件</button>
</div>
</section>
</section>
@@ -140,20 +209,46 @@ const tabs = [
</section>
<section v-else class="panel page-stack">
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
<div class="section-head">
<h2>审计日志</h2>
<button class="btn ghost" @click="ctx.loadAudit">刷新</button>
</div>
<div class="toolbar">
<input v-model="ctx.auditPage.q" placeholder="搜索操作、目标、信息或 IP" @keyup.enter="ctx.loadAudit" />
<input v-model="ctx.auditPage.type" placeholder="类型,例如 source.deleted" @keyup.enter="ctx.loadAudit" />
<input v-model="ctx.auditPage.target" placeholder="目标" @keyup.enter="ctx.loadAudit" />
<button class="btn ghost" @click="ctx.auditPage.page = 1; ctx.loadAudit()">筛选</button>
</div>
<table>
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.auditLogs" :key="item.id">
<tr v-for="item in ctx.auditPage.items" :key="item.id" class="clickable" @click="ctx.selectAuditLog(item)">
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
<td>{{ item.target }}</td>
<td>{{ ctx.auditMessage(item) }}</td>
<td>{{ item.ip || "-" }}</td>
<td>{{ item.createdAt }}</td>
</tr>
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志</td></tr>
<tr v-if="ctx.auditPage.items.length === 0"><td colspan="5">暂无审计日志</td></tr>
</tbody>
</table>
<div class="pager">
<button class="btn ghost compact" :disabled="ctx.auditPage.page <= 1" @click="ctx.setAuditPage(ctx.auditPage.page - 1)">上一页</button>
<span> {{ ctx.auditPage.page }} / {{ Math.max(1, Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)) }} {{ ctx.auditPage.total }} </span>
<button class="btn ghost compact" :disabled="ctx.auditPage.page >= Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)" @click="ctx.setAuditPage(ctx.auditPage.page + 1)">下一页</button>
</div>
<Teleport to="body">
<div v-if="ctx.auditPage.selected" class="modal-backdrop" @click.self="ctx.auditPage.selected = null">
<section class="modal-panel">
<div class="section-head">
<h2>审计详情</h2>
<button class="btn ghost compact" @click="ctx.auditPage.selected = null">关闭</button>
</div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.auditPage.selected) }}</pre>
</section>
</div>
</Teleport>
</section>
</section>
</template>
@@ -22,8 +22,8 @@ onMounted(() => state.load());
<main class="portal-shell">
<nav class="topnav">
<RouterLink class="brand" to="/">
<span><img src="/logo-44.png" alt="YMhut Box" /></span>
<strong>YMhut Box</strong>
<span><img :src="state.branding.value.siteIconUrl" :alt="state.branding.value.developerName" /></span>
<strong>{{ state.branding.value.developerName }}</strong>
</RouterLink>
<div class="nav-links">
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
@@ -53,6 +53,12 @@ export function usePortalState() {
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
const branding = computed(() => ({
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "https://img.ymhut.cn/file/1782108850041_icon.webp",
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
developerName: bootstrap.value?.branding?.developerName || "YMhut",
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
}));
const isReady = computed(() => loaded && !loading.value && !error.value);
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
@@ -123,6 +129,7 @@ export function usePortalState() {
downloadUrl,
appVersion,
serviceVersion,
branding,
isReady,
hasPartialData,
releasesEmpty,
+23 -12
View File
@@ -25,7 +25,8 @@
"png",
"webp"
],
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg"
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg",
"mediaType": "image"
},
{
"api_url": "https://v2.xxapi.cn/api/baisi?return=302",
@@ -40,7 +41,8 @@
"png",
"webp"
],
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg"
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg",
"mediaType": "image"
},
{
"api_url": "https://v2.xxapi.cn/api/heisi?return=302",
@@ -55,7 +57,8 @@
"png",
"webp"
],
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg"
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg",
"mediaType": "image"
},
{
"api_url": "https://api.pearapi.ai/api/beautifulgirl?type=image",
@@ -70,7 +73,8 @@
"png",
"webp"
],
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg"
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg",
"mediaType": "image"
},
{
"api_url": "https://apii.ctose.cn/api/cy/api/",
@@ -85,7 +89,8 @@
"png",
"webp"
],
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/"
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/",
"mediaType": "image"
},
{
"api_url": "https://api.suyanw.cn/api/mao.php",
@@ -100,7 +105,8 @@
"png",
"webp"
],
"thumbnail_url": "https://api.suyanw.cn/api/mao.php"
"thumbnail_url": "https://api.suyanw.cn/api/mao.php",
"mediaType": "image"
},
{
"api_url": "https://api.suyanw.cn/api/scenery.php",
@@ -115,9 +121,11 @@
"png",
"webp"
],
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php"
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php",
"mediaType": "image"
}
]
],
"kind": "image"
},
{
"enabled": true,
@@ -143,7 +151,8 @@
"mp4",
"webm"
],
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg"
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg",
"mediaType": "video"
},
{
"api_url": "https://api.mmp.cc/api/miss?type=mp4",
@@ -156,9 +165,11 @@
"mp4",
"webm"
],
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg"
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg",
"mediaType": "video"
}
]
],
"kind": "video"
}
],
"last_updated": "2025-09-9T17:45:00Z",
@@ -172,4 +183,4 @@
"default_view": "grid",
"show_thumbnails": false
}
}
}
+9 -9
View File
@@ -10,15 +10,15 @@
"fullInstaller": {
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
"size": 113480968,
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
"size": 113484192,
"version": "2.0.7.5"
},
"msix": {
"fileName": "YMhutBox_2.0.7.5_x64.msix",
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"size": 259959751,
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
"size": 259968386,
"version": "2.0.7.5"
},
"appInstaller": {
@@ -32,15 +32,15 @@
"fullInstaller": {
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
"size": 113480968,
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
"size": 113484192,
"version": "2.0.7.5"
},
"msix": {
"fileName": "YMhutBox_2.0.7.5_x64.msix",
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"size": 259959751,
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
"size": 259968386,
"version": "2.0.7.5"
},
"appInstaller": {
@@ -56,5 +56,5 @@
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
},
"createdAt": "2026-06-26T10:00:33.3827184Z"
"createdAt": "2026-06-27T08:54:56.8073504Z"
}