| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- // 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
- }
|