main.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. // Command vrtxsqlweb 给 migrate 库套一个本地 Web 界面。
  2. //
  3. // 和 cmd/vrtxsql 是同一套能力, 只是把 flag 换成浏览器里的表单:
  4. // 填两个 DSN、看一眼源库结构、勾选要迁的表、点一下, 迁移过程以日志流实时回显,
  5. // 结束给出每张表的报告 (行数 / 列数 / 索引数 / 耗时)。
  6. //
  7. // go run ./cmd/vrtxsqlweb -addr :8099
  8. //
  9. // 实现只用了标准库 + 一个内嵌的 index.html, 没有前端工具链, 也没有外部依赖:
  10. // 它把 migrate 这个库直接暴露给 HTTP, 不复制任何迁移逻辑。
  11. package main
  12. import (
  13. _ "embed"
  14. "encoding/json"
  15. "flag"
  16. "fmt"
  17. "log"
  18. "net/http"
  19. "os"
  20. "path/filepath"
  21. "runtime"
  22. "sort"
  23. "strings"
  24. "time"
  25. "TsDB2/migrate"
  26. )
  27. // index.html 是这份单页界面, 编译期嵌进二进制 —— 运行时不需要任何静态文件。
  28. //
  29. //go:embed index.html
  30. var indexHTML []byte
  31. func main() {
  32. // 默认只听回环: 这是个本地工具, 而且 /api/browse 能列本机任意目录。
  33. addr := flag.String("addr", "127.0.0.1:8099", "监听地址, 如 127.0.0.1:8099")
  34. flag.Parse()
  35. mux := http.NewServeMux()
  36. mux.HandleFunc("/", handleIndex)
  37. mux.HandleFunc("/api/browse", handleBrowse)
  38. mux.HandleFunc("/api/stat", handleStat)
  39. mux.HandleFunc("/api/list", handleList)
  40. mux.HandleFunc("/api/databases", handleDatabases)
  41. mux.HandleFunc("/api/migrate", handleMigrate)
  42. url := "http://" + *addr
  43. if strings.HasPrefix(*addr, ":") {
  44. url = "http://localhost" + *addr
  45. }
  46. log.Printf("vrtxsqlweb 已启动: %s", url)
  47. log.Printf("提示: 服务运行的当前目录就是相对路径的基准目录")
  48. // 迁移大表可能跑很久, 这里刻意不设 WriteTimeout。
  49. if err := http.ListenAndServe(*addr, mux); err != nil {
  50. log.Fatal(err)
  51. }
  52. }
  53. func handleIndex(w http.ResponseWriter, r *http.Request) {
  54. if r.URL.Path != "/" {
  55. http.NotFound(w, r)
  56. return
  57. }
  58. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  59. _, _ = w.Write(indexHTML)
  60. }
  61. // ============================ /api/browse ============================
  62. // browseReq.Path 用指针是为了区分「字段没传」和「传了空串」:
  63. //
  64. // nil → 从服务进程的当前目录起步 (前端首次打开弹窗)
  65. // "" → 虚拟根 (Windows 列出所有盘符; Unix 就是 /)
  66. // 其他 → 该目录; 若给的是文件, 自动退到它所在目录
  67. type browseReq struct {
  68. Path *string `json:"path"`
  69. }
  70. type browseEntry struct {
  71. Name string `json:"name"`
  72. Path string `json:"path"`
  73. IsDir bool `json:"isDir"`
  74. Size int64 `json:"size"`
  75. Ext string `json:"ext"`
  76. }
  77. type browseResp struct {
  78. Path string `json:"path"`
  79. Parent string `json:"parent"`
  80. Root bool `json:"root"` // 已到顶 (此电脑 / 文件系统根)
  81. Dirs []browseEntry `json:"dirs"`
  82. Files []browseEntry `json:"files"`
  83. Error string `json:"error,omitempty"`
  84. }
  85. // handleBrowse 给前端的「浏览…」弹窗列目录。
  86. // 这是本地工具且默认只监听回环地址, 所以不做沙箱限制。
  87. func handleBrowse(w http.ResponseWriter, r *http.Request) {
  88. var req browseReq
  89. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  90. writeJSON(w, browseResp{Error: "请求体解析失败: " + err.Error()})
  91. return
  92. }
  93. var dir string
  94. switch {
  95. case req.Path == nil:
  96. wd, err := os.Getwd()
  97. if err != nil {
  98. writeJSON(w, browseResp{Error: "取当前目录失败: " + err.Error()})
  99. return
  100. }
  101. dir = wd
  102. case *req.Path == "":
  103. if runtime.GOOS == "windows" {
  104. writeJSON(w, browseResp{Dirs: windowsDrives(), Root: true})
  105. return
  106. }
  107. dir = string(filepath.Separator)
  108. default:
  109. dir = *req.Path
  110. }
  111. abs, err := filepath.Abs(dir)
  112. if err != nil {
  113. writeJSON(w, browseResp{Error: "路径非法: " + err.Error()})
  114. return
  115. }
  116. if st, err := os.Stat(abs); err == nil && !st.IsDir() {
  117. abs = filepath.Dir(abs)
  118. }
  119. resp := browseResp{Path: abs, Dirs: []browseEntry{}, Files: []browseEntry{}}
  120. switch parent := filepath.Dir(abs); {
  121. case parent == abs && runtime.GOOS == "windows":
  122. resp.Parent = "" // 盘根再往上一级 = 此电脑
  123. case parent == abs:
  124. resp.Root = true // Unix 的 /
  125. default:
  126. resp.Parent = parent
  127. }
  128. ents, err := os.ReadDir(abs)
  129. if err != nil {
  130. resp.Error = "读取目录失败: " + err.Error()
  131. writeJSON(w, resp)
  132. return
  133. }
  134. for _, e := range ents {
  135. name := e.Name()
  136. if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") {
  137. continue // 隐藏项和系统目录不展示
  138. }
  139. full := filepath.Join(abs, name)
  140. if e.IsDir() {
  141. resp.Dirs = append(resp.Dirs, browseEntry{Name: name, Path: full, IsDir: true})
  142. continue
  143. }
  144. var size int64
  145. if info, err := e.Info(); err == nil {
  146. size = info.Size()
  147. }
  148. resp.Files = append(resp.Files, browseEntry{
  149. Name: name,
  150. Path: full,
  151. Size: size,
  152. Ext: strings.ToLower(filepath.Ext(name)),
  153. })
  154. }
  155. sort.Slice(resp.Dirs, func(i, j int) bool { return resp.Dirs[i].Name < resp.Dirs[j].Name })
  156. sort.Slice(resp.Files, func(i, j int) bool { return resp.Files[i].Name < resp.Files[j].Name })
  157. writeJSON(w, resp)
  158. }
  159. func windowsDrives() []browseEntry {
  160. out := make([]browseEntry, 0, 8)
  161. for c := 'A'; c <= 'Z'; c++ {
  162. root := string(c) + ":\\"
  163. if st, err := os.Stat(root); err == nil && st.IsDir() {
  164. out = append(out, browseEntry{Name: root, Path: root, IsDir: true})
  165. }
  166. }
  167. return out
  168. }
  169. // ============================ /api/stat ============================
  170. type statReq struct {
  171. Path string `json:"path"`
  172. }
  173. type statResp struct {
  174. Path string `json:"path"`
  175. Exists bool `json:"exists"`
  176. IsDir bool `json:"isDir"`
  177. Size int64 `json:"size"`
  178. Parent string `json:"parent"`
  179. ParentExists bool `json:"parentExists"`
  180. Error string `json:"error,omitempty"`
  181. }
  182. // handleStat 让前端能显示「文件已存在 / 不存在(将新建) / 父目录不存在」。
  183. // 目标库一般是新建的, 所以要能区分「还没这个文件」和「路径写错了」。
  184. func handleStat(w http.ResponseWriter, r *http.Request) {
  185. var req statReq
  186. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  187. writeJSON(w, statResp{Error: "请求体解析失败: " + err.Error()})
  188. return
  189. }
  190. p := strings.TrimSpace(req.Path)
  191. if p == "" {
  192. writeJSON(w, statResp{})
  193. return
  194. }
  195. abs, err := filepath.Abs(p)
  196. if err != nil {
  197. writeJSON(w, statResp{Error: "路径非法: " + err.Error()})
  198. return
  199. }
  200. resp := statResp{Path: abs, Parent: filepath.Dir(abs)}
  201. if st, err := os.Stat(abs); err == nil {
  202. resp.Exists = true
  203. resp.IsDir = st.IsDir()
  204. resp.Size = st.Size()
  205. }
  206. if st, err := os.Stat(resp.Parent); err == nil && st.IsDir() {
  207. resp.ParentExists = true
  208. }
  209. writeJSON(w, resp)
  210. }
  211. // ============================ /api/list ============================
  212. type listReq struct {
  213. DSN string `json:"dsn"`
  214. Tables []string `json:"tables"`
  215. }
  216. type colInfo struct {
  217. Name string `json:"name"`
  218. Type string `json:"type"`
  219. Size int `json:"size"`
  220. Dynamic bool `json:"dynamic"`
  221. IsPK bool `json:"isPK"`
  222. Synthetic bool `json:"synthetic"`
  223. }
  224. type idxInfo struct {
  225. Name string `json:"name"`
  226. Type string `json:"type"`
  227. Columns []string `json:"columns"`
  228. RowStore bool `json:"rowStore"`
  229. }
  230. type tableInfo struct {
  231. Name string `json:"name"`
  232. Columns []colInfo `json:"columns"`
  233. PKColumn string `json:"pkColumn"`
  234. PKIndexType string `json:"pkIndexType"`
  235. RowStore []string `json:"rowStore"`
  236. Indexes []idxInfo `json:"indexes"`
  237. Rows uint64 `json:"rows"`
  238. }
  239. type listResp struct {
  240. Kind string `json:"kind"`
  241. Identity string `json:"identity"`
  242. Tables []tableInfo `json:"tables"`
  243. Error string `json:"error,omitempty"`
  244. }
  245. type dbListReq struct {
  246. Host string `json:"host"`
  247. Port string `json:"port"`
  248. User string `json:"user"`
  249. Pass string `json:"pass"`
  250. }
  251. type dbListResp struct {
  252. Databases []string `json:"databases,omitempty"`
  253. Error string `json:"error,omitempty"`
  254. }
  255. // sysSchemas MySQL 自带的库, 迁它们没有意义, 从候选里去掉。
  256. var sysSchemas = map[string]bool{
  257. "information_schema": true,
  258. "performance_schema": true,
  259. "mysql": true,
  260. "sys": true,
  261. }
  262. // handleDatabases 只回答"这台 MySQL 上有哪些库", 给连接表单把库名做成候选。
  263. // 库名不必先知道 —— 能不能列出库只取决于账号权限。
  264. func handleDatabases(w http.ResponseWriter, r *http.Request) {
  265. var req dbListReq
  266. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  267. writeJSON(w, dbListResp{Error: "请求体解析失败: " + err.Error()})
  268. return
  269. }
  270. user := strings.TrimSpace(req.User)
  271. if user == "" {
  272. writeJSON(w, dbListResp{Error: "请先填写用户名"})
  273. return
  274. }
  275. host := strings.TrimSpace(req.Host)
  276. if host == "" {
  277. host = "127.0.0.1"
  278. }
  279. port := strings.TrimSpace(req.Port)
  280. if port == "" {
  281. port = "3306"
  282. }
  283. // 库名留空: / 后面只挂连接参数, 顺序不能反过来
  284. dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?charset=utf8mb4", user, req.Pass, host, port)
  285. names, err := migrate.MySQLDatabases(dsn)
  286. if err != nil {
  287. writeJSON(w, dbListResp{Error: friendlyDBErr(err, host, port)})
  288. return
  289. }
  290. keep := make([]string, 0, len(names))
  291. for _, n := range names {
  292. if !sysSchemas[n] {
  293. keep = append(keep, n)
  294. }
  295. }
  296. writeJSON(w, dbListResp{Databases: keep})
  297. }
  298. // friendlyDBErr 把驱动的报错翻成"接下来该查什么"。
  299. // 密码错、权限不足这类原文已经够清楚, 原样带上; 麻烦的是连不上 ——
  300. // 那是一句英文 TCP 报错, 看着不知道从哪儿下手。
  301. func friendlyDBErr(err error, host, port string) string {
  302. msg := err.Error()
  303. switch {
  304. case strings.Contains(msg, "refused"), strings.Contains(msg, "connectex"),
  305. strings.Contains(msg, "no such host"), strings.Contains(msg, "i/o timeout"),
  306. strings.Contains(msg, "deadline exceeded"):
  307. return fmt.Sprintf("连不上 %s:%s —— 检查 MySQL 是否在运行、主机名和端口是否写对 (%v)",
  308. host, port, err)
  309. case strings.Contains(msg, "Access denied"):
  310. return "账号或密码不对:" + msg
  311. }
  312. return msg
  313. }
  314. // handleList 只读源库结构, 不碰目标库 —— 对应 CLI 的 -list。
  315. func handleList(w http.ResponseWriter, r *http.Request) {
  316. var req listReq
  317. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  318. writeJSON(w, listResp{Error: "请求体解析失败: " + err.Error()})
  319. return
  320. }
  321. if req.DSN == "" {
  322. writeJSON(w, listResp{Error: "请先填写源库 DSN"})
  323. return
  324. }
  325. ep, err := migrate.ParseDSN(req.DSN, migrate.DefaultOptions())
  326. if err != nil {
  327. writeJSON(w, listResp{Error: err.Error()})
  328. return
  329. }
  330. defer ep.Close()
  331. resp := listResp{Kind: string(ep.Kind), Identity: ep.Identity()}
  332. names, err := ep.Src.Tables()
  333. if err != nil {
  334. resp.Error = "列出表失败: " + err.Error()
  335. writeJSON(w, resp)
  336. return
  337. }
  338. want := map[string]bool{}
  339. for _, t := range req.Tables {
  340. want[t] = true
  341. }
  342. for _, name := range names {
  343. if name == migrate.MetaTable {
  344. continue // 保真表是迁移的随身行李, 不当作业务表展示
  345. }
  346. if len(want) > 0 && !want[name] {
  347. continue
  348. }
  349. sc, err := ep.Src.Schema(name)
  350. if err != nil {
  351. resp.Tables = append(resp.Tables, tableInfo{
  352. Name: name,
  353. PKIndexType: "读结构失败: " + err.Error(),
  354. })
  355. continue
  356. }
  357. resp.Tables = append(resp.Tables, toTableInfo(sc))
  358. }
  359. writeJSON(w, resp)
  360. }
  361. func toTableInfo(sc *migrate.TableSchema) tableInfo {
  362. ti := tableInfo{
  363. Name: sc.Name,
  364. PKColumn: sc.PKColumn,
  365. PKIndexType: migrate.IndexTypeName(sc.PKIndexType),
  366. RowStore: sc.RowStore(),
  367. Rows: sc.Rows,
  368. Columns: make([]colInfo, 0, len(sc.Columns)),
  369. }
  370. for _, c := range sc.Columns {
  371. ti.Columns = append(ti.Columns, colInfo{
  372. Name: c.Name,
  373. Type: migrate.TypeName(c.Type),
  374. Size: c.Size(),
  375. Dynamic: c.IsDynamic(),
  376. IsPK: c.Name == sc.PKColumn,
  377. Synthetic: sc.IsSynthetic(c.Name),
  378. })
  379. }
  380. for _, idx := range sc.Indexes {
  381. ti.Indexes = append(ti.Indexes, idxInfo{
  382. Name: idx.Name,
  383. Type: migrate.IndexTypeName(idx.Type),
  384. Columns: idx.Columns,
  385. RowStore: idx.IsRowStore(),
  386. })
  387. }
  388. return ti
  389. }
  390. // ============================ /api/migrate ============================
  391. type migrateReq struct {
  392. From string `json:"from"`
  393. To string `json:"to"`
  394. Tables []string `json:"tables"`
  395. Recreate bool `json:"recreate"`
  396. Batch int `json:"batch"`
  397. Limit uint64 `json:"limit"`
  398. DryRun bool `json:"dryRun"`
  399. NoMeta bool `json:"noMeta"`
  400. AutoPK bool `json:"autoPk"`
  401. Loose bool `json:"loose"`
  402. // TruncOversize 值超过引擎单值上限 (16360 字节) 时截断并逐行报出 ——
  403. // 与 CLI 的 -truncate-oversize 同一个开关, 明确的数据丢失, 默认关。
  404. TruncOversize bool `json:"truncateOversize"`
  405. // KeepGoing 单张表失败时跳过它继续迁其余表 (前端默认勾上)。
  406. KeepGoing bool `json:"keepGoing"`
  407. // EachRow 每读一行数据就推一条事件, 界面能看着数据一行行滚过去。
  408. // 开销随行数线性上涨 (百万行 = 百万条事件), 默认关。
  409. EachRow bool `json:"eachRow"`
  410. }
  411. // streamEvent 迁移过程以 NDJSON 一行一条推送 (前端按行解析):
  412. //
  413. // {"type":"log", "at":"15:04:05","msg":"...","level":"stage"}
  414. // {"type":"progress", "table":"orders","rows":12000,"elapsed":"1.2s"}
  415. // {"type":"row", "table":"orders","rows":12001,"msg":"id=1, name=..."}
  416. // {"type":"done", "report":{...}}
  417. // {"type":"error", "msg":"..."}
  418. type streamEvent struct {
  419. Type string `json:"type"`
  420. Msg string `json:"msg,omitempty"`
  421. At string `json:"at,omitempty"`
  422. Level string `json:"level,omitempty"` // 空=普通; "stage"=阶段分隔行
  423. Table string `json:"table,omitempty"`
  424. Rows uint64 `json:"rows,omitempty"`
  425. Elapsed string `json:"elapsed,omitempty"`
  426. Report *reportInfo `json:"report,omitempty"`
  427. }
  428. type reportInfo struct {
  429. From string `json:"from"`
  430. To string `json:"to"`
  431. Rows uint64 `json:"rows"`
  432. Elapsed string `json:"elapsed"`
  433. Tables []tableReport `json:"tables"`
  434. }
  435. type tableReport struct {
  436. Table string `json:"table"`
  437. Rows uint64 `json:"rows"`
  438. Columns int `json:"columns"`
  439. Indexes int `json:"indexes"`
  440. Elapsed string `json:"elapsed"`
  441. Skipped bool `json:"skipped"`
  442. Note string `json:"note"`
  443. Error string `json:"error"`
  444. }
  445. func handleMigrate(w http.ResponseWriter, r *http.Request) {
  446. var req migrateReq
  447. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  448. http.Error(w, "请求体解析失败: "+err.Error(), http.StatusBadRequest)
  449. return
  450. }
  451. flusher, _ := w.(http.Flusher)
  452. w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
  453. w.Header().Set("Cache-Control", "no-cache")
  454. w.Header().Set("X-Accel-Buffering", "no") // 走反代时也别缓冲
  455. w.WriteHeader(http.StatusOK)
  456. enc := json.NewEncoder(w)
  457. emit := func(ev streamEvent) {
  458. _ = enc.Encode(ev)
  459. if flusher != nil {
  460. flusher.Flush()
  461. }
  462. }
  463. now := func() string { return time.Now().Format("15:04:05") }
  464. if req.From == "" || req.To == "" {
  465. emit(streamEvent{Type: "error", Msg: "请同时填写源库与目标库 DSN"})
  466. return
  467. }
  468. if req.From == req.To {
  469. emit(streamEvent{Type: "error", Msg: "源库与目标库不能是同一个"})
  470. return
  471. }
  472. opts := migrate.DefaultOptions()
  473. opts.Create = true
  474. opts.Recreate = req.Recreate
  475. opts.EmbedMeta = !req.NoMeta
  476. opts.StrictValue = !req.Loose
  477. opts.TruncateOversize = req.TruncOversize
  478. opts.AutoPK = req.AutoPK
  479. opts.ContinueOnError = req.KeepGoing
  480. opts.DryRun = req.DryRun
  481. opts.Limit = req.Limit
  482. opts.Tables = req.Tables
  483. if req.Batch > 0 {
  484. opts.Batch = req.Batch
  485. }
  486. // 把库的逐表进度接到 HTTP 流上: Run 内部每完成一张表就回调这里。
  487. opts.Verbose = true
  488. opts.Log = func(format string, args ...any) {
  489. emit(streamEvent{Type: "log", At: now(), Msg: fmt.Sprintf(format, args...)})
  490. }
  491. // 大表"已写 N 行"走结构化事件: 前端原地刷一行, 不往日志里灌流水
  492. // (灌进去会把日志顶到看不见, 滚动条也就一直在跳)。
  493. opts.Progress = func(table string, rows uint64, elapsed time.Duration) {
  494. emit(streamEvent{Type: "progress", Table: table, Rows: rows,
  495. Elapsed: elapsed.Round(time.Millisecond).String()})
  496. }
  497. // 逐行事件 (可选): 让人眼看着数据一行行滚过去。日志区有行数上限, 不会无限长。
  498. if req.EachRow {
  499. opts.EachRow = func(table string, n uint64, row migrate.Row) {
  500. emit(streamEvent{Type: "row", Table: table, Rows: n, Msg: formatRowValues(row)})
  501. }
  502. }
  503. src, err := migrate.ParseDSN(req.From, opts)
  504. if err != nil {
  505. emit(streamEvent{Type: "error", Msg: "打开源库: " + err.Error()})
  506. return
  507. }
  508. defer src.Close()
  509. dst, err := migrate.ParseDSN(req.To, opts)
  510. if err != nil {
  511. emit(streamEvent{Type: "error", Msg: "打开目标库: " + err.Error()})
  512. return
  513. }
  514. defer dst.Close()
  515. emit(streamEvent{Type: "log", At: now(),
  516. Msg: fmt.Sprintf("开始: %s → %s", src.Identity(), dst.Identity())})
  517. // 两阶段: 先把所有表的结构立起来, 再灌数据。结构阶段是秒级的 —— 点完按钮
  518. // 很快就能看到 52 张表全部就位, 不用盯着一张表猜是卡住还是死锁。
  519. var rep1 *migrate.Report
  520. if !req.DryRun {
  521. schemaOpts := *opts
  522. schemaOpts.SchemaOnly = true
  523. // 结构阶段尽量往前推: 一张表建不出来不该挡住后面几十张, 明细留给数据阶段报。
  524. schemaOpts.ContinueOnError = true
  525. emit(streamEvent{Type: "log", At: now(), Level: "stage",
  526. Msg: "阶段 1/2: 同步表结构 (建表 + 建索引, 不搬数据)"})
  527. rep1, _ = migrate.Run(src.Src, dst.Dst, &schemaOpts)
  528. if rep1 != nil {
  529. ok, bad := 0, 0
  530. for _, tr := range rep1.Tables {
  531. if tr.Error != "" {
  532. bad++
  533. } else {
  534. ok++
  535. }
  536. }
  537. msg := fmt.Sprintf("阶段 1/2 完成: %d 张表结构就绪", ok)
  538. if bad > 0 {
  539. msg += fmt.Sprintf(", %d 张建表失败 (具体错误在数据阶段报)", bad)
  540. }
  541. emit(streamEvent{Type: "log", At: now(), Msg: msg})
  542. }
  543. }
  544. dataOpts := *opts
  545. // 表已经在阶段 1 建好了, 这里再 Recreate 一次纯属白干 (还会把刚建的表删掉)。
  546. dataOpts.Recreate = false
  547. if !req.DryRun {
  548. emit(streamEvent{Type: "log", At: now(), Level: "stage", Msg: "阶段 2/2: 迁移数据"})
  549. }
  550. rep, runErr := migrate.Run(src.Src, dst.Dst, &dataOpts)
  551. if rep != nil {
  552. mergeSchemaNotes(rep, rep1)
  553. emit(streamEvent{Type: "done", Report: toReportInfo(rep)})
  554. }
  555. if runErr != nil {
  556. emit(streamEvent{Type: "error", Msg: runErr.Error()})
  557. }
  558. }
  559. // mergeSchemaNotes 把阶段 1 才知道的事补进数据阶段的报告。
  560. //
  561. // 为什么需要: 数据阶段面对的已经是建好的表, CreateTable 直接返回, 于是"哪个索引
  562. // 被跳过"这类只发生在建表时刻的信息在阶段 2 的报告里是空的 —— 不补上, 用户看
  563. // 报告会以为索引都建全了。
  564. func mergeSchemaNotes(rep, schemaRep *migrate.Report) {
  565. if rep == nil || schemaRep == nil {
  566. return
  567. }
  568. notes := make(map[string]string, len(schemaRep.Tables))
  569. for _, tr := range schemaRep.Tables {
  570. n := strings.TrimSuffix(strings.TrimSuffix(tr.Note, "仅结构"), "; ")
  571. if n != "" {
  572. notes[tr.Table] = n
  573. }
  574. }
  575. for i := range rep.Tables {
  576. n, ok := notes[rep.Tables[i].Table]
  577. if !ok {
  578. continue
  579. }
  580. if rep.Tables[i].Note == "" {
  581. rep.Tables[i].Note = n
  582. } else {
  583. rep.Tables[i].Note += "; " + n
  584. }
  585. }
  586. }
  587. // formatRowValues 把一行数据拼成 "值, 值, ..." 供逐行滚动显示。
  588. // 截断长值: 这些行是给人看"数据在动"的, 不是给人读全文的。
  589. func formatRowValues(row migrate.Row) string {
  590. var b strings.Builder
  591. for i, v := range row.Values {
  592. if i > 0 {
  593. b.WriteString(", ")
  594. }
  595. s := fmt.Sprintf("%v", v)
  596. if r := []rune(s); len(r) > 32 {
  597. s = string(r[:32]) + "…"
  598. }
  599. b.WriteString(s)
  600. }
  601. s := b.String()
  602. if r := []rune(s); len(r) > 220 {
  603. s = string(r[:220]) + "…"
  604. }
  605. return s
  606. }
  607. func toReportInfo(rep *migrate.Report) *reportInfo {
  608. ri := &reportInfo{
  609. From: string(rep.From),
  610. To: string(rep.To),
  611. Rows: rep.Rows,
  612. Elapsed: rep.Elapsed.Round(time.Millisecond).String(),
  613. Tables: make([]tableReport, 0, len(rep.Tables)),
  614. }
  615. for _, tr := range rep.Tables {
  616. ri.Tables = append(ri.Tables, tableReport{
  617. Table: tr.Table,
  618. Rows: tr.Rows,
  619. Columns: tr.Columns,
  620. Indexes: tr.Indexes,
  621. Elapsed: tr.Elapsed.Round(time.Millisecond).String(),
  622. Skipped: tr.Skipped,
  623. Note: tr.Note,
  624. Error: tr.Error,
  625. })
  626. }
  627. return ri
  628. }
  629. func writeJSON(w http.ResponseWriter, v any) {
  630. w.Header().Set("Content-Type", "application/json; charset=utf-8")
  631. _ = json.NewEncoder(w).Encode(v)
  632. }