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") } // modernc.org/sqlite 注册名为 sqlite 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 } db.SetMaxOpenConns(5) db.SetMaxIdleConns(2) if err := db.Ping(); err != nil { _ = db.Close() return nil, err } return db, nil } 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() }