@@ -22,6 +22,7 @@ type adminRow struct {
|
||||
type DatabaseStatus struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
ConfigProvider string `json:"configProvider"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
SQLiteReady bool `json:"sqliteReady"`
|
||||
RemoteReady bool `json:"remoteReady"`
|
||||
FailoverActive bool `json:"failoverActive"`
|
||||
|
||||
@@ -22,6 +22,9 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := createSchemaIndexes(conn, d); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.recordSchemaVersion(conn, d)
|
||||
}
|
||||
|
||||
@@ -281,21 +284,67 @@ func schemaStatements(d dialect) []string {
|
||||
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)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_events_code ON feedback_events(feedback_code)`,
|
||||
`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)`,
|
||||
}
|
||||
}
|
||||
|
||||
type schemaIndex struct {
|
||||
name string
|
||||
table string
|
||||
columns string
|
||||
}
|
||||
|
||||
func schemaIndexes() []schemaIndex {
|
||||
return []schemaIndex{
|
||||
{name: "idx_feedback_tickets_activity", table: "feedback_tickets", columns: "last_activity_at"},
|
||||
{name: "idx_feedback_comments_code", table: "feedback_comments", columns: "feedback_code"},
|
||||
{name: "idx_feedback_attachments_code", table: "feedback_attachments", columns: "feedback_code"},
|
||||
{name: "idx_feedback_events_code", table: "feedback_events", columns: "feedback_code"},
|
||||
{name: "idx_mail_records_code", table: "mail_records", columns: "feedback_code"},
|
||||
{name: "idx_endpoint_call_logs_source", table: "endpoint_call_logs", columns: "source_id"},
|
||||
{name: "idx_audit_logs_created", table: "audit_logs", columns: "created_at"},
|
||||
{name: "idx_audit_logs_type", table: "audit_logs", columns: "type"},
|
||||
{name: "idx_audit_logs_target", table: "audit_logs", columns: "target"},
|
||||
{name: "idx_legacy_json_revisions_name", table: "legacy_json_revisions", columns: "name, id"},
|
||||
{name: "idx_release_notices_version", table: "release_notices", columns: "version"},
|
||||
{name: "idx_release_notice_revisions_version", table: "release_notice_revisions", columns: "version, id"},
|
||||
}
|
||||
}
|
||||
|
||||
func createSchemaIndexes(conn *sql.DB, d dialect) error {
|
||||
for _, index := range schemaIndexes() {
|
||||
if d.name == "mysql" {
|
||||
exists, err := mysqlIndexExists(conn, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := conn.Exec(d.rebind(createIndexStatement(d, index))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlIndexExists(conn *sql.DB, index schemaIndex) (bool, error) {
|
||||
var count int
|
||||
err := conn.QueryRow(`SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND index_name = ?`, index.table, index.name).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func createIndexStatement(d dialect, index schemaIndex) string {
|
||||
if d.name == "mysql" {
|
||||
return fmt.Sprintf("CREATE INDEX %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||
}
|
||||
return fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||
}
|
||||
|
||||
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
||||
columns := []string{"version", "applied_at", "description"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
||||
|
||||
@@ -66,6 +66,7 @@ func Open(cfg *config.Config) (*Store, error) {
|
||||
status: DatabaseStatus{
|
||||
ActiveProvider: "sqlite",
|
||||
ConfigProvider: cfg.Database.Provider,
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
SQLiteReady: true,
|
||||
LastRecoveredAt: Now(),
|
||||
},
|
||||
@@ -165,6 +166,7 @@ func (s *Store) ReconfigureDatabase(cfg *config.Config) error {
|
||||
s.remoteDB = remote
|
||||
s.remoteDialect = remoteDialect
|
||||
s.status.ConfigProvider = cfg.Database.Provider
|
||||
s.status.SchemaVersion = CurrentSchemaVersion
|
||||
s.status.SQLiteReady = true
|
||||
s.status.RemoteReady = remote != nil
|
||||
s.status.LastError = ""
|
||||
@@ -269,6 +271,7 @@ func (s *Store) openRemote() error {
|
||||
s.dialect = remoteDialect
|
||||
s.status.ActiveProvider = "mysql"
|
||||
s.status.ConfigProvider = "mysql"
|
||||
s.status.SchemaVersion = CurrentSchemaVersion
|
||||
s.status.RemoteReady = true
|
||||
s.status.FailoverActive = false
|
||||
s.status.LastError = ""
|
||||
@@ -304,6 +307,7 @@ func (s *Store) checkRemote() {
|
||||
}
|
||||
s.status.ActiveProvider = "mysql"
|
||||
s.status.RemoteReady = true
|
||||
s.status.SchemaVersion = CurrentSchemaVersion
|
||||
s.status.FailoverActive = false
|
||||
s.status.LastError = ""
|
||||
s.status.LastRecoveredAt = Now()
|
||||
@@ -320,6 +324,7 @@ func (s *Store) markFailover(err error) {
|
||||
s.dialect = s.localDialect
|
||||
s.status.ActiveProvider = "sqlite"
|
||||
s.status.ConfigProvider = s.cfg.Database.Provider
|
||||
s.status.SchemaVersion = CurrentSchemaVersion
|
||||
s.status.RemoteReady = false
|
||||
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
||||
s.status.LastError = err.Error()
|
||||
|
||||
@@ -138,6 +138,51 @@ func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaIndexStatementsAreDialectAware(t *testing.T) {
|
||||
mysql := dialectFor("mysql")
|
||||
sqlite := dialectFor("sqlite")
|
||||
for _, statement := range schemaStatements(mysql) {
|
||||
if strings.Contains(strings.ToUpper(statement), "CREATE INDEX IF NOT EXISTS") {
|
||||
t.Fatalf("mysql schema statement contains unsupported index syntax: %s", statement)
|
||||
}
|
||||
}
|
||||
|
||||
index := schemaIndexes()[0]
|
||||
mysqlStatement := createIndexStatement(mysql, index)
|
||||
if strings.Contains(strings.ToUpper(mysqlStatement), "IF NOT EXISTS") {
|
||||
t.Fatalf("mysql index statement contains unsupported syntax: %s", mysqlStatement)
|
||||
}
|
||||
if !strings.Contains(createIndexStatement(sqlite, index), "IF NOT EXISTS") {
|
||||
t.Fatalf("sqlite index statement should remain idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCreatesSQLiteIndexesIdempotently(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
conn, err := sql.Open("sqlite", filepath.Join(root, "indexes.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
store := &Store{}
|
||||
d := dialectFor("sqlite")
|
||||
if err := store.migrate(conn, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.migrate(conn, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var name string
|
||||
if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, "idx_feedback_tickets_activity").Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "idx_feedback_tickets_activity" {
|
||||
t.Fatalf("unexpected index name %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
|
||||
@@ -271,12 +271,16 @@ func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request)
|
||||
"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": "反馈附件包"},
|
||||
{"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback"), "description": "反馈加密包和解密后的本地包"},
|
||||
},
|
||||
"sqlitePath": r.store.Path(),
|
||||
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
|
||||
"schemaVersion": status.SchemaVersion,
|
||||
"lastSyncAt": status.LastSyncAt,
|
||||
"lastSyncError": status.LastSyncError,
|
||||
"lastError": status.LastError,
|
||||
"failoverActive": status.FailoverActive,
|
||||
"remoteReady": status.RemoteReady,
|
||||
"activeProvider": status.ActiveProvider,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,9 +30,12 @@ const tabs = [
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||
<span>Schema</span><strong>{{ ctx.database?.schemaVersion || "-" }}</strong>
|
||||
<span>活动数据库</span><strong>{{ ctx.database?.activeProvider || "-" }}</strong>
|
||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
||||
<span>恢复时间</span><strong>{{ ctx.database?.lastRecoveredAt || "-" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
@@ -98,9 +101,12 @@ const tabs = [
|
||||
<div class="kv-grid">
|
||||
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
|
||||
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
|
||||
<span>Schema</span><strong>{{ ctx.migrationStatus?.schemaVersion || "-" }}</strong>
|
||||
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.migrationStatus?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>Failover</span><strong>{{ ctx.migrationStatus?.failoverActive ? "active" : "standby" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || "-" }}</strong>
|
||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || ctx.migrationStatus?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
|
||||
Reference in New Issue
Block a user