diff --git a/cmd/dbtool/main.go b/cmd/dbtool/main.go index 42af2b9..ccdf9ff 100644 --- a/cmd/dbtool/main.go +++ b/cmd/dbtool/main.go @@ -1,6 +1,6 @@ // Command dbtool 是后台管理用的数据库导出 / 导入脚本。 // -// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 数据」导出为 +// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为 // **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。 // // dbtool dump [-config ] [-out ] [-with-extension] [-clean] 导出 @@ -23,9 +23,13 @@ // 自己实现一遍,因此**只覆盖本项目实际用到的对象**: // // 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、 -// 数据、序列当前值。 +// 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。 // -// 视图 / 触发器 / 外键 / 注释 / 权限不导出(当前库中也不存在)。 +// 触发器 / 外键 / 注释 / 权限不导出。 +// +// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01), +// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图, +// 公开查询直接报 relation does not exist。 // // 目标库为空还是已有数据: // @@ -57,6 +61,7 @@ import ( "fmt" "log" "os" + "sort" "strings" "time" @@ -112,7 +117,7 @@ func usage() { 用法: dbtool dump [-config ] [-out ] [-with-extension] [-clean] - 导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件 + 导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件 dbtool import [-config ] [-in ] [-clean] 把 dump 出来的 SQL 文件灌入目标库 @@ -178,13 +183,17 @@ func runDump(args []string) { if len(tables) == 0 { log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确") } + views, err := listViews(db) + if err != nil { + log.Fatalf("✗ 列举视图失败: %v", err) + } cols := make(map[string][]column, len(tables)) // ① 结构(-clean 时先输出清理段) fmt.Fprintln(w, "-- ==================== 结构 ====================") if *clean { - writeClean(w, tables) + writeClean(w, tables, views) } for _, t := range tables { cs, err := listColumns(db, t) @@ -194,7 +203,11 @@ func runDump(args []string) { cols[t] = cs writeCreateTable(w, t, cs) } - log.Printf("→ 结构:%d 张表", len(tables)) + // 视图放在全部基表之后建:视图可能引用基表,也可能引用另一个视图(listViews 已按依赖排序)。 + for _, v := range views { + writeCreateView(w, v) + } + log.Printf("→ 结构:%d 张表、%d 个视图", len(tables), len(views)) // ② 数据 fmt.Fprintln(w, "-- ==================== 数据 ====================") @@ -306,19 +319,169 @@ func execScript(ctx context.Context, db *sql.DB, script string) error { }) } -// writeClean 输出清理段:DROP 掉本次要重建的表,让脚本可以灌进「已有数据的库」。 +// writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。 // -// 只 DROP 本次 dump 里出现的表(与 pg_dump --clean 口径一致)——目标库中多出来的表保持不动。 -// 用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举; +// 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。 +// 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。 +// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举; // 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。 -func writeClean(w *bufio.Writer, tables []string) { - fmt.Fprintln(w, "-- 清理(-clean):DROP 下列表,目标库中这些表的既有数据将丢失") +func writeClean(w *bufio.Writer, tables []string, views []viewDef) { + fmt.Fprintln(w, "-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失") + for i := len(views) - 1; i >= 0; i-- { + fmt.Fprintf(w, "DROP VIEW IF EXISTS %s;\n", qname(views[i].Name)) + } for _, t := range tables { fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t)) } fmt.Fprintln(w) } +// viewDef 单个视图的名字与定义(定义取自 pg_get_viewdef,已去掉前导空白)。 +type viewDef struct { + Name string + Def string +} + +// listViews 返回 public 下全部视图,按**依赖顺序**排列(被依赖者在前)。 +// +// 视图可以依赖基表,也可以依赖另一个视图;重建时必须先建被依赖者,否则 CREATE VIEW 会报 +// relation does not exist。顺序由 pg_depend 推出的「视图→视图」依赖做拓扑排序得到。 +func listViews(db *sql.DB) ([]viewDef, error) { + rows, err := db.Query(` + SELECT c.relname, pg_get_viewdef(c.oid, true) + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'v' + ORDER BY c.relname`) + if err != nil { + return nil, err + } + defer rows.Close() + + defs := map[string]string{} + var names []string + for rows.Next() { + var name, def string + if err := rows.Scan(&name, &def); err != nil { + return nil, err + } + defs[name] = def + names = append(names, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(names) == 0 { + return nil, nil + } + + deps, err := listViewDeps(db) + if err != nil { + return nil, err + } + ordered, err := topoSortViews(names, deps) + if err != nil { + return nil, err + } + out := make([]viewDef, 0, len(ordered)) + for _, name := range ordered { + // pg_get_viewdef 末尾自带分号,去掉后由 writeCreateView 统一补,避免出现 ";;"。 + body := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(defs[name]), ";")) + out = append(out, viewDef{Name: name, Def: body}) + } + return out, nil +} + +// listViewDeps 返回「视图 → 它直接引用的另一个视图」的依赖边(忽略对基表的引用)。 +func listViewDeps(db *sql.DB) (map[string][]string, error) { + rows, err := db.Query(` + SELECT DISTINCT v.relname, d.relname + FROM pg_depend dep + JOIN pg_rewrite rw ON rw.oid = dep.objid + JOIN pg_class v ON v.oid = rw.ev_class + JOIN pg_class d ON d.oid = dep.refobjid + JOIN pg_namespace n ON n.oid = v.relnamespace + WHERE dep.classid = 'pg_rewrite'::regclass + AND dep.refclassid = 'pg_class'::regclass + AND dep.deptype = 'n' + AND v.relkind = 'v' AND d.relkind = 'v' + AND v.oid <> d.oid + AND n.nspname = 'public'`) + if err != nil { + return nil, err + } + defer rows.Close() + + deps := map[string][]string{} + for rows.Next() { + var v, d string + if err := rows.Scan(&v, &d); err != nil { + return nil, err + } + deps[v] = append(deps[v], d) + } + return deps, rows.Err() +} + +// topoSortViews 对视图做拓扑排序:被依赖者在前。每步用名字排序兜底,保证导出可复现。 +func topoSortViews(names []string, deps map[string][]string) ([]string, error) { + known := make(map[string]bool, len(names)) + indeg := make(map[string]int, len(names)) + for _, n := range names { + known[n] = true + indeg[n] = 0 + } + + adj := map[string][]string{} + for v, ds := range deps { + if !known[v] { + continue + } + for _, d := range ds { + if !known[d] || d == v { + continue + } + adj[d] = append(adj[d], v) // d 必须先建,建完 d 才轮到 v + indeg[v]++ + } + } + + ready := make([]string, 0, len(names)) + for _, n := range names { + if indeg[n] == 0 { + ready = append(ready, n) + } + } + sort.Strings(ready) + + out := make([]string, 0, len(names)) + for len(ready) > 0 { + n := ready[0] + ready = ready[1:] + out = append(out, n) + + next := append([]string(nil), adj[n]...) + sort.Strings(next) + for _, m := range next { + indeg[m]-- + if indeg[m] == 0 { + ready = append(ready, m) + } + } + sort.Strings(ready) + } + if len(out) != len(names) { + return nil, fmt.Errorf("视图依赖存在环,无法排序(已完成 %d/%d)", len(out), len(names)) + } + return out, nil +} + +// writeCreateView 输出一个视图的 CREATE VIEW。 +// 定义来自 pg_get_viewdef(形如「 SELECT ...」),去掉前导空白后接到 AS 之后即可。 +func writeCreateView(w *bufio.Writer, v viewDef) { + fmt.Fprintf(w, "CREATE VIEW %s AS\n%s;\n\n", qname(v.Name), v.Def) +} + // writeHeader 写文件头与几个会话设置。 func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) { fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr()) diff --git a/db_dump.sql b/db_dump.sql index c044ce9..7147e0e 100644 --- a/db_dump.sql +++ b/db_dump.sql @@ -1,5 +1,5 @@ -- dbtool 导出:127.0.0.1:5432/fashion --- 生成时间:2026-09-23T13:49:34+08:00 +-- 生成时间:2026-09-23T14:06:13+08:00 -- -- 导入(二选一): -- dbtool import -in <本文件> @@ -171,6 +171,85 @@ CREATE TABLE IF NOT EXISTS public."users" ( "tier" character varying(16) ); +CREATE VIEW public."public_brand_runway_images" AS +SELECT i.id, + i.created_at, + i.updated_at, + i.is_deleted, + i.image, + i.runway_id, + i.brand_id, + i.name, + i.sort_order, + i.look_index, + i.is_detail, + i.phash, + i.is_duplicate, + i.dup_of + FROM brand_runway_images i + JOIN brand_runways r ON r.id = i.runway_id + WHERE i.is_deleted = 0 AND r.status::text = 'published'::text AND r.is_deleted = 0; + +CREATE VIEW public."public_brand_runways" AS +SELECT brand_runways.id, + brand_runways.title_en, + brand_runways.title_cn, + brand_runways.description_en, + brand_runways.description_cn, + brand_runways.created_at, + brand_runways.updated_at, + brand_runways.is_deleted, + brand_runways.image_count, + brand_runways.brand_id, + brand_runways.year, + brand_runways.cover, + brand_runways.collection_type, + brand_runways.season, + brand_runways.season_code, + brand_runways.status, + brand_runways.job_id, + brand_runways.reviewer, + brand_runways.reject_reason + FROM brand_runways + WHERE brand_runways.status::text = 'published'::text AND brand_runways.is_deleted = 0; + +CREATE VIEW public."public_street_snap_images" AS +SELECT i.id, + i.snap_id, + i.image, + i.name, + i.sort_order, + i.is_deleted, + i.created_at, + i.updated_at, + i.phash, + i.is_duplicate, + i.dup_of, + i.is_detail, + i.parent_image_id + FROM street_snap_images i + JOIN street_snaps s ON s.id = i.snap_id + WHERE i.is_deleted = 0 AND s.status::text = 'published'::text AND s.is_deleted = 0; + +CREATE VIEW public."public_street_snaps" AS +SELECT street_snaps.id, + street_snaps.title, + street_snaps.year, + street_snaps.city, + street_snaps.cover, + street_snaps.image_count, + street_snaps.is_deleted, + street_snaps.created_at, + street_snaps.updated_at, + street_snaps.source, + street_snaps.source_url, + street_snaps.status, + street_snaps.job_id, + street_snaps.reviewer, + street_snaps.reject_reason + FROM street_snaps + WHERE street_snaps.status::text = 'published'::text AND street_snaps.is_deleted = 0; + -- ==================== 数据 ==================== INSERT INTO public."brand_runway_images" ("id", "created_at", "updated_at", "is_deleted", "image", "runway_id", "brand_id", "name", "sort_order", "look_index", "is_detail", "phash", "is_duplicate", "dup_of") VALUES (E'989', E'1789137485', E'1789137485', E'0', E'runway/3b9b7e07916b454ba93c56b4d2edc81614101e3c.jpg', E'9', E'7', E'Look 1', E'1', E'1', E'0', NULL, E'0', E''),