update
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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 <yml>] [-out <file>] 导出全库 schema+data 到 JSON
|
||||
dbtool import [-config <yml>] [-in <file>] [-data-only] 从 JSON 回灌(默认 DROP+CREATE+INSERT)
|
||||
dbtool dump [-config <yml>] [-out <file>] 导出全库数据到 JSON
|
||||
dbtool import [-config <yml>] [-in <file>] 把 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 {
|
||||
|
||||
Reference in New Issue
Block a user