// Command ac15sp 打印一个(或多个)存储过程的**参数列表 + 正文原样**。 // // 为什么这是常备工具而不是一次性探针: // AC-15 阶段 2 已三次证明「照抄 SP」不可行 —— 注释会写错(`sp_user_expense_baddebt` // 的 COMMENT 写着"用户余额充值")、正文会写坏(`sp_user_expense_school_spend` 的 INSERT // 2 列配 3 值)、默认备注会缺失。行为只能从**语句正文**里读,所以需要一个随时能查的入口。 // // 用法: // // go run ./cmd/ac15sp -sp sp_user_expense_baddebt // go run ./cmd/ac15sp -sp sp_a -sp sp_b -db legacy_oracle // // 只读边界(硬性): // - 只查 `information_schema`(`parameters` / `routines`),不执行任何 DDL/DML; // - 连接建立后先 `SET SESSION TRANSACTION READ ONLY`,服务端也会拒绝写; // - 库名必须在允许清单内(只允许本机 fixture 库)。 // // 口令:只从环境变量 `AC15_DB_PASS` / `MYSQL_PWD` 或仓库内 `config/dev.json` 读取, // 不打印、不进日志、不落盘。 // // 输出刻意**不做任何美化**:正文按数据库中存储的样子原样打印(含换行与缩进), // 便于直接 grep / diff;不要在这里做格式化或转义,那会让"看到的就是存的"这条不成立。 package main import ( "database/sql" "encoding/json" "flag" "fmt" "os" "regexp" "strings" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // allowedDatabases 只允许本机 fixture 库(与 dualrun/fixture 的口径一致)。 var allowedDatabases = map[string]bool{ "finance_bk": true, "legacy_finance": true, "legacy_oracle": true, "yuansiwei_2026": true, "yuansiwei_i": true, "e2e_test": true, } var allowedHosts = map[string]bool{"127.0.0.1": true, "localhost": true, "::1": true} type multiFlag []string func (m *multiFlag) String() string { return strings.Join(*m, ",") } func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } var ( dsnPattern = regexp.MustCompile(`^([^:]+):([^@]+)@tcp\(([^:]+):(\d+)\)`) jsonPattern = regexp.MustCompile(`"dsn"\s*:\s*"([^"]+)"`) ) func main() { var sps multiFlag db := flag.String("db", "legacy_oracle", "库名(必须在允许清单内)") cfgPath := flag.String("config", "config/dev.json", "凭据来源(环境变量优先)") flag.Var(&sps, "sp", "存储过程名,可重复:-sp a -sp b") flag.Parse() // 兼容位置参数写法:ac15sp sp_a sp_b if len(sps) == 0 { sps = append(sps, flag.Args()...) } if len(sps) == 0 { fmt.Fprintln(os.Stderr, "用法: go run ./cmd/ac15sp -sp [-db legacy_oracle]") os.Exit(2) } if !allowedDatabases[*db] { fmt.Fprintf(os.Stderr, "拒绝:库 %q 不在允许清单内\n", *db) os.Exit(2) } host, port, user, pass, err := loadCreds(*cfgPath) if err != nil { fmt.Fprintln(os.Stderr, "凭据加载失败:", err) os.Exit(2) } if !allowedHosts[host] { fmt.Fprintln(os.Stderr, "拒绝:host 不在允许清单内(只允许本机)") os.Exit(2) } if pass == "" { fmt.Fprintln(os.Stderr, "拒绝:口令为空(用 AC15_DB_PASS / MYSQL_PWD 或 config/dev.json)") os.Exit(2) } dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&timeout=5s", user, pass, host, port, *db) g, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { fmt.Fprintln(os.Stderr, "连接失败:", err) os.Exit(1) } sqlDB, err := g.DB() if err != nil { fmt.Fprintln(os.Stderr, "取连接失败:", err) os.Exit(1) } defer sqlDB.Close() // 服务端级只读:即使本程序写错也写不进去 if _, err := sqlDB.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil { fmt.Fprintln(os.Stderr, "设置只读会话失败:", err) os.Exit(1) } exit := 0 for _, sp := range sps { if err := dumpOne(sqlDB, *db, sp); err != nil { fmt.Fprintf(os.Stderr, "[%s] %v\n", sp, err) exit = 1 } } os.Exit(exit) } // dumpOne 打印参数列表 + 正文原样。 // // 两个查询都落在 information_schema(不 SHOW,不读 mysql.*): // - 参数:information_schema.parameters // - 正文:information_schema.routines.routine_definition(**原样输出,不 trim 不转义**) func dumpOne(db *sql.DB, schema, sp string) error { var routineType, securityType, definer, comment, body, created, altered sql.NullString var dataAccess, deterministic sql.NullString err := db.QueryRow(`SELECT routine_type, IFNULL(sql_data_access,''), IFNULL(is_deterministic,''), IFNULL(security_type,''), IFNULL(definer,''), IFNULL(routine_comment,''), IFNULL(routine_definition,''), IFNULL(created,''), IFNULL(last_altered,'') FROM information_schema.routines WHERE routine_schema = ? AND routine_name = ?`, schema, sp). Scan(&routineType, &dataAccess, &deterministic, &securityType, &definer, &comment, &body, &created, &altered) if err == sql.ErrNoRows { return fmt.Errorf("在 %s 下找不到 routine %q", schema, sp) } if err != nil { return err } rows, err := db.Query(`SELECT ordinal_position, IFNULL(parameter_mode,''), IFNULL(parameter_name,''), IFNULL(dtd_identifier,'') FROM information_schema.parameters WHERE specific_schema = ? AND specific_name = ? AND ordinal_position > 0 ORDER BY ordinal_position`, schema, sp) if err != nil { return err } defer rows.Close() type param struct { pos int mode string name string typ string } var params []param for rows.Next() { var p param if err := rows.Scan(&p.pos, &p.mode, &p.name, &p.typ); err == nil { params = append(params, p) } } if err := rows.Err(); err != nil { return err } fmt.Printf("===== %s.%s =====\n", schema, sp) fmt.Printf("type=%s data_access=%s deterministic=%s security=%s definer=%s\n", routineType.String, dataAccess.String, deterministic.String, securityType.String, definer.String) fmt.Printf("created=%s last_altered=%s\n", created.String, altered.String) fmt.Printf("comment=%s\n", comment.String) fmt.Printf("-- 参数(%d 个)--\n", len(params)) if len(params) == 0 { fmt.Println("(无参数)") } for _, p := range params { fmt.Printf(" %d. %s %s %s\n", p.pos, p.mode, p.name, p.typ) } fmt.Println("-- 正文(原样,未做任何格式化/转义)--") // 正文原样输出:不 TrimSpace、不替换换行 —— "看到的就是数据库里存的" fmt.Print(body.String) if !strings.HasSuffix(body.String, "\n") { fmt.Println() } fmt.Println() return nil } // loadCreds 环境变量优先,其次 config/dev.json 的 db.dsn。 // // 只取 host/port/user/pass;返回的 pass 只用于拼 DSN,不打印。 func loadCreds(path string) (host string, port int, user, pass string, err error) { host, port = "127.0.0.1", 3306 if v := os.Getenv("AC15_DB_HOST"); v != "" { host = v } user = os.Getenv("AC15_DB_USER") pass = os.Getenv("AC15_DB_PASS") if pass == "" { pass = os.Getenv("MYSQL_PWD") } raw, rerr := os.ReadFile(path) if rerr != nil { if user == "" || pass == "" { return "", 0, "", "", fmt.Errorf("读 %s 失败且环境变量不完整: %w", path, rerr) } return host, port, user, pass, nil } var f struct { DB struct { DSN string `json:"dsn"` } `json:"db"` } if jerr := json.Unmarshal(raw, &f); jerr != nil { if user != "" && pass != "" { return host, port, user, pass, nil } return "", 0, "", "", fmt.Errorf("解析 %s 失败: %w", path, jerr) } m := dsnPattern.FindStringSubmatch(f.DB.DSN) if m == nil { if user != "" && pass != "" { return host, port, user, pass, nil } return "", 0, "", "", fmt.Errorf("%s 里的 dsn 无法解析", path) } if user == "" { user = m[1] } if pass == "" { pass = m[2] } if os.Getenv("AC15_DB_HOST") == "" { host = m[3] } if v := m[4]; v != "" { fmt.Sscanf(v, "%d", &port) } return host, port, user, pass, nil }