更新 update 门户站点界面和后台功能
build-winui / winui (push) Has been cancelled

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
@@ -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])
}
}