|
|
@@ -0,0 +1,244 @@
|
|
|
+// 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 <procName> [-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
|
|
|
+}
|