From dffcf9f9e5a2ee5f5ae9e025a3740609d9eeb7c2 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Mon, 21 Sep 2026 18:42:50 +0800 Subject: [PATCH] update --- cmd/dbtool/db_dump.json | 32 +++---- cmd/dbtool/main.go | 194 +++++++++++++++++----------------------- 2 files changed, 98 insertions(+), 128 deletions(-) diff --git a/cmd/dbtool/db_dump.json b/cmd/dbtool/db_dump.json index b619ab0..5e190c8 100644 --- a/cmd/dbtool/db_dump.json +++ b/cmd/dbtool/db_dump.json @@ -1,6 +1,6 @@ { "version": 1, - "generated_at": "2026-09-21T09:22:33Z", + "generated_at": "2026-09-21T10:42:40Z", "database": "fashion", "tables": [ { @@ -10,7 +10,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "draft_id", @@ -8439,7 +8439,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "job_id", @@ -8668,7 +8668,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "created_at", @@ -19515,7 +19515,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "title_en", @@ -19702,7 +19702,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "created_at", @@ -19900,7 +19900,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "created_at", @@ -20081,7 +20081,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "viewed_at", @@ -20151,7 +20151,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": true, + "is_identity": false, "default": "nextval('image_embeddings_id_seq'::regclass)" }, { @@ -20192,7 +20192,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "created_at", @@ -21932,7 +21932,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "user_id", @@ -22906,7 +22906,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "draft_id", @@ -36659,7 +36659,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "job_id", @@ -36822,7 +36822,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "snap_id", @@ -36898,7 +36898,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "title", @@ -36961,7 +36961,7 @@ "name": "id", "type": "int8", "is_nullable": false, - "is_identity": false + "is_identity": true }, { "name": "created_at", diff --git a/cmd/dbtool/main.go b/cmd/dbtool/main.go index 90464ea..134c197 100644 --- a/cmd/dbtool/main.go +++ b/cmd/dbtool/main.go @@ -1,14 +1,20 @@ -// Command dbtool 是后台管理用的数据库一键迁移脚本。 +// Command dbtool 是后台管理用的数据库搬运脚本。 // -// 用途:在不同开发电脑 / 环境之间搬运 PostgreSQL 库(fashion)的「结构 + 数据」, +// 用途:在不同开发电脑 / 环境之间搬运 PostgreSQL 库(fashion)的「数据」, // 省去手动 pg_dump / 重新 seed 的麻烦。完全复用后端既有的 pgx 驱动与 // configs/config.yml,不依赖任何外部二进制(pg_dump 等)。 // // 子命令: // -// dbtool dump -out db_dump.json 导出当前库全部表(schema+data)为单个 JSON 文件 -// dbtool import -in db_dump.json 读取 JSON 文件,DROP+CREATE+INSERT 回灌到目标库 -// (可用 -data-only 只导数据,表结构须已由 AutoMigrate 创建) +// dbtool dump -out db_dump.json 导出当前库全部表的数据 + 列元信息为单个 JSON 文件 +// dbtool import -in db_dump.json 把 JSON 数据灌入目标库(结构先由 AutoMigrate 收敛) +// +// 结构在哪里定义:**只在代码里** —— model 的 GORM tag + database.EnsureDedupSchema。 +// import 会先调 database.AutoMigrate + EnsureDedupSchema,把目标库收敛到与当前代码 +// 一致(建表 / 加列 / 加索引 / 建 pgvector 扩展 / 建 HNSW 索引),然后再灌数据。 +// +// 历史实现曾让 import 自己读 information_schema 拼 DDL 来重建表,那条路必然丢信息 +// (类型修饰符 vector(64)、identity 自增、索引、扩展),已移除。 // // 连接信息来自 configs/config.yml 的 database 段(同后端服务),可用 -config 指定其它配置。 package main @@ -26,15 +32,26 @@ import ( "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" "fashionapi/internal/config" + "fashionapi/internal/database" ) -// columnMeta 是单列的元信息,用于重建 DDL 与导入时类型转换。 +// columnMeta 是单列的元信息,用于导入时的类型转换与 identity 判定。 type columnMeta struct { - Name string `json:"name"` - Type string `json:"type"` // udt_name,如 int8 / varchar / timestamptz / bool / jsonb + Name string `json:"name"` + // Type 为 udt_name,如 int8 / varchar / timestamptz / vector。 + // 注意:它**不含**类型修饰符(vector(64) 会退化成 vector), + // 因此不可用于重建 DDL —— 结构一律交给 database.AutoMigrate。 + Type string `json:"type"` IsNullable bool `json:"is_nullable"` + // IsIdentity 取 information_schema.columns.is_identity。 + // 必须读这一列:identity 列的 column_default 是 NULL, + // 用 column_default LIKE 'nextval(%' 去猜会把它全部漏判为「非自增」, + // 于是重建出的表 id 没有默认值、插入即违反 NOT NULL。 IsIdentity bool `json:"is_identity"` Default string `json:"default,omitempty"` } @@ -96,15 +113,16 @@ func main() { } func usage() { - fmt.Println(`dbtool - 后台数据库一键迁移脚本(PostgreSQL) + fmt.Println(`dbtool - 后台数据库搬运脚本(PostgreSQL) 用法: - dbtool dump [-config ] [-out ] 导出全库 schema+data 到 JSON - dbtool import [-config ] [-in ] [-data-only] 从 JSON 回灌(默认 DROP+CREATE+INSERT) + dbtool dump [-config ] [-out ] 导出全库数据到 JSON + dbtool import [-config ] [-in ] 把 JSON 数据灌入目标库 说明: 连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。 - import 默认连结构带数据全部重建;-data-only 仅导数据(表结构须已由后端 AutoMigrate 创建)。`) + import 会先把目标库结构收敛到与当前代码一致(AutoMigrate + EnsureDedupSchema), + 再清空同名表并按 JSON 重灌数据。结构定义只在代码里,dump 文件不含 DDL。`) } // openDB 按 config 加载 DSN 并探活。 @@ -170,12 +188,18 @@ func runDump(args []string) { log.Printf("✓ 已导出 %d 张表 -> %s", len(df.Tables), *out) } -// runImport 回灌。 +// runImport 把 JSON 中的数据灌入目标库。 +// +// 结构不在这里重建:先调 ensureSchema 用后端的 AutoMigrate + EnsureDedupSchema +// 把目标库收敛到与当前代码一致(建表 / 加列 / 加索引 / 建 pgvector 扩展 / 建 HNSW 索引), +// 本函数只负责搬数据。这样结构只有一个来源(model + EnsureDedupSchema), +// 不会再出现「自拼 DDL 丢类型修饰符 / 丢 identity 自增 / 丢索引 / 不建扩展」那一类问题。 +// +// 注意:导入先清空目标同名表再写入,属「以 dump 为准的整体覆盖」。 func runImport(args []string) { fs := flag.NewFlagSet("import", flag.ExitOnError) in := fs.String("in", "db_dump.json", "导入文件路径") cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)") - dataOnly := fs.Bool("data-only", false, "仅导数据(表结构须已由 AutoMigrate 创建)") _ = fs.Parse(args) raw, err := os.ReadFile(*in) @@ -187,36 +211,28 @@ func runImport(args []string) { log.Fatalf("✗ 解析 %s 失败: %v", *in, err) } - db, _, err := openDB(*cfgPath) + db, dcfg, err := openDB(*cfgPath) if err != nil { log.Fatalf("✗ %v", err) } defer db.Close() - // 关闭外键 / 触发器,避免插入顺序受约束(整库重建无需保序)。 + // ① 结构:与后端同一套定义(含 CREATE EXTENSION vector、HNSW 索引、老库兼容迁移)。 + log.Printf("→ 收敛目标库结构(AutoMigrate + EnsureDedupSchema)...") + if err := ensureSchema(dcfg); err != nil { + log.Fatalf("✗ 结构初始化失败: %v", err) + } + + // ② 数据:关闭外键 / 触发器,避免插入顺序受约束(整库覆盖无需保序)。 if _, err := db.Exec("SET session_replication_role = 'replica'"); err != nil { log.Fatalf("✗ 关闭约束检查失败: %v", err) } defer db.Exec("SET session_replication_role = 'origin'") for _, t := range df.Tables { - if !*dataOnly { - log.Printf("→ 重建表 %s ...", t.Name) - if _, err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS "%s" CASCADE`, t.Name)); err != nil { - log.Fatalf("✗ 删表 %s 失败: %v", t.Name, err) - } - ddl, err := buildCreate(t) - if err != nil { - log.Fatalf("✗ 生成建表语句失败(%s): %v", t.Name, err) - } - if _, err := db.Exec(ddl); err != nil { - log.Fatalf("✗ 建表 %s 失败: %v", t.Name, err) - } - } else { - // 仅导数据:先清空目标表(约束已由 replica 角色关闭),再插入。 - if _, err := db.Exec(fmt.Sprintf(`DELETE FROM "%s"`, t.Name)); err != nil { - log.Fatalf("✗ 清空表 %s 失败: %v", t.Name, err) - } + // 先清空目标表(约束已由 replica 角色关闭),再插入。 + if _, err := db.Exec(fmt.Sprintf(`DELETE FROM "%s"`, t.Name)); err != nil { + log.Fatalf("✗ 清空表 %s 失败: %v", t.Name, err) } if err := importRows(db, t); err != nil { log.Fatalf("✗ 导数据到 %s 失败: %v", t.Name, err) @@ -226,6 +242,32 @@ func runImport(args []string) { log.Printf("✓ 导入完成(%d 张表)", len(df.Tables)) } +// ensureSchema 用后端的 database.AutoMigrate + EnsureDedupSchema 把目标库结构 +// 收敛到与当前代码一致。这是「结构定义只有一个来源」的落点: +// 表 / 列来自 model 的 GORM tag,扩展与 HNSW 索引来自 EnsureDedupSchema。 +func ensureSchema(dcfg *config.DatabaseConfig) error { + gdb, err := gorm.Open(postgres.Open(dcfg.DSN()), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + SkipDefaultTransaction: true, + }) + if err != nil { + return fmt.Errorf("连接数据库失败: %w", err) + } + sqlDB, err := gdb.DB() + if err != nil { + return fmt.Errorf("获取底层连接池失败: %w", err) + } + defer sqlDB.Close() + + if err := database.AutoMigrate(gdb); err != nil { + return fmt.Errorf("AutoMigrate: %w", err) + } + if err := database.EnsureDedupSchema(gdb); err != nil { + return fmt.Errorf("EnsureDedupSchema: %w", err) + } + return nil +} + // listTables 返回 public 模式下所有基表名。 func listTables(db *sql.DB) ([]string, error) { rows, err := db.Query(` @@ -293,12 +335,15 @@ func dumpTable(db *sql.DB, name string) (tableDump, error) { // dumpColumns 读取列元信息与主键。 func dumpColumns(db *sql.DB, table string) ([]columnMeta, []string, error) { + // is_identity 必须直接读 information_schema.columns.is_identity: + // identity 列的 column_default 是 NULL,用 column_default LIKE 'nextval(%' 判断 + // 会把 GENERATED BY DEFAULT AS IDENTITY 的列全部漏判成「非自增」。 colQuery := strings.Replace(` SELECT c.column_name, c.udt_name, (c.is_nullable = 'YES'), COALESCE(c.column_default, ''), - COALESCE(c.column_default LIKE 'nextval(%', false) + (c.is_identity = 'YES') FROM information_schema.columns c WHERE c.table_schema = 'public' AND c.table_name = $1 ORDER BY c.ordinal_position`, "$1", quoteLit(table), 1) @@ -312,12 +357,12 @@ func dumpColumns(db *sql.DB, table string) ([]columnMeta, []string, error) { var cols []columnMeta for rows.Next() { var name, udt, def string - var nullable, isIdent bool - if err := rows.Scan(&name, &udt, &nullable, &def, &isIdent); err != nil { + var nullable, isIdentity bool + if err := rows.Scan(&name, &udt, &nullable, &def, &isIdentity); err != nil { return nil, nil, err } cols = append(cols, columnMeta{ - Name: name, Type: udt, IsNullable: nullable, IsIdentity: isIdent, Default: def, + Name: name, Type: udt, IsNullable: nullable, IsIdentity: isIdentity, Default: def, }) } if err := rows.Err(); err != nil { @@ -348,81 +393,6 @@ func dumpColumns(db *sql.DB, table string) ([]columnMeta, []string, error) { return cols, pk, pkRows.Err() } -// pgType 将 udt_name 映射为建表用的列类型。 -func pgType(udt string) string { - switch udt { - case "int2": - return "smallint" - case "int4": - return "integer" - case "int8": - return "bigint" - case "numeric": - return "numeric" - case "float4": - return "real" - case "float8": - return "double precision" - case "bool": - return "boolean" - case "timestamp": - return "timestamp" - case "timestamptz": - return "timestamptz" - case "date": - return "date" - case "time": - return "time" - case "text": - return "text" - case "json": - return "json" - case "jsonb": - return "jsonb" - case "uuid": - return "uuid" - case "bytea": - return "bytea" - default: - return udt // varchar / char / 未知类型原样返回 - } -} - -// buildCreate 由列元信息重建 CREATE TABLE 语句。 -func buildCreate(t tableDump) (string, error) { - var b strings.Builder - b.WriteString(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS "%s" (`, t.Name)) - first := true - for _, c := range t.Columns { - if !first { - b.WriteString(",") - } - first = false - b.WriteString(fmt.Sprintf("\n \"%s\" %s", c.Name, pgType(c.Type))) - if c.IsIdentity { - b.WriteString(" GENERATED BY DEFAULT AS IDENTITY") - } else if c.Default != "" { - b.WriteString(" DEFAULT " + c.Default) - } - if !c.IsNullable && !c.IsIdentity { - b.WriteString(" NOT NULL") - } - } - if len(t.Pk) > 0 { - b.WriteString(",\n PRIMARY KEY (" + strings.Join(quoteAll(t.Pk), ",") + ")") - } - b.WriteString("\n);") - return b.String(), nil -} - -func quoteAll(cols []string) []string { - out := make([]string, len(cols)) - for i, c := range cols { - out[i] = `"` + c + `"` - } - return out -} - // castFor 返回列类型对应的 pg 类型转换后缀(用于导入时把字符串值转为正确类型)。 func castFor(udt string) string { switch udt {