Jelajahi Sumber

feat(ac15): 固化 SP 正文查看工具 cmd/ac15sp(只读)

背景:R15/R18/R19 三条风险全靠核对 SP 正文发现,其中 R18 正是注释与正文不符
(baddebt 的 COMMENT 写着'用户余额充值')——核对 SP 定义已从一次性动作变成常备工序。

- 目录 cmd/ac15sp/(cmd 下天然是给人手跑的),用法 go run ./cmd/ac15sp -sp <name> [-db X]
- 输出:参数列表(information_schema.parameters)+ 正文原样(routines.routine_definition,
  不 trim、不转义、不美化,便于 grep/diff)
- 只读:只查 information_schema,连接后 SET SESSION TRANSACTION READ ONLY;库名/ host 双白名单
- 口令只走 AC15_DB_PASS / MYSQL_PWD / config/dev.json,不打印不落盘
- README 三行:用途、为什么常备、只读边界
- 删除一次性探针 tmp_probe/(不入库)
Q 5 jam lalu
induk
melakukan
4b28c84e89
2 mengubah file dengan 249 tambahan dan 0 penghapusan
  1. 5 0
      cmd/ac15sp/README.md
  2. 244 0
      cmd/ac15sp/main.go

+ 5 - 0
cmd/ac15sp/README.md

@@ -0,0 +1,5 @@
+# cmd/ac15sp — 查存储过程正文(只读)
+
+用途:`go run ./cmd/ac15sp -sp <procName> [-db legacy_oracle]` 打印 **参数列表 + 正文原样**(不美化、不反转义,便于 grep/diff)。
+为什么常备:AC-15 已三次证明"照抄 SP"不可行(注释写错、正文写坏、默认备注缺失),行为只能从语句正文读。
+只读边界:只查 `information_schema`(`parameters` / `routines`),连接后 `SET SESSION TRANSACTION READ ONLY`,禁一切 DDL/DML;口令只走 `AC15_DB_PASS`/`MYSQL_PWD`/`config/dev.json`,不打印不落盘。更广的查询(表/视图/列/任意 SELECT)用 `cmd/spdump`。

+ 244 - 0
cmd/ac15sp/main.go

@@ -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
+}