chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
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()
}