Files
ai_site/platform/internal/dbsync/dialect.go
whm b04b180d30 feat: harden loose-offline sync for user JWT, schema, and console ops
Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:47:35 +08:00

315 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dbsync
import (
"context"
"database/sql"
"fmt"
"strings"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
func Open(driver Driver, dsn string) (*sql.DB, error) {
var name string
switch driver {
case DriverSQLite:
name = "sqlite"
if dsn == "" {
return nil, fmt.Errorf("sqlite dsn required")
}
dsn = normalizeSQLiteDSN(dsn)
case DriverMySQL:
name = "mysql"
case DriverPostgres:
name = "postgres"
default:
return nil, fmt.Errorf("unsupported driver: %s", driver)
}
db, err := sql.Open(name, dsn)
if err != nil {
return nil, err
}
if driver == DriverSQLite {
// SQLite 文件锁:单连接更稳,避免 Windows 上并发 open 卡死/EOF
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
} else {
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
// normalizeSQLiteDSNWindows 反斜杠 → 正斜杠;裸盘符路径补 file:;补 busy_timeout。
func normalizeSQLiteDSN(dsn string) string {
dsn = strings.TrimSpace(dsn)
lower := strings.ToLower(dsn)
if strings.HasPrefix(lower, "file:") {
rest := dsn[5:]
path, query, hasQ := strings.Cut(rest, "?")
path = strings.ReplaceAll(path, `\`, `/`)
if hasQ {
dsn = "file:" + path + "?" + query
} else {
dsn = "file:" + path
}
} else if strings.Contains(dsn, `\`) || (len(dsn) >= 2 && dsn[1] == ':') {
path := strings.ReplaceAll(dsn, `\`, `/`)
dsn = "file:" + path
}
if !strings.Contains(strings.ToLower(dsn), "busy_timeout") {
if strings.Contains(dsn, "?") {
dsn += "&_pragma=busy_timeout(5000)"
} else {
dsn += "?_pragma=busy_timeout(5000)"
}
}
return dsn
}
func quoteIdent(driver Driver, name string) string {
name = strings.ReplaceAll(name, "`", "")
name = strings.ReplaceAll(name, `"`, "")
switch driver {
case DriverMySQL:
return "`" + name + "`"
case DriverPostgres:
return `"` + name + `"`
default:
return `"` + name + `"`
}
}
func EnsureOutbox(ctx context.Context, db *sql.DB, driver Driver) error {
var ddl string
switch driver {
case DriverSQLite:
ddl = `CREATE TABLE IF NOT EXISTS ` + OutboxTable + ` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
row_pk TEXT NOT NULL,
op TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
synced_at TEXT
);`
case DriverMySQL:
ddl = `CREATE TABLE IF NOT EXISTS ` + OutboxTable + ` (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(128) NOT NULL,
row_pk VARCHAR(255) NOT NULL,
op VARCHAR(16) NOT NULL,
payload LONGTEXT NOT NULL,
version BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
synced_at DATETIME(3) NULL,
INDEX idx_outbox_unsynced (synced_at, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
case DriverPostgres:
ddl = `CREATE TABLE IF NOT EXISTS ` + OutboxTable + ` (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
row_pk TEXT NOT NULL,
op TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '',
version BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
synced_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_ajz_outbox_unsynced ON ` + OutboxTable + ` (synced_at, id);`
default:
return fmt.Errorf("unsupported driver")
}
_, err := db.ExecContext(ctx, ddl)
return err
}
func EnsureMeta(ctx context.Context, db *sql.DB, driver Driver) error {
var ddl string
switch driver {
case DriverSQLite:
ddl = `CREATE TABLE IF NOT EXISTS ` + MetaTable + ` (
table_name TEXT NOT NULL,
row_pk TEXT NOT NULL,
version INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (table_name, row_pk)
);`
case DriverMySQL:
ddl = `CREATE TABLE IF NOT EXISTS ` + MetaTable + ` (
table_name VARCHAR(128) NOT NULL,
row_pk VARCHAR(255) NOT NULL,
version BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (table_name, row_pk)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
case DriverPostgres:
ddl = `CREATE TABLE IF NOT EXISTS ` + MetaTable + ` (
table_name TEXT NOT NULL,
row_pk TEXT NOT NULL,
version BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (table_name, row_pk)
);`
}
_, err := db.ExecContext(ctx, ddl)
return err
}
// InstallTriggers在业务表上挂触发器写入 outbox实时捕获变更
func InstallTriggers(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string) error {
table = strings.TrimSpace(table)
pkCol = strings.TrimSpace(pkCol)
if table == "" || pkCol == "" {
return fmt.Errorf("table and pk required")
}
switch driver {
case DriverSQLite:
return installSQLiteTriggers(ctx, db, table, pkCol)
case DriverMySQL:
return installMySQLTriggers(ctx, db, table, pkCol)
case DriverPostgres:
return installPostgresTriggers(ctx, db, table, pkCol)
default:
return fmt.Errorf("unsupported driver")
}
}
func installSQLiteTriggers(ctx context.Context, db *sql.DB, table, pk string) error {
if err := EnsureFlagTable(ctx, db, DriverSQLite); err != nil {
return err
}
guard := `(SELECT IFNULL((SELECT v FROM ` + FlagTable + ` WHERE k='applying'),0))=0`
stmts := []string{
fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_ai_%s`, table),
fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_au_%s`, table),
fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_ad_%s`, table),
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ai_%s AFTER INSERT ON "%s" WHEN %s BEGIN
INSERT INTO %s(table_name,row_pk,op,payload,version,created_at)
VALUES('%s', CAST(NEW."%s" AS TEXT), 'upsert', '',
CAST(strftime('%%s','now') AS INTEGER)*1000, datetime('now'));
END;`, table, table, guard, OutboxTable, table, pk),
fmt.Sprintf(`CREATE TRIGGER ajz_sync_au_%s AFTER UPDATE ON "%s" WHEN %s BEGIN
INSERT INTO %s(table_name,row_pk,op,payload,version,created_at)
VALUES('%s', CAST(NEW."%s" AS TEXT), 'upsert', '',
CAST(strftime('%%s','now') AS INTEGER)*1000, datetime('now'));
END;`, table, table, guard, OutboxTable, table, pk),
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ad_%s AFTER DELETE ON "%s" WHEN %s BEGIN
INSERT INTO %s(table_name,row_pk,op,payload,version,created_at)
VALUES('%s', CAST(OLD."%s" AS TEXT), 'delete', '',
CAST(strftime('%%s','now') AS INTEGER)*1000, datetime('now'));
END;`, table, table, guard, OutboxTable, table, pk),
}
for _, s := range stmts {
if _, err := db.ExecContext(ctx, s); err != nil {
return fmt.Errorf("sqlite trigger %s: %w", table, err)
}
}
return nil
}
func installMySQLTriggers(ctx context.Context, db *sql.DB, table, pk string) error {
drops := []string{
fmt.Sprintf("DROP TRIGGER IF EXISTS ajz_sync_ai_%s", table),
fmt.Sprintf("DROP TRIGGER IF EXISTS ajz_sync_au_%s", table),
fmt.Sprintf("DROP TRIGGER IF EXISTS ajz_sync_ad_%s", table),
}
for _, s := range drops {
_, _ = db.ExecContext(ctx, s)
}
creates := []string{
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ai_%s AFTER INSERT ON `+"`%s`"+` FOR EACH ROW
BEGIN
IF IFNULL(@ajz_applying,0)=0 THEN
INSERT INTO %s(table_name,row_pk,op,payload,version)
VALUES('%s', CAST(NEW.`+"`%s`"+` AS CHAR), 'upsert', '', UNIX_TIMESTAMP(NOW(3))*1000);
END IF;
END`, table, table, OutboxTable, table, pk),
fmt.Sprintf(`CREATE TRIGGER ajz_sync_au_%s AFTER UPDATE ON `+"`%s`"+` FOR EACH ROW
BEGIN
IF IFNULL(@ajz_applying,0)=0 THEN
INSERT INTO %s(table_name,row_pk,op,payload,version)
VALUES('%s', CAST(NEW.`+"`%s`"+` AS CHAR), 'upsert', '', UNIX_TIMESTAMP(NOW(3))*1000);
END IF;
END`, table, table, OutboxTable, table, pk),
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ad_%s AFTER DELETE ON `+"`%s`"+` FOR EACH ROW
BEGIN
IF IFNULL(@ajz_applying,0)=0 THEN
INSERT INTO %s(table_name,row_pk,op,payload,version)
VALUES('%s', CAST(OLD.`+"`%s`"+` AS CHAR), 'delete', '', UNIX_TIMESTAMP(NOW(3))*1000);
END IF;
END`, table, table, OutboxTable, table, pk),
}
for _, s := range creates {
if _, err := db.ExecContext(ctx, s); err != nil {
return fmt.Errorf("mysql trigger %s: %w", table, err)
}
}
return nil
}
func installPostgresTriggers(ctx context.Context, db *sql.DB, table, pk string) error {
fn := fmt.Sprintf("ajz_sync_fn_%s", table)
_, err := db.ExecContext(ctx, fmt.Sprintf(`
CREATE OR REPLACE FUNCTION %s() RETURNS trigger AS $$
BEGIN
IF COALESCE(current_setting('ajz.applying', true), '0') = '1' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
IF TG_OP = 'DELETE' THEN
INSERT INTO %s(table_name,row_pk,op,payload,version)
VALUES('%s', OLD.%s::text, 'delete', '', (EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint);
RETURN OLD;
ELSE
INSERT INTO %s(table_name,row_pk,op,payload,version)
VALUES('%s', NEW.%s::text, 'upsert', '', (EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint);
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;`, fn, OutboxTable, table, quoteIdent(DriverPostgres, pk), OutboxTable, table, quoteIdent(DriverPostgres, pk)))
if err != nil {
return err
}
_, _ = db.ExecContext(ctx, fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_trg_%s ON %s`, table, quoteIdent(DriverPostgres, table)))
_, err = db.ExecContext(ctx, fmt.Sprintf(`
CREATE TRIGGER ajz_sync_trg_%s
AFTER INSERT OR UPDATE OR DELETE ON %s
FOR EACH ROW EXECUTE PROCEDURE %s();`, table, quoteIdent(DriverPostgres, table), fn))
return err
}
func ListTables(ctx context.Context, db *sql.DB, driver Driver) ([]string, error) {
var q string
switch driver {
case DriverSQLite:
q = `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_ajz_%' ORDER BY name`
case DriverMySQL:
q = `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name NOT LIKE '\_ajz\_%' ORDER BY table_name`
case DriverPostgres:
q = `SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename NOT LIKE '\_ajz\_%' ORDER BY tablename`
default:
return nil, fmt.Errorf("unsupported")
}
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}