@@ -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"`
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user