This commit is contained in:
toom1996
2026-09-21 18:42:50 +08:00
parent 1fa1f856c0
commit dffcf9f9e5
2 changed files with 98 additions and 128 deletions

View File

@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"generated_at": "2026-09-21T09:22:33Z", "generated_at": "2026-09-21T10:42:40Z",
"database": "fashion", "database": "fashion",
"tables": [ "tables": [
{ {
@ -10,7 +10,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "draft_id", "name": "draft_id",
@ -8439,7 +8439,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "job_id", "name": "job_id",
@ -8668,7 +8668,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "created_at", "name": "created_at",
@ -19515,7 +19515,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "title_en", "name": "title_en",
@ -19702,7 +19702,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "created_at", "name": "created_at",
@ -19900,7 +19900,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "created_at", "name": "created_at",
@ -20081,7 +20081,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "viewed_at", "name": "viewed_at",
@ -20151,7 +20151,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": true, "is_identity": false,
"default": "nextval('image_embeddings_id_seq'::regclass)" "default": "nextval('image_embeddings_id_seq'::regclass)"
}, },
{ {
@ -20192,7 +20192,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "created_at", "name": "created_at",
@ -21932,7 +21932,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "user_id", "name": "user_id",
@ -22906,7 +22906,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "draft_id", "name": "draft_id",
@ -36659,7 +36659,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "job_id", "name": "job_id",
@ -36822,7 +36822,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "snap_id", "name": "snap_id",
@ -36898,7 +36898,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "title", "name": "title",
@ -36961,7 +36961,7 @@
"name": "id", "name": "id",
"type": "int8", "type": "int8",
"is_nullable": false, "is_nullable": false,
"is_identity": false "is_identity": true
}, },
{ {
"name": "created_at", "name": "created_at",

View File

@ -1,14 +1,20 @@
// Command dbtool 是后台管理用的数据库一键迁移脚本。 // Command dbtool 是后台管理用的数据库搬运脚本。
// //
// 用途:在不同开发电脑 / 环境之间搬运 PostgreSQL 库(fashion)的「结构 + 数据」, // 用途:在不同开发电脑 / 环境之间搬运 PostgreSQL 库(fashion)的「数据」,
// 省去手动 pg_dump / 重新 seed 的麻烦。完全复用后端既有的 pgx 驱动与 // 省去手动 pg_dump / 重新 seed 的麻烦。完全复用后端既有的 pgx 驱动与
// configs/config.yml,不依赖任何外部二进制(pg_dump 等)。 // configs/config.yml,不依赖任何外部二进制(pg_dump 等)。
// //
// 子命令: // 子命令:
// //
// dbtool dump -out db_dump.json 导出当前库全部表(schema+data)为单个 JSON 文件 // dbtool dump -out db_dump.json 导出当前库全部表的数据 + 列元信息为单个 JSON 文件
// dbtool import -in db_dump.json 读取 JSON 文件,DROP+CREATE+INSERT 回灌到目标库 // dbtool import -in db_dump.json 把 JSON 数据灌入目标库(结构先由 AutoMigrate 收敛)
// (可用 -data-only 只导数据,表结构须已由 AutoMigrate 创建) //
// 结构在哪里定义:**只在代码里** —— model 的 GORM tag + database.EnsureDedupSchema。
// import 会先调 database.AutoMigrate + EnsureDedupSchema,把目标库收敛到与当前代码
// 一致(建表 / 加列 / 加索引 / 建 pgvector 扩展 / 建 HNSW 索引),然后再灌数据。
//
// 历史实现曾让 import 自己读 information_schema 拼 DDL 来重建表,那条路必然丢信息
// (类型修饰符 vector(64)、identity 自增、索引、扩展),已移除。
// //
// 连接信息来自 configs/config.yml 的 database 段(同后端服务),可用 -config 指定其它配置。 // 连接信息来自 configs/config.yml 的 database 段(同后端服务),可用 -config 指定其它配置。
package main package main
@ -26,15 +32,26 @@ import (
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"fashionapi/internal/config" "fashionapi/internal/config"
"fashionapi/internal/database"
) )
// columnMeta 是单列的元信息,用于重建 DDL 与导入时类型转换。 // columnMeta 是单列的元信息,用于导入时的类型转换与 identity 判定。
type columnMeta struct { type columnMeta struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` // udt_name,如 int8 / varchar / timestamptz / bool / jsonb // Type 为 udt_name,如 int8 / varchar / timestamptz / vector。
// 注意:它**不含**类型修饰符(vector(64) 会退化成 vector),
// 因此不可用于重建 DDL —— 结构一律交给 database.AutoMigrate。
Type string `json:"type"`
IsNullable bool `json:"is_nullable"` 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"` IsIdentity bool `json:"is_identity"`
Default string `json:"default,omitempty"` Default string `json:"default,omitempty"`
} }
@ -96,15 +113,16 @@ func main() {
} }
func usage() { func usage() {
fmt.Println(`dbtool - 后台数据库一键迁移脚本(PostgreSQL) fmt.Println(`dbtool - 后台数据库搬运脚本(PostgreSQL)
用法: 用法:
dbtool dump [-config <yml>] [-out <file>] 导出全库 schema+data 到 JSON dbtool dump [-config <yml>] [-out <file>] 导出全库数据到 JSON
dbtool import [-config <yml>] [-in <file>] [-data-only] 从 JSON 回灌(默认 DROP+CREATE+INSERT) dbtool import [-config <yml>] [-in <file>] 把 JSON 数据灌入目标库
说明: 说明:
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。 连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
import 默认连结构带数据全部重建;-data-only 仅导数据(表结构须已由后端 AutoMigrate 创建)。`) import 会先把目标库结构收敛到与当前代码一致(AutoMigrate + EnsureDedupSchema),
再清空同名表并按 JSON 重灌数据。结构定义只在代码里,dump 文件不含 DDL。`)
} }
// openDB 按 config 加载 DSN 并探活。 // openDB 按 config 加载 DSN 并探活。
@ -170,12 +188,18 @@ func runDump(args []string) {
log.Printf("✓ 已导出 %d 张表 -> %s", len(df.Tables), *out) 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) { func runImport(args []string) {
fs := flag.NewFlagSet("import", flag.ExitOnError) fs := flag.NewFlagSet("import", flag.ExitOnError)
in := fs.String("in", "db_dump.json", "导入文件路径") in := fs.String("in", "db_dump.json", "导入文件路径")
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)") cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
dataOnly := fs.Bool("data-only", false, "仅导数据(表结构须已由 AutoMigrate 创建)")
_ = fs.Parse(args) _ = fs.Parse(args)
raw, err := os.ReadFile(*in) raw, err := os.ReadFile(*in)
@ -187,36 +211,28 @@ func runImport(args []string) {
log.Fatalf("✗ 解析 %s 失败: %v", *in, err) log.Fatalf("✗ 解析 %s 失败: %v", *in, err)
} }
db, _, err := openDB(*cfgPath) db, dcfg, err := openDB(*cfgPath)
if err != nil { if err != nil {
log.Fatalf("✗ %v", err) log.Fatalf("✗ %v", err)
} }
defer db.Close() 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 { if _, err := db.Exec("SET session_replication_role = 'replica'"); err != nil {
log.Fatalf("✗ 关闭约束检查失败: %v", err) log.Fatalf("✗ 关闭约束检查失败: %v", err)
} }
defer db.Exec("SET session_replication_role = 'origin'") defer db.Exec("SET session_replication_role = 'origin'")
for _, t := range df.Tables { for _, t := range df.Tables {
if !*dataOnly { // 先清空目标表(约束已由 replica 角色关闭),再插入。
log.Printf("→ 重建表 %s ...", t.Name) if _, err := db.Exec(fmt.Sprintf(`DELETE FROM "%s"`, t.Name)); err != nil {
if _, err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS "%s" CASCADE`, t.Name)); err != nil { log.Fatalf("✗ 清空表 %s 失败: %v", t.Name, err)
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)
}
} }
if err := importRows(db, t); err != nil { if err := importRows(db, t); err != nil {
log.Fatalf("✗ 导数据到 %s 失败: %v", t.Name, err) log.Fatalf("✗ 导数据到 %s 失败: %v", t.Name, err)
@ -226,6 +242,32 @@ func runImport(args []string) {
log.Printf("✓ 导入完成(%d 张表)", len(df.Tables)) 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 模式下所有基表名。 // listTables 返回 public 模式下所有基表名。
func listTables(db *sql.DB) ([]string, error) { func listTables(db *sql.DB) ([]string, error) {
rows, err := db.Query(` rows, err := db.Query(`
@ -293,12 +335,15 @@ func dumpTable(db *sql.DB, name string) (tableDump, error) {
// dumpColumns 读取列元信息与主键。 // dumpColumns 读取列元信息与主键。
func dumpColumns(db *sql.DB, table string) ([]columnMeta, []string, error) { 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(` colQuery := strings.Replace(`
SELECT c.column_name, SELECT c.column_name,
c.udt_name, c.udt_name,
(c.is_nullable = 'YES'), (c.is_nullable = 'YES'),
COALESCE(c.column_default, ''), COALESCE(c.column_default, ''),
COALESCE(c.column_default LIKE 'nextval(%', false) (c.is_identity = 'YES')
FROM information_schema.columns c FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = $1 WHERE c.table_schema = 'public' AND c.table_name = $1
ORDER BY c.ordinal_position`, "$1", quoteLit(table), 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 var cols []columnMeta
for rows.Next() { for rows.Next() {
var name, udt, def string var name, udt, def string
var nullable, isIdent bool var nullable, isIdentity bool
if err := rows.Scan(&name, &udt, &nullable, &def, &isIdent); err != nil { if err := rows.Scan(&name, &udt, &nullable, &def, &isIdentity); err != nil {
return nil, nil, err return nil, nil, err
} }
cols = append(cols, columnMeta{ 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 { 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() 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 类型转换后缀(用于导入时把字符串值转为正确类型)。 // castFor 返回列类型对应的 pg 类型转换后缀(用于导入时把字符串值转为正确类型)。
func castFor(udt string) string { func castFor(udt string) string {
switch udt { switch udt {