| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- // 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)
- }
|