schema_align.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. package fixture
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "io"
  7. "os"
  8. "path/filepath"
  9. "regexp"
  10. "sort"
  11. "strings"
  12. )
  13. // 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的时间列默认值对齐到 DDL 文件的口径。
  14. //
  15. // 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
  16. // GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这些列 **NOT NULL 且无默认值**,
  17. // 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。
  18. //
  19. // ⚠ 三类时间列的语义**完全不同**,不能一视同仁:
  20. // - `created_at`:**审计**——这行什么时候入库的。取当前时间合理。
  21. // - `create_time`:**业务**——这笔钱/这节课什么时候发生的。取当前时间 = **静默写错**。
  22. // - `update_time`:**业务**——最后修改时间(仅 `t_courses`,DDL 本身就是 DEFAULT NULL)。
  23. // 补默认只解决"插入漏传则报错",**不解决"取到当前时间就错了"**,所以必须配 create_time 断言
  24. // (见 run_marker.go 的 countRowsAfter)。
  25. //
  26. // 纪律(不可妥协):
  27. // 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移);
  28. // 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径
  29. // —— `FixNullable` 打开时才额外按 DDL 改 NULL 性;
  30. // 3. NULL 性、类型、注释等差异**默认只报告不执行**,必须显式开 `FixNullable` 才动;
  31. // 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY)。
  32. //
  33. // 关于 ALGORITHM:默认在语句末尾带 `, ALGORITHM=INSTANT`(显式声明"只改元数据")。
  34. // 但 **MySQL 8.0.23 不支持对 "MODIFY 改默认值" 用 INSTANT/INPLACE**(实测 Error 1845),
  35. // 要 8.0.29+ 才支持;在旧版本上必须显式降级为 `Algorithm=none` 才能执行(见报告)。
  36. // defaultModelDir 是 DDL 目录。
  37. const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model`
  38. // ColumnsAudit 是审计类时间列(取默认值合理)。
  39. var ColumnsAudit = []string{"created_at", "updated_at"}
  40. // ColumnsBusiness 是业务类时间列(取默认值 = 静默写错,必须配断言)。
  41. var ColumnsBusiness = []string{"create_time"}
  42. // AlignOptions 是对齐参数。
  43. type AlignOptions struct {
  44. ModelDir string // DDL 目录
  45. Columns []string // 参与对齐的列名(默认 created_at/updated_at)
  46. Algorithm string // instant / inplace / copy / none
  47. FixNullable bool // 是否按 DDL 修正 NULL 性(默认只报告)
  48. OnlyTables []string // 只处理这些表(空=全部)
  49. }
  50. // ddlColumn 是从 DDL 文件里解析出来的一列。
  51. type ddlColumn struct {
  52. Table string
  53. Column string
  54. RawDef string
  55. Type string
  56. NotNull bool
  57. HasDefault bool
  58. OnUpdate bool
  59. Comment string
  60. }
  61. // liveColumn 是实库的一列(取自 information_schema)。
  62. type liveColumn struct {
  63. Table string
  64. Column string
  65. ColumnType string
  66. Nullable bool
  67. Default sql.NullString
  68. Extra string
  69. Comment string
  70. }
  71. // AlignAction 是一张表一列的对齐判定。
  72. type AlignAction struct {
  73. Table string
  74. Column string
  75. Kind string // ok / alignable / alignable-nullable / drift-nullable / ddl-no-default / ddl-missing
  76. LiveDesc string
  77. DDLDefault string
  78. SQL string
  79. Note string
  80. }
  81. // AlignReport 是整轮比对结果。
  82. type AlignReport struct {
  83. ModelDir string
  84. Options AlignOptions
  85. Actions []AlignAction
  86. DryRunOK map[int]bool
  87. Applied int
  88. Failed int
  89. Errors []string
  90. }
  91. // Alignable 返回需要执行 ALTER 的条目下标。
  92. func (r *AlignReport) Alignable() []int {
  93. var out []int
  94. for i, a := range r.Actions {
  95. if a.Kind == "alignable" || a.Kind == "alignable-nullable" {
  96. out = append(out, i)
  97. }
  98. }
  99. return out
  100. }
  101. // Problems 返回「只报告不执行」的条目。
  102. func (r *AlignReport) Problems() []AlignAction {
  103. var out []AlignAction
  104. for _, a := range r.Actions {
  105. if strings.HasPrefix(a.Kind, "drift") || a.Kind == "ddl-missing" {
  106. out = append(out, a)
  107. }
  108. }
  109. return out
  110. }
  111. var (
  112. createTableLine = regexp.MustCompile("^\\s*CREATE\\s+TABLE\\s+`([A-Za-z0-9_]+)`")
  113. columnLine = regexp.MustCompile("^\\s*`([A-Za-z0-9_]+)`\\s+(.+?),?\\s*$")
  114. defaultExpr = regexp.MustCompile(`(?i)\bDEFAULT\s+(CURRENT_TIMESTAMP(?:\(\d*\))?|'[^']*'|[^\s]+)`)
  115. commentExpr = regexp.MustCompile(`(?i)\bCOMMENT\s+'((?:[^']|'')*)'`)
  116. firstToken = regexp.MustCompile(`^[A-Za-z]+(?:\([0-9,]+\))?`)
  117. )
  118. // ParseDDLFile 解析一个 DDL 文件,返回指定列的列定义。
  119. func ParseDDLFile(path string, want []string) ([]ddlColumn, error) {
  120. raw, err := os.ReadFile(path)
  121. if err != nil {
  122. return nil, fmt.Errorf("读 DDL 失败(%s):%w", path, err)
  123. }
  124. var out []ddlColumn
  125. curTable := ""
  126. for _, line := range strings.Split(string(raw), "\n") {
  127. trimmed := strings.TrimSpace(line)
  128. if trimmed == "" || strings.HasPrefix(trimmed, "--") {
  129. continue
  130. }
  131. if m := createTableLine.FindStringSubmatch(trimmed); m != nil {
  132. curTable = m[1]
  133. continue
  134. }
  135. if curTable == "" {
  136. continue
  137. }
  138. if strings.HasPrefix(trimmed, ")") {
  139. curTable = ""
  140. continue
  141. }
  142. m := columnLine.FindStringSubmatch(line)
  143. if m == nil {
  144. continue
  145. }
  146. col := m[1]
  147. if !containsString(want, col) {
  148. continue
  149. }
  150. def := strings.TrimSuffix(strings.TrimSpace(m[2]), ",")
  151. c := ddlColumn{Table: curTable, Column: col, RawDef: def}
  152. if t := firstToken.FindString(def); t != "" {
  153. c.Type = t
  154. }
  155. c.NotNull = strings.Contains(strings.ToUpper(def), "NOT NULL")
  156. if dm := defaultExpr.FindStringSubmatch(def); dm != nil {
  157. c.HasDefault = true
  158. }
  159. c.OnUpdate = strings.Contains(strings.ToUpper(def), "ON UPDATE CURRENT_TIMESTAMP")
  160. if cm := commentExpr.FindStringSubmatch(def); cm != nil {
  161. c.Comment = strings.ReplaceAll(cm[1], "''", "'")
  162. }
  163. out = append(out, c)
  164. }
  165. return out, nil
  166. }
  167. func containsString(ss []string, s string) bool {
  168. for _, v := range ss {
  169. if v == s {
  170. return true
  171. }
  172. }
  173. return false
  174. }
  175. // loadLiveColumns 从 information_schema 读实库指定列的属性。
  176. func loadLiveColumns(ctx context.Context, db *sql.DB, schema string, columns []string) (map[string]liveColumn, error) {
  177. // 关掉 information_schema 统计缓存,拿即时值(同 batch2 的 AUTO_INCREMENT 坑)。
  178. if _, err := db.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
  179. return nil, err
  180. }
  181. ph := make([]string, len(columns))
  182. args := []any{schema}
  183. for i, c := range columns {
  184. ph[i] = "?"
  185. args = append(args, c)
  186. }
  187. q := fmt.Sprintf(`
  188. SELECT table_name, column_name, column_type, is_nullable, column_default, extra, column_comment
  189. FROM information_schema.columns
  190. WHERE table_schema = ? AND column_name IN (%s)`, strings.Join(ph, ","))
  191. rows, err := db.QueryContext(ctx, q, args...)
  192. if err != nil {
  193. return nil, err
  194. }
  195. defer rows.Close()
  196. out := map[string]liveColumn{}
  197. for rows.Next() {
  198. var c liveColumn
  199. var nullable string
  200. if err := rows.Scan(&c.Table, &c.Column, &c.ColumnType, &nullable, &c.Default, &c.Extra, &c.Comment); err != nil {
  201. return nil, err
  202. }
  203. c.Nullable = strings.EqualFold(nullable, "YES")
  204. out[c.Table+"."+c.Column] = c
  205. }
  206. return out, rows.Err()
  207. }
  208. // algorithmSuffix 生成 ALGORITHM 子句(none 时返回空串)。
  209. func algorithmSuffix(algorithm string) string {
  210. switch strings.ToLower(strings.TrimSpace(algorithm)) {
  211. case "instant":
  212. return ", ALGORITHM=INSTANT"
  213. case "inplace":
  214. return ", ALGORITHM=INPLACE, LOCK=NONE"
  215. case "copy":
  216. return ", ALGORITHM=COPY"
  217. default: // "", "none"
  218. return ""
  219. }
  220. }
  221. // buildModifySQL 用**实库现有属性** + DDL 的 DEFAULT 拼出 MODIFY 语句。
  222. func buildModifySQL(live liveColumn, ddl ddlColumn, fixNullable bool, algorithm string) string {
  223. var b strings.Builder
  224. b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType))
  225. notNull := !live.Nullable
  226. if fixNullable && ddl.NotNull {
  227. notNull = true
  228. }
  229. if notNull {
  230. b.WriteString(" NOT NULL")
  231. }
  232. b.WriteString(" DEFAULT CURRENT_TIMESTAMP(3)")
  233. if ddl.OnUpdate {
  234. b.WriteString(" ON UPDATE CURRENT_TIMESTAMP(3)")
  235. }
  236. if live.Comment != "" {
  237. b.WriteString(" COMMENT '" + strings.ReplaceAll(live.Comment, "'", "''") + "'")
  238. }
  239. b.WriteString(algorithmSuffix(algorithm))
  240. return b.String()
  241. }
  242. func describeLive(l liveColumn) string {
  243. def := "NULL(无默认值)"
  244. if l.Default.Valid {
  245. def = l.Default.String
  246. }
  247. null := "NOT NULL"
  248. if l.Nullable {
  249. null = "NULL"
  250. }
  251. extra := ""
  252. if l.Extra != "" {
  253. extra = fmt.Sprintf(" extra=%q", l.Extra)
  254. }
  255. return fmt.Sprintf("%s %s DEFAULT=%s%s", l.ColumnType, null, def, extra)
  256. }
  257. // BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。
  258. func BuildAlignReport(ctx context.Context, cfg Config, opts AlignOptions) (*AlignReport, error) {
  259. if opts.ModelDir == "" {
  260. opts.ModelDir = os.Getenv("AC15_MODEL_DIR")
  261. }
  262. if opts.ModelDir == "" {
  263. opts.ModelDir = defaultModelDir
  264. }
  265. if len(opts.Columns) == 0 {
  266. opts.Columns = ColumnsAudit
  267. }
  268. files := []string{
  269. filepath.Join(opts.ModelDir, "core-model-ddl.sql"),
  270. filepath.Join(opts.ModelDir, "academic-model-ddl.sql"),
  271. }
  272. var specs []ddlColumn
  273. for _, f := range files {
  274. cols, err := ParseDDLFile(f, opts.Columns)
  275. if err != nil {
  276. return nil, err
  277. }
  278. specs = append(specs, cols...)
  279. }
  280. if len(specs) == 0 {
  281. return nil, fmt.Errorf("DDL 里没有解析到 %v 的定义(modelDir=%s)", opts.Columns, opts.ModelDir)
  282. }
  283. db, err := cfg.OpenTarget()
  284. if err != nil {
  285. return nil, err
  286. }
  287. defer db.Close()
  288. live, err := loadLiveColumns(ctx, db, cfg.TargetDB, opts.Columns)
  289. if err != nil {
  290. return nil, err
  291. }
  292. rep := &AlignReport{ModelDir: opts.ModelDir, Options: opts, DryRunOK: map[int]bool{}}
  293. for _, sp := range specs {
  294. if len(opts.OnlyTables) > 0 && !containsString(opts.OnlyTables, sp.Table) {
  295. continue
  296. }
  297. key := sp.Table + "." + sp.Column
  298. lv, ok := live[key]
  299. if !ok {
  300. rep.Actions = append(rep.Actions, AlignAction{
  301. Table: sp.Table, Column: sp.Column, Kind: "ddl-missing",
  302. LiveDesc: "<实库无此列>", Note: "DDL 有、实库没有 —— 属缺列,本工具不处理",
  303. })
  304. continue
  305. }
  306. a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv),
  307. DDLDefault: "CURRENT_TIMESTAMP(3)"}
  308. ddlNull := "NOT NULL"
  309. if !sp.NotNull {
  310. ddlNull = "NULL"
  311. }
  312. switch {
  313. case lv.Nullable != !sp.NotNull:
  314. if opts.FixNullable && sp.NotNull && sp.HasDefault {
  315. a.Kind = "alignable-nullable"
  316. a.SQL = buildModifySQL(lv, sp, true, opts.Algorithm)
  317. a.Note = fmt.Sprintf("NULL 性不一致(实库可空、DDL %s)→ 按 DDL 一并修正 + 补 DEFAULT", ddlNull)
  318. } else {
  319. a.Kind = "drift-nullable"
  320. a.Note = fmt.Sprintf("NULL 性不一致:实库可空,DDL %s —— 未开 FixNullable,只报告不改", ddlNull)
  321. }
  322. case !sp.HasDefault:
  323. a.Kind = "ddl-no-default"
  324. a.Note = "DDL 该列本身没有 DEFAULT CURRENT_TIMESTAMP(3),无需对齐"
  325. case lv.Default.Valid:
  326. a.Kind = "ok"
  327. a.Note = "已有默认值(已对齐)"
  328. default:
  329. a.Kind = "alignable"
  330. a.SQL = buildModifySQL(lv, sp, false, opts.Algorithm)
  331. a.Note = "实库 DEFAULT 为 NULL → 补 DEFAULT CURRENT_TIMESTAMP(3)" +
  332. map[bool]string{true: ",并补 ON UPDATE CURRENT_TIMESTAMP(3)", false: ""}[sp.OnUpdate]
  333. }
  334. rep.Actions = append(rep.Actions, a)
  335. }
  336. sort.Slice(rep.Actions, func(i, j int) bool {
  337. if rep.Actions[i].Table != rep.Actions[j].Table {
  338. return rep.Actions[i].Table < rep.Actions[j].Table
  339. }
  340. return rep.Actions[i].Column < rep.Actions[j].Column
  341. })
  342. return rep, nil
  343. }
  344. // DryRunAlign 在**会话临时表**上验证每条 ALTER 是否合法且达到预期效果。
  345. func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
  346. db, err := cfg.OpenTarget()
  347. if err != nil {
  348. return err
  349. }
  350. defer db.Close()
  351. conn, err := db.Conn(ctx)
  352. if err != nil {
  353. return err
  354. }
  355. defer conn.Close()
  356. for i := range rep.Actions {
  357. a := &rep.Actions[i]
  358. if a.Kind != "alignable" && a.Kind != "alignable-nullable" {
  359. continue
  360. }
  361. tmp := "tmp_align_" + a.Table
  362. if _, err := conn.ExecContext(ctx,
  363. fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE `%s`", tmp, a.Table)); err != nil {
  364. return fmt.Errorf("dry-run 建临时表失败(%s):%w", a.Table, err)
  365. }
  366. stmt := strings.Replace(a.SQL, "ALTER TABLE `"+a.Table+"`", "ALTER TABLE `"+tmp+"`", 1)
  367. if _, err := conn.ExecContext(ctx, stmt); err != nil {
  368. rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 失败 %s.%s:%v", a.Table, a.Column, err))
  369. rep.DryRunOK[i] = false
  370. continue
  371. }
  372. var def sql.NullString
  373. var extra string
  374. if err := conn.QueryRowContext(ctx, `
  375. SELECT column_default, extra FROM information_schema.columns
  376. WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, tmp, a.Column).Scan(&def, &extra); err != nil {
  377. rows, err2 := conn.QueryContext(ctx, fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE '%s'", tmp, a.Column))
  378. if err2 != nil {
  379. rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 复查失败 %s.%s:%v", a.Table, a.Column, err2))
  380. rep.DryRunOK[i] = false
  381. continue
  382. }
  383. for rows.Next() {
  384. var field, typ, null, key, d, ex sql.NullString
  385. if err := rows.Scan(&field, &typ, &null, &key, &d, &ex); err != nil {
  386. rows.Close()
  387. return err
  388. }
  389. def, extra = d, ex.String
  390. }
  391. rows.Close()
  392. }
  393. rep.DryRunOK[i] = def.Valid && strings.Contains(strings.ToLower(def.String), "current_timestamp")
  394. if !rep.DryRunOK[i] {
  395. rep.Errors = append(rep.Errors, fmt.Sprintf(
  396. "dry-run 复查不符 %s.%s:default=%v extra=%q(期望 CURRENT_TIMESTAMP)", a.Table, a.Column, def, extra))
  397. }
  398. }
  399. return nil
  400. }
  401. // ApplyAlign 执行对齐(只在 dry-run 全部通过时执行)。
  402. func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
  403. for i := range rep.Actions {
  404. k := rep.Actions[i].Kind
  405. if (k == "alignable" || k == "alignable-nullable") && !rep.DryRunOK[i] {
  406. return fmt.Errorf("存在未通过 dry-run 的语句,拒绝执行(%s.%s)",
  407. rep.Actions[i].Table, rep.Actions[i].Column)
  408. }
  409. }
  410. db, err := cfg.OpenTarget()
  411. if err != nil {
  412. return err
  413. }
  414. defer db.Close()
  415. for i := range rep.Actions {
  416. a := &rep.Actions[i]
  417. if a.Kind != "alignable" && a.Kind != "alignable-nullable" {
  418. continue
  419. }
  420. if _, err := db.ExecContext(ctx, a.SQL); err != nil {
  421. rep.Failed++
  422. rep.Errors = append(rep.Errors, fmt.Sprintf("执行失败 %s.%s:%v", a.Table, a.Column, err))
  423. continue
  424. }
  425. rep.Applied++
  426. }
  427. return nil
  428. }
  429. // VerifyAlign 复核:指定列的 COLUMN_DEFAULT 必须全部非 NULL。
  430. func VerifyAlign(ctx context.Context, cfg Config, columns []string) (total int, missing []string, err error) {
  431. if len(columns) == 0 {
  432. columns = ColumnsAudit
  433. }
  434. db, err := cfg.OpenTarget()
  435. if err != nil {
  436. return 0, nil, err
  437. }
  438. defer db.Close()
  439. live, err := loadLiveColumns(ctx, db, cfg.TargetDB, columns)
  440. if err != nil {
  441. return 0, nil, err
  442. }
  443. for key, c := range live {
  444. total++
  445. if !c.Default.Valid {
  446. missing = append(missing, fmt.Sprintf("%s(%s)", key, c.ColumnType))
  447. }
  448. }
  449. sort.Strings(missing)
  450. return total, missing, nil
  451. }
  452. // PrintAlignReport 打印对齐清单。
  453. func PrintAlignReport(w io.Writer, r *AlignReport) {
  454. fmt.Fprintf(w, "== 结构对齐清单(基准:DDL 文件)==\n")
  455. fmt.Fprintf(w, "DDL 目录:%s\n", r.ModelDir)
  456. algo := r.Options.Algorithm
  457. if algo == "" {
  458. algo = "none"
  459. }
  460. fmt.Fprintf(w, "列范围:%v 算法子句:%s FixNullable=%v\n\n", r.Options.Columns, algo, r.Options.FixNullable)
  461. alignable := r.Alignable()
  462. fmt.Fprintf(w, "【待执行】%d 条\n", len(alignable))
  463. for _, i := range alignable {
  464. a := r.Actions[i]
  465. dry := "未验证"
  466. if ok, has := r.DryRunOK[i]; has {
  467. if ok {
  468. dry = "dry-run ✔"
  469. } else {
  470. dry = "dry-run ✘"
  471. }
  472. }
  473. fmt.Fprintf(w, " %-32s [%s] %s\n %s\n", a.Table+"."+a.Column, a.Kind, dry, a.SQL)
  474. }
  475. okCount := 0
  476. for _, a := range r.Actions {
  477. if a.Kind == "ok" {
  478. okCount++
  479. }
  480. }
  481. fmt.Fprintf(w, "\n【已对齐】%d 条\n", okCount)
  482. for _, a := range r.Actions {
  483. if a.Kind == "ok" {
  484. fmt.Fprintf(w, " %-32s %s\n", a.Table+"."+a.Column, a.LiveDesc)
  485. }
  486. }
  487. probs := r.Problems()
  488. fmt.Fprintf(w, "\n【只报告、不执行】%d 条\n", len(probs))
  489. for _, a := range probs {
  490. fmt.Fprintf(w, " %-32s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc)
  491. }
  492. skipped := 0
  493. for _, a := range r.Actions {
  494. if a.Kind == "ddl-no-default" {
  495. skipped++
  496. }
  497. }
  498. if skipped > 0 {
  499. fmt.Fprintf(w, "\n【DDL 无默认值、无需动】%d 条\n", skipped)
  500. }
  501. if len(r.Errors) > 0 {
  502. fmt.Fprintf(w, "\n【错误/告警】%d 条\n", len(r.Errors))
  503. for _, e := range r.Errors {
  504. fmt.Fprintf(w, " - %s\n", e)
  505. }
  506. }
  507. if r.Applied > 0 || r.Failed > 0 {
  508. fmt.Fprintf(w, "\n执行结果:成功 %d 条,失败 %d 条\n", r.Applied, r.Failed)
  509. }
  510. }