ソースを参照

feat(ac15): 把「读旧库定义」固化成常备工具 cmd/spdump(并修掉它自己的一个静默错误)

三个原因让它必须存在,而不是一次性探针:

1. `sp_user_expense_baddebt` 的 COMMENT 写着 '用户余额充值'(从 recharge 复制忘改)
2. `sp_user_expense_school_spend` 的 INSERT 分支是 `INSERT INTO t_user_expense(userid,spend)
   VALUES(inputUserid, inputMoney, inputSpendMoney)` —— 2 列配 3 值,且 inputMoney 在该 SP 里不存在
3. `sp_user_expense_school_transfer_expenditure` 没有默认备注(SP1/2/3/4 都有)

⇒ 「照抄 SP」不可行:旧库里混着错注释和坏 SQL,照抄会把缺陷一起搬过来。
   行为只能从语句里读,这个工具就是"读语句"的入口,让下一个人也能查。

**证据强度排序**(已写进 README):
   SQL 语句 > 参数签名 > COMMENT > 表名/函数名
   前三个里只有 COMMENT 会骗人;表名和 COMMENT 都会。

子命令:proc / routines / view / views / tables / columns / sql
(`sql` 只放行 SELECT/SHOW/DESC/EXPLAIN/WITH 的单条语句;连接建立即 SET SESSION TRANSACTION READ ONLY)

安全:库名走 fixture.allowedDatabases 白名单(新增 OpenReadOnlyDB),只连本机,
口令仍只从 AC15_* 或 config/dev.json 读,不打印。

顺带修掉工具自己的一个静默错误:`spdump columns t_campus -db yuansiwei_2026` 这种
"先对象、后库名"的写法,Go 的 flag 包遇到第一个非 flag 参数就停止解析 → `-db` 被静默丢弃
→ 连到默认库 → 查不到列 → 打印 "(0 行)"。`(0 行)` 看起来像"这表没有列",
是个**错误答案**而不是一个错误,比报错危险。改为 splitFlagsAndArgs 重排 + 单测锁住。
Q 7 時間 前
親
コミット
695308f72c

+ 51 - 0
cmd/spdump/README.md

@@ -0,0 +1,51 @@
+# `cmd/spdump` —— 读旧库定义的常备工具
+
+```bash
+go run ./cmd/spdump proc sp_user_expense_baddebt          # SHOW CREATE PROCEDURE
+go run ./cmd/spdump routines -like "%expense%"            # 有哪些 routine(先看清单)
+go run ./cmd/spdump view yuansiwei.t_school_campus        # 跨库视图怎么定义的
+go run ./cmd/spdump tables -db finance_bk -like "%activity%"
+go run ./cmd/spdump columns t_campus -db yuansiwei_2026    # 列类型/默认值/注释
+go run ./cmd/spdump sql -db legacy_oracle "select max(id) from t_courses_activity"
+```
+
+`-db` 默认 `legacy_oracle`;flags 与位置参数**任意顺序**都行。
+
+## 为什么它必须存在(不是"方便一下")
+
+AC-15 阶段 2 用三次实测证明:**「照抄 SP」是不可行的策略** —— 不是因为它难,
+是因为旧库里混着**错注释**和**坏 SQL**,照抄会把缺陷一起搬过来。
+
+| # | 事实 | 如果照抄 / 按名字猜会怎样 |
+|---|---|---|
+| 1 | `sp_user_expense_baddebt` 的 `COMMENT` 写着 **`用户余额充值`**(从 recharge 复制忘改) | 按注释判断会把**坏账**当成充值 |
+| 2 | `sp_user_expense_school_spend` 的 INSERT 分支是 `INSERT INTO t_user_expense(userid,spend) VALUES(inputUserid, inputMoney, inputSpendMoney)` —— **2 列配 3 值**,且 `inputMoney` 在该 SP 里根本不存在 | 照抄会把这句坏 SQL 搬进 Go;它只在"用户没有账户行"时触发,线上从不走到,所以测也测不出来 |
+| 3 | `sp_user_expense_school_transfer_expenditure` **没有默认备注**(SP1/2/3/4 都有 `IF inputRemark='' THEN SET …`) | 用空备注跑双跑,"备注逐字一致"是**空对空**,等于没测 |
+| 4 | `sp_user_expense.school_cleared_balance` 名字叫「余额清零」,实际清的是**校区**余额,全局只减去同一个 `campusBalance` | 会断言"清零后全局余额 = 0",而它一般不是 0 |
+
+## 行为从哪里读:证据强度排序
+
+| 依据 | 可靠度 | 反例 |
+|---|---|---|
+| **SQL 语句**(UPDATE / INSERT / IF 守卫) | 最高 | 无(这是唯一的事实来源) |
+| **参数签名** | 高 | 无 —— 签名能反推"不可能发生的事":SP11 只有 4 个参数、连 `requestId` 都没有 ⇒ 它**不可能**幂等、**不可能**写流水 |
+| 自己的 `COMMENT` | **会骗人** | `baddebt` 的注释写着"用户余额充值" |
+| 表名 / 函数名 | **会骗人** | `t_user_expense` 字面像"全局表",实际两个充值 SP 都写双表(这一条当时把 team-lead 也带偏了) |
+
+⇒ **证据强度:SQL 语句 > 参数签名 > COMMENT > 表名/函数名。**
+
+## 安全约束(硬性,不是约定)
+
+- 只连 `fixture.allowedDatabases` 白名单里的**本机**库(`OpenReadOnlyDB` 里守);
+- `sql` 子命令只接受 `SELECT / SHOW / DESC / EXPLAIN / WITH` 开头的**单条**语句(分号后还有内容就拒绝);
+- 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,服务端也会拒绝本会话的写;
+- 口令只经 `AC15_*` 环境变量或仓库既有 `config/dev.json` 读取,**不打印、不落盘**。
+
+## 一个自己踩过的坑(写在这里免得下一个人再踩)
+
+`spdump columns t_campus -db yuansiwei_2026` —— 这种"先对象、后库名"的写法,
+如果直接用 Go 的 `flag` 包解析,`-db` 会被**静默丢弃**(`flag` 遇到第一个非 flag 参数就停止解析),
+于是连到默认库、查不到列、打印 `(0 行)`。
+
+**`(0 行)` 看起来像"这表没有列",是个错误答案,而不是一个错误。** 这类失败比报错危险。
+所以 `main.go` 里先做 `splitFlagsAndArgs` 重排,并有单测锁住(`TestSplitFlagsAndArgs`)。

+ 459 - 0
cmd/spdump/main.go

@@ -0,0 +1,459 @@
+// spdump 是**读旧库定义**的常备工具(不是一次性探针)。
+//
+// 为什么它不是"方便一下"而是必须存在:
+//
+//	AC-15 阶段 2 已经用三次实测证明「照抄 SP」不可行 —— 旧库里混着错注释和坏 SQL:
+//	  1. `sp_user_expense_baddebt` 的 COMMENT 写的是 '用户余额充值'(从 recharge 复制忘改);
+//	  2. `sp_user_expense_school_spend` 的 INSERT 分支是 `INSERT INTO t_user_expense(userid,spend)
+//	     VALUES(inputUserid, inputMoney, inputSpendMoney)` —— 2 列配 3 值,且 inputMoney 在该 SP 里不存在;
+//	  3. `sp_user_expense_school_transfer_expenditure` 没有默认备注(与 SP1/2/3/4 都不同)。
+//
+//	⇒ 行为必须从**语句**里读,而不是从 SP 名字 / COMMENT / 表名里猜。
+//	  这个工具就是"读语句"的入口,让下一个人也能查,而不是只有当时那个人会查。
+//
+// 用法:
+//
+//	spdump proc <spName>...                 # SHOW CREATE PROCEDURE(默认库 legacy_oracle)
+//	spdump routines [-db X] [-like a%b]     # 列出 routine(名/类型/注释/修改时间)
+//	spdump view <db.viewName>               # SHOW CREATE VIEW(跨库视图要看它是怎么定义的)
+//	spdump views [-db X] [-like %campus%]   # 列出视图
+//	spdump tables [-db X] [-like %expense%] # 列出表/视图(含 TABLE_TYPE)
+//	spdump columns <table> [-db X]          # 列出列定义(类型/是否可空/默认值/注释)
+//	spdump sql "<SELECT ...>" [-db X]       # 只读查询,表格输出
+//
+// 安全约束(硬性,不是约定):
+//   - 只连 `fixture.allowedDatabases` 里的本机库;
+//   - `sql` 子命令只接受 SELECT / SHOW / DESC / EXPLAIN 开头的语句(其余直接拒绝);
+//   - 连接建立后 `SET SESSION TRANSACTION READ ONLY`,服务端也会拒绝写。
+//
+// 口令:与 fixture 一致(AC15_* 环境变量或仓库 config/dev.json),不打印、不进日志。
+package main
+
+import (
+	"database/sql"
+	"flag"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
+)
+
+// usage 是本工具的唯一文档入口(保持与文件头注释同步)。
+const usage = `spdump —— 读旧库定义(只读)
+
+子命令:
+  proc     <spName>...               SHOW CREATE PROCEDURE
+  routines [-db X] [-like <pat>]     列出 routine
+  view     <db.viewName>             SHOW CREATE VIEW
+  views    [-db X] [-like <pat>]     列出视图
+  tables   [-db X] [-like <pat>]     列出表/视图(LIKE 用 % 通配)
+  columns  <table> [-db X]           列出列定义
+  sql      "<SELECT ...>" [-db X]    只读查询
+
+通用参数:-db <库名>(默认 legacy_oracle)
+`
+
+func main() {
+	if len(os.Args) < 2 {
+		fmt.Fprint(os.Stderr, usage)
+		os.Exit(2)
+	}
+	sub := os.Args[1]
+	// ⚠ Go 的 flag 包**遇到第一个非 flag 参数就停止解析**,
+	// 所以 `spdump columns t_campus -db yuansiwei_2026` 里的 `-db` 会被当成位置参数丢掉,
+	// 结果连到默认库、查不到列、打印「(0 行)」—— 一个"看起来正常"的错误答案。
+	// 这里先把位置参数挪到后面,让 flags 与位置参数**任意顺序**都能生效。
+	fs := flag.NewFlagSet(sub, flag.ExitOnError)
+	dbName := fs.String("db", "legacy_oracle", "目标库(必须在 fixture 白名单内)")
+	like := fs.String("like", "", "LIKE 过滤(如 %expense%)")
+	flagArgs, posArgs := splitFlagsAndArgs(os.Args[2:], map[string]bool{"-db": true, "--db": true, "-like": true, "--like": true})
+	if err := fs.Parse(append(flagArgs, posArgs...)); err != nil {
+		fail("解析参数失败:%v", err)
+	}
+	args := fs.Args()
+
+	cfg, err := fixture.LoadConfig(repoRoot())
+	if err != nil {
+		fail("读取连接配置失败:%v", err)
+	}
+	db, err := cfg.OpenReadOnlyDB(*dbName)
+	if err != nil {
+		fail("%v", err)
+	}
+	defer func() { _ = db.Close() }()
+
+	switch sub {
+	case "proc":
+		cmdProc(db, args)
+	case "routines":
+		cmdRoutines(db, *dbName, *like)
+	case "view":
+		cmdView(db, args)
+	case "views":
+		cmdList(db, *dbName, "VIEW", *like)
+	case "tables":
+		cmdTables(db, *dbName, *like)
+	case "columns":
+		cmdColumns(db, *dbName, args)
+	case "sql":
+		cmdSQL(db, args)
+	default:
+		fmt.Fprint(os.Stderr, usage)
+		os.Exit(2)
+	}
+}
+
+// repoRoot 从工作目录向上找 config/dev.json(CLI 在仓库任意子目录下都能跑)。
+func repoRoot() string {
+	dir, err := os.Getwd()
+	if err != nil {
+		return "."
+	}
+	for i := 0; i < 8; i++ {
+		if _, err := os.Stat(filepath.Join(dir, "config", "dev.json")); err == nil {
+			return dir
+		}
+		parent := filepath.Dir(dir)
+		if parent == dir {
+			break
+		}
+		dir = parent
+	}
+	return "."
+}
+
+// cmdProc 打印存储过程/函数的完整定义(含 COMMENT —— 注意 COMMENT 可能是错的)。
+func cmdProc(db *sql.DB, names []string) {
+	if len(names) == 0 {
+		fail("proc 需要至少一个 SP 名字")
+	}
+	for _, name := range names {
+		// 名字进 SQL 只能是标识符:白名单字符 + 反引号,避免拼接注入。
+		if !identOK.MatchString(name) {
+			fail("非法的 routine 名:%q", name)
+		}
+		rows, err := db.Query("SHOW CREATE PROCEDURE `" + name + "`")
+		if err != nil {
+			fmt.Printf("===== %s =====\n<读取失败:%v>\n\n", name, err)
+			continue
+		}
+		body, ok := findCreateColumn(rows, "Create Procedure")
+		_ = rows.Close()
+		if !ok || body == "" {
+			fmt.Printf("===== %s =====\n<不存在或没有定义>\n\n", name)
+			continue
+		}
+		fmt.Printf("===== %s =====\n%s\n\n", name, body)
+	}
+}
+
+// cmdRoutines 列出 routine 清单(先看有哪些,再决定读哪几个)。
+func cmdRoutines(db *sql.DB, dbName, like string) {
+	q := `SELECT ROUTINE_TYPE, ROUTINE_NAME, IFNULL(ROUTINE_COMMENT,''), IFNULL(DEFINER,''), IFNULL(LAST_ALTERED,'')
+            FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?`
+	args := []any{dbName}
+	if like != "" {
+		q += " AND ROUTINE_NAME LIKE ?"
+		args = append(args, like)
+	}
+	q += " ORDER BY ROUTINE_TYPE, ROUTINE_NAME"
+	printQuery(db, q, args...)
+}
+
+// cmdView 打印视图定义(跨库视图要看清它指向哪张物理表)。
+func cmdView(db *sql.DB, args []string) {
+	if len(args) == 0 {
+		fail("view 需要一个视图全名(db.name)")
+	}
+	full := args[0]
+	parts := strings.SplitN(full, ".", 2)
+	schema, name := "", full
+	if len(parts) == 2 {
+		schema, name = parts[0], parts[1]
+	}
+	if !identOK.MatchString(name) || (schema != "" && !identOK.MatchString(schema)) {
+		fail("非法的视图名:%q", full)
+	}
+	var rows *sql.Rows
+	var err error
+	if schema != "" {
+		rows, err = db.Query("SHOW CREATE VIEW `" + schema + "`.`" + name + "`")
+	} else {
+		rows, err = db.Query("SHOW CREATE VIEW `" + name + "`")
+	}
+	if err != nil {
+		fail("读视图 %s 失败:%v", full, err)
+	}
+	defer func() { _ = rows.Close() }()
+	body, ok := findCreateColumn(rows, "Create View")
+	if !ok {
+		fail("视图 %s 不存在或没有定义", full)
+	}
+	fmt.Printf("===== %s =====\n%s\n\n", full, body)
+}
+
+// cmdList 列出某类型的对象(VIEW / BASE TABLE 之外的自定义筛选交给 cmdTables)。
+func cmdList(db *sql.DB, dbName, tableType, like string) {
+	q := `SELECT TABLE_NAME, TABLE_TYPE, IFNULL(TABLE_COMMENT,'')
+            FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = ?`
+	args := []any{dbName, tableType}
+	if like != "" {
+		q += " AND TABLE_NAME LIKE ?"
+		args = append(args, like)
+	}
+	q += " ORDER BY TABLE_NAME"
+	printQuery(db, q, args...)
+}
+
+// cmdTables 列出表与视图(含 TABLE_TYPE —— 视图和表在这里一眼能分开)。
+func cmdTables(db *sql.DB, dbName, like string) {
+	q := `SELECT TABLE_NAME, TABLE_TYPE, ENGINE, TABLE_ROWS, IFNULL(TABLE_COMMENT,'')
+            FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`
+	args := []any{dbName}
+	if like != "" {
+		q += " AND TABLE_NAME LIKE ?"
+		args = append(args, like)
+	}
+	q += " ORDER BY TABLE_TYPE, TABLE_NAME"
+	printQuery(db, q, args...)
+}
+
+// cmdColumns 打印列定义。
+//
+// 特意带上 COLUMN_DEFAULT / IS_NULLABLE —— 这两列正是 GORM 建表与 DDL 不一致的地方
+// (`created_at` 少了 `DEFAULT CURRENT_TIMESTAMP(3)` → Error 1364)。
+func cmdColumns(db *sql.DB, dbName string, args []string) {
+	if len(args) == 0 {
+		fail("columns 需要一个表名")
+	}
+	table := args[0]
+	q := `SELECT ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
+	             IFNULL(COLUMN_DEFAULT,'<NULL>'), IFNULL(EXTRA,''), IFNULL(COLUMN_COMMENT,'')
+            FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
+           ORDER BY ORDINAL_POSITION`
+	printQuery(db, q, dbName, table)
+}
+
+// readOnlyPrefixes 是 `sql` 子命令允许的语句前缀。
+//
+// 白名单而不是黑名单:本工具的全部价值是"读",写语句不属于它的能力范围,
+// 与其在代码里"尽量拦",不如让"没被允许"成为默认。
+var readOnlyPrefixes = []string{"select", "show", "desc", "describe", "explain", "with"}
+
+// allowedSQL 判断一条语句是否属于「只读」。
+//
+// 规则:必须以上面的前缀开头,且**去掉末尾分号后不能再有分号**(否则是两条语句)。
+func allowedSQL(stmt string) bool {
+	s := strings.TrimSpace(stmt)
+	if s == "" {
+		return false
+	}
+	lower := strings.ToLower(s)
+	if strings.Contains(strings.TrimSuffix(lower, ";"), ";") {
+		return false
+	}
+	for _, p := range readOnlyPrefixes {
+		if strings.HasPrefix(lower, p) {
+			return true
+		}
+	}
+	return false
+}
+
+// cmdSQL 执行一条只读查询并打印。
+func cmdSQL(db *sql.DB, args []string) {
+	if len(args) == 0 {
+		fail("sql 需要一条语句")
+	}
+	stmt := strings.TrimSpace(args[0])
+	if !allowedSQL(stmt) {
+		fail("sql 只接受单条只读语句(SELECT/SHOW/DESC/EXPLAIN/WITH),拒绝:%q", stmt)
+	}
+	printQuery(db, stmt)
+}
+
+// findCreateColumn 在 SHOW CREATE 的结果里找出定义正文那一列。
+//
+// 不用固定列序号:PROCEDURE 的定义列名是 "Create Procedure",VIEW 是 "Create View",
+// 且 MySQL 各版本的列顺序不稳。按列名取比按位置稳。
+func findCreateColumn(rows *sql.Rows, want string) (string, bool) {
+	cols, err := rows.Columns()
+	if err != nil {
+		return "", false
+	}
+	idx := -1
+	for i, c := range cols {
+		if strings.EqualFold(c, want) {
+			idx = i
+			break
+		}
+	}
+	if idx < 0 {
+		return "", false
+	}
+	for rows.Next() {
+		vals := make([]any, len(cols))
+		ptrs := make([]any, len(cols))
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rows.Scan(ptrs...); err != nil {
+			return "", false
+		}
+		if b, ok := vals[idx].([]byte); ok {
+			return strings.TrimSpace(string(b)), true
+		}
+		if s, ok := vals[idx].(string); ok {
+			return strings.TrimSpace(s), true
+		}
+	}
+	return "", false
+}
+
+// printQuery 用对齐的表格形式打印任意查询结果(NULL 显示为 <NULL>,不会是空串)。
+func printQuery(db *sql.DB, q string, args ...any) {
+	rows, err := db.Query(q, args...)
+	if err != nil {
+		fail("查询失败:%v\n语句:%s", err, q)
+	}
+	defer func() { _ = rows.Close() }()
+	cols, err := rows.Columns()
+	if err != nil {
+		fail("取列名失败:%v", err)
+	}
+	var out [][]string
+	for rows.Next() {
+		vals := make([]any, len(cols))
+		ptrs := make([]any, len(cols))
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rows.Scan(ptrs...); err != nil {
+			fail("扫描行失败:%v", err)
+		}
+		line := make([]string, len(cols))
+		for i, v := range vals {
+			line[i] = cell(v)
+		}
+		out = append(out, line)
+	}
+	if err := rows.Err(); err != nil {
+		fail("遍历结果失败:%v", err)
+	}
+
+	width := make([]int, len(cols))
+	for i, c := range cols {
+		width[i] = len(c)
+	}
+	for _, line := range out {
+		for i, c := range line {
+			if n := displayWidth(c); n > width[i] {
+				width[i] = n
+			}
+		}
+	}
+	var head []string
+	for i, c := range cols {
+		head = append(head, pad(c, width[i]))
+	}
+	fmt.Println(strings.Join(head, " | "))
+	sep := make([]string, len(cols))
+	for i := range cols {
+		sep[i] = strings.Repeat("-", width[i])
+	}
+	fmt.Println(strings.Join(sep, "-+-"))
+	for _, line := range out {
+		cells := make([]string, len(line))
+		for i, c := range line {
+			cells[i] = pad(c, width[i])
+		}
+		fmt.Println(strings.Join(cells, " | "))
+	}
+	fmt.Printf("(%d 行)\n", len(out))
+}
+
+// cell 把任意驱动值转成字符串;NULL 显式标出(空串与 NULL 必须能区分)。
+func cell(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return "<NULL>"
+	case []byte:
+		return string(t)
+	case string:
+		return t
+	case int64:
+		return strconv.FormatInt(t, 10)
+	case uint64:
+		return strconv.FormatUint(t, 10)
+	case float64:
+		return strconv.FormatFloat(t, 'f', -1, 64)
+	case bool:
+		return strconv.FormatBool(t)
+	default:
+		return fmt.Sprintf("%v", t)
+	}
+}
+
+// displayWidth 按**字符数**算宽度(中文一字占两列宽在终端里更常见,这里按 2 折算)。
+func displayWidth(s string) int {
+	w := 0
+	for _, r := range s {
+		if r > 0x2E80 { // CJK 及全角标点
+			w += 2
+			continue
+		}
+		w++
+	}
+	return w
+}
+
+// pad 用空格补齐到指定显示宽度。
+func pad(s string, w int) string {
+	if n := displayWidth(s); n < w {
+		return s + strings.Repeat(" ", w-n)
+	}
+	return s
+}
+
+// identOK 是「把名字拼进 SQL」前的字符白名单(标识符只允许字母数字下划线)。
+var identOK = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
+// splitFlagsAndArgs 把「位置参数」与「flags」分开,使二者**任意顺序**都能生效。
+//
+// 背景:Go 的 flag 包遇到第一个非 flag 参数即停止解析。而本工具的直觉用法恰好是
+// `spdump columns t_campus -db yuansiwei_2026`(先对象、后库名),若不重排,
+// `-db` 会被静默丢弃 → 连到默认库 → 查不到 → 打印 "(0 行)"。
+// 这个错误**看起来像正常结果**("这表没有列"),比报错危险得多。
+//
+// 入参:args 原始参数;valueFlags 需要吃掉下一个 token 作为值的 flag(如 -db 是 string)。
+// 出参:flags 在前、位置参数在后的 token 序列(flag 包对"flag 在前"没有意见)。
+func splitFlagsAndArgs(args []string, valueFlags map[string]bool) (flags, pos []string) {
+	for i := 0; i < len(args); i++ {
+		a := args[i]
+		if strings.HasPrefix(a, "-") && a != "-" && a != "--" {
+			if strings.Contains(a, "=") {
+				flags = append(flags, a)
+				continue
+			}
+			flags = append(flags, a)
+			// 值型 flag:把下一个 token 一起归入 flags(即使它不以 - 开头)。
+			if valueFlags[a] && i+1 < len(args) {
+				flags = append(flags, args[i+1])
+				i++
+			}
+			continue
+		}
+		pos = append(pos, a)
+	}
+	return flags, pos
+}
+
+// fail 打印到 stderr 并以非零码退出(CLI 语义:失败必须能被 shell 看见)。
+func fail(format string, args ...any) {
+	fmt.Fprintf(os.Stderr, format+"\n", args...)
+	os.Exit(1)
+}

+ 99 - 0
cmd/spdump/main_test.go

@@ -0,0 +1,99 @@
+package main
+
+import "testing"
+
+// TestSplitFlagsAndArgs 锁住「flags 与位置参数任意顺序」这条。
+//
+// 这条不是洁癖:`spdump columns t_campus -db yuansiwei_2026` 这种直觉写法若丢掉了 `-db`,
+// 工具会连到默认库、查不到列、打印 "(0 行)" —— 一个看起来正常的错误答案。
+func TestSplitFlagsAndArgs(t *testing.T) {
+	valueFlags := map[string]bool{"-db": true, "--db": true, "-like": true, "--like": true}
+
+	cases := []struct {
+		name      string
+		in        []string
+		wantFlags []string
+		wantPos   []string
+	}{
+		{
+			name:      "flags 在前",
+			in:        []string{"-db", "yuansiwei_2026", "t_campus"},
+			wantFlags: []string{"-db", "yuansiwei_2026"},
+			wantPos:   []string{"t_campus"},
+		},
+		{
+			name:      "flags 在后(直觉写法,必须支持)",
+			in:        []string{"t_campus", "-db", "yuansiwei_2026"},
+			wantFlags: []string{"-db", "yuansiwei_2026"},
+			wantPos:   []string{"t_campus"},
+		},
+		{
+			name:      "等号形式",
+			in:        []string{"t_campus", "-db=yuansiwei"},
+			wantFlags: []string{"-db=yuansiwei"},
+			wantPos:   []string{"t_campus"},
+		},
+		{
+			name:      "两个值型 flag 交错",
+			in:        []string{"x", "-like", "%a%", "-db", "finance_bk"},
+			wantFlags: []string{"-like", "%a%", "-db", "finance_bk"},
+			wantPos:   []string{"x"},
+		},
+		{
+			name:      "纯位置参数",
+			in:        []string{"sp_user_expense_baddebt", "sp_user_expense_recharge"},
+			wantFlags: nil,
+			wantPos:   []string{"sp_user_expense_baddebt", "sp_user_expense_recharge"},
+		},
+		{
+			name:      "SQL 语句里的短横线不能被当 flag",
+			in:        []string{"-db", "finance_bk", "select * from t where a=-1"},
+			wantFlags: []string{"-db", "finance_bk"},
+			wantPos:   []string{"select * from t where a=-1"},
+		},
+	}
+	for _, c := range cases {
+		gotFlags, gotPos := splitFlagsAndArgs(c.in, valueFlags)
+		if !eq(gotFlags, c.wantFlags) {
+			t.Errorf("%s:flags = %v, want %v", c.name, gotFlags, c.wantFlags)
+		}
+		if !eq(gotPos, c.wantPos) {
+			t.Errorf("%s:pos = %v, want %v", c.name, gotPos, c.wantPos)
+		}
+	}
+}
+
+func eq(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
+
+// TestReadOnlyPrefixes 保证 `sql` 子命令只放行只读语句的前缀判断是可靠的。
+func TestReadOnlyPrefixes(t *testing.T) {
+	allowed := []string{"select 1", "SELECT * FROM t", "show create table t", "desc t", "explain select 1"}
+	for _, s := range allowed {
+		if !allowedSQL(s) {
+			t.Errorf("应当放行:%q", s)
+		}
+	}
+	denied := []string{
+		"update t set a=1",
+		"delete from t",
+		"drop table t",
+		"insert into t values (1)",
+		"select 1; drop table t", // 分号后第二条 → 拒绝
+		"",
+	}
+	for _, s := range denied {
+		if allowedSQL(s) {
+			t.Errorf("应当拒绝:%q", s)
+		}
+	}
+}

+ 47 - 0
internal/academic/dualrun/fixture/config.go

@@ -26,6 +26,7 @@ import (
 	"os"
 	"path/filepath"
 	"regexp"
+	"sort"
 	"strconv"
 	"strings"
 	"time"
@@ -262,6 +263,52 @@ func (c Config) legacyDSN() string {
 		c.LegacyUser, c.LegacyPass, c.Host, c.Port, c.LegacyDB)
 }
 
+// dsnFor 组出「同一账号、指定库」的 DSN(只读用途;写权限由 assertDatabase + 会话只读兜底)。
+func (c Config) dsnFor(dbName string) string {
+	return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
+		c.TargetUser, c.TargetPass, c.Host, c.Port, dbName)
+}
+
+// OpenReadOnlyDB 打开 `allowedDatabases` 中**任一**库的只读连接。
+//
+// 用途:核对旧库的定义(`SHOW CREATE PROCEDURE` / `information_schema` / 跨库视图),
+// 也就是"读定义而不是照着名字猜行为"这件事的落点。**不参与迁移**,也不产出任何写操作:
+// 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,服务端会拒绝本会话的任何写语句。
+//
+// 入参:dbName 库名(必须在 allowedDatabases 内,否则直接拒绝)。
+// 出参:*sql.DB(只读);error——库名不在白名单 / 连接失败 / 库名与 DATABASE() 不符。
+func (c Config) OpenReadOnlyDB(dbName string) (*sql.DB, error) {
+	if !allowedDatabases[dbName] {
+		return nil, fmt.Errorf("数据源守卫失败:库 %q 不在允许清单内(只允许 %v)", dbName, sortedKeys(allowedDatabases))
+	}
+	if !allowedHosts[c.Host] {
+		return nil, fmt.Errorf("数据源守卫失败:host %q 不在允许清单内(只允许本机)", maskHost(c.Host))
+	}
+	db, err := openGorm(c.dsnFor(dbName))
+	if err != nil {
+		return nil, fmt.Errorf("连接库 %q 失败:%w", dbName, err)
+	}
+	if err := assertDatabase(db, dbName); err != nil {
+		_ = db.Close()
+		return nil, err
+	}
+	if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil {
+		_ = db.Close()
+		return nil, fmt.Errorf("库 %q 会话只读设置失败:%w", dbName, err)
+	}
+	return db, nil
+}
+
+// sortedKeys 返回白名单的稳定顺序(错误信息里可复现,不随 map 遍历顺序抖动)。
+func sortedKeys(m map[string]bool) []string {
+	out := make([]string, 0, len(m))
+	for k := range m {
+		out = append(out, k)
+	}
+	sort.Strings(out)
+	return out
+}
+
 // openGorm 用 gorm 的 mysql driver 打开连接(复用仓库既有依赖,不新增 driver)。
 func openGorm(dsn string) (*sql.DB, error) {
 	gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{