main.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. // spdump 是**读旧库定义**的常备工具(不是一次性探针)。
  2. //
  3. // 为什么它不是"方便一下"而是必须存在:
  4. //
  5. // AC-15 阶段 2 已经用三次实测证明「照抄 SP」不可行 —— 旧库里混着错注释和坏 SQL:
  6. // 1. `sp_user_expense_baddebt` 的 COMMENT 写的是 '用户余额充值'(从 recharge 复制忘改);
  7. // 2. `sp_user_expense_school_spend` 的 INSERT 分支是 `INSERT INTO t_user_expense(userid,spend)
  8. // VALUES(inputUserid, inputMoney, inputSpendMoney)` —— 2 列配 3 值,且 inputMoney 在该 SP 里不存在;
  9. // 3. `sp_user_expense_school_transfer_expenditure` 没有默认备注(与 SP1/2/3/4 都不同)。
  10. //
  11. // ⇒ 行为必须从**语句**里读,而不是从 SP 名字 / COMMENT / 表名里猜。
  12. // 这个工具就是"读语句"的入口,让下一个人也能查,而不是只有当时那个人会查。
  13. //
  14. // 用法:
  15. //
  16. // spdump proc <spName>... # SHOW CREATE PROCEDURE(默认库 legacy_oracle)
  17. // spdump routines [-db X] [-like a%b] # 列出 routine(名/类型/注释/修改时间)
  18. // spdump view <db.viewName> # SHOW CREATE VIEW(跨库视图要看它是怎么定义的)
  19. // spdump views [-db X] [-like %campus%] # 列出视图
  20. // spdump tables [-db X] [-like %expense%] # 列出表/视图(含 TABLE_TYPE)
  21. // spdump columns <table> [-db X] # 列出列定义(类型/是否可空/默认值/注释)
  22. // spdump sql "<SELECT ...>" [-db X] # 只读查询,表格输出
  23. //
  24. // 安全约束(硬性,不是约定):
  25. // - 只连 `fixture.allowedDatabases` 里的本机库;
  26. // - `sql` 子命令只接受 SELECT / SHOW / DESC / EXPLAIN 开头的语句(其余直接拒绝);
  27. // - 连接建立后 `SET SESSION TRANSACTION READ ONLY`,服务端也会拒绝写。
  28. //
  29. // 口令:与 fixture 一致(AC15_* 环境变量或仓库 config/dev.json),不打印、不进日志。
  30. package main
  31. import (
  32. "database/sql"
  33. "flag"
  34. "fmt"
  35. "os"
  36. "path/filepath"
  37. "regexp"
  38. "strconv"
  39. "strings"
  40. "yuansiwei/unified-backend/internal/academic/dualrun/fixture"
  41. )
  42. // usage 是本工具的唯一文档入口(保持与文件头注释同步)。
  43. const usage = `spdump —— 读旧库定义(只读)
  44. 子命令:
  45. proc <spName>... SHOW CREATE PROCEDURE
  46. routines [-db X] [-like <pat>] 列出 routine
  47. view <db.viewName> SHOW CREATE VIEW
  48. views [-db X] [-like <pat>] 列出视图
  49. tables [-db X] [-like <pat>] 列出表/视图(LIKE 用 % 通配)
  50. columns <table> [-db X] 列出列定义
  51. sql "<SELECT ...>" [-db X] 只读查询
  52. 通用参数:-db <库名>(默认 legacy_oracle)
  53. `
  54. func main() {
  55. if len(os.Args) < 2 {
  56. fmt.Fprint(os.Stderr, usage)
  57. os.Exit(2)
  58. }
  59. sub := os.Args[1]
  60. // ⚠ Go 的 flag 包**遇到第一个非 flag 参数就停止解析**,
  61. // 所以 `spdump columns t_campus -db yuansiwei_2026` 里的 `-db` 会被当成位置参数丢掉,
  62. // 结果连到默认库、查不到列、打印「(0 行)」—— 一个"看起来正常"的错误答案。
  63. // 这里先把位置参数挪到后面,让 flags 与位置参数**任意顺序**都能生效。
  64. fs := flag.NewFlagSet(sub, flag.ExitOnError)
  65. dbName := fs.String("db", "legacy_oracle", "目标库(必须在 fixture 白名单内)")
  66. like := fs.String("like", "", "LIKE 过滤(如 %expense%)")
  67. flagArgs, posArgs := splitFlagsAndArgs(os.Args[2:], map[string]bool{"-db": true, "--db": true, "-like": true, "--like": true})
  68. if err := fs.Parse(append(flagArgs, posArgs...)); err != nil {
  69. fail("解析参数失败:%v", err)
  70. }
  71. args := fs.Args()
  72. cfg, err := fixture.LoadConfig(repoRoot())
  73. if err != nil {
  74. fail("读取连接配置失败:%v", err)
  75. }
  76. db, err := cfg.OpenReadOnlyDB(*dbName)
  77. if err != nil {
  78. fail("%v", err)
  79. }
  80. defer func() { _ = db.Close() }()
  81. switch sub {
  82. case "proc":
  83. cmdProc(db, args)
  84. case "routines":
  85. cmdRoutines(db, *dbName, *like)
  86. case "view":
  87. cmdView(db, args)
  88. case "views":
  89. cmdList(db, *dbName, "VIEW", *like)
  90. case "tables":
  91. cmdTables(db, *dbName, *like)
  92. case "columns":
  93. cmdColumns(db, *dbName, args)
  94. case "sql":
  95. cmdSQL(db, args)
  96. default:
  97. fmt.Fprint(os.Stderr, usage)
  98. os.Exit(2)
  99. }
  100. }
  101. // repoRoot 从工作目录向上找 config/dev.json(CLI 在仓库任意子目录下都能跑)。
  102. func repoRoot() string {
  103. dir, err := os.Getwd()
  104. if err != nil {
  105. return "."
  106. }
  107. for i := 0; i < 8; i++ {
  108. if _, err := os.Stat(filepath.Join(dir, "config", "dev.json")); err == nil {
  109. return dir
  110. }
  111. parent := filepath.Dir(dir)
  112. if parent == dir {
  113. break
  114. }
  115. dir = parent
  116. }
  117. return "."
  118. }
  119. // cmdProc 打印存储过程/函数的完整定义(含 COMMENT —— 注意 COMMENT 可能是错的)。
  120. func cmdProc(db *sql.DB, names []string) {
  121. if len(names) == 0 {
  122. fail("proc 需要至少一个 SP 名字")
  123. }
  124. for _, name := range names {
  125. // 名字进 SQL 只能是标识符:白名单字符 + 反引号,避免拼接注入。
  126. if !identOK.MatchString(name) {
  127. fail("非法的 routine 名:%q", name)
  128. }
  129. rows, err := db.Query("SHOW CREATE PROCEDURE `" + name + "`")
  130. if err != nil {
  131. fmt.Printf("===== %s =====\n<读取失败:%v>\n\n", name, err)
  132. continue
  133. }
  134. body, ok := findCreateColumn(rows, "Create Procedure")
  135. _ = rows.Close()
  136. if !ok || body == "" {
  137. fmt.Printf("===== %s =====\n<不存在或没有定义>\n\n", name)
  138. continue
  139. }
  140. fmt.Printf("===== %s =====\n%s\n\n", name, body)
  141. }
  142. }
  143. // cmdRoutines 列出 routine 清单(先看有哪些,再决定读哪几个)。
  144. func cmdRoutines(db *sql.DB, dbName, like string) {
  145. q := `SELECT ROUTINE_TYPE, ROUTINE_NAME, IFNULL(ROUTINE_COMMENT,''), IFNULL(DEFINER,''), IFNULL(LAST_ALTERED,'')
  146. FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?`
  147. args := []any{dbName}
  148. if like != "" {
  149. q += " AND ROUTINE_NAME LIKE ?"
  150. args = append(args, like)
  151. }
  152. q += " ORDER BY ROUTINE_TYPE, ROUTINE_NAME"
  153. printQuery(db, q, args...)
  154. }
  155. // cmdView 打印视图定义(跨库视图要看清它指向哪张物理表)。
  156. func cmdView(db *sql.DB, args []string) {
  157. if len(args) == 0 {
  158. fail("view 需要一个视图全名(db.name)")
  159. }
  160. full := args[0]
  161. parts := strings.SplitN(full, ".", 2)
  162. schema, name := "", full
  163. if len(parts) == 2 {
  164. schema, name = parts[0], parts[1]
  165. }
  166. if !identOK.MatchString(name) || (schema != "" && !identOK.MatchString(schema)) {
  167. fail("非法的视图名:%q", full)
  168. }
  169. var rows *sql.Rows
  170. var err error
  171. if schema != "" {
  172. rows, err = db.Query("SHOW CREATE VIEW `" + schema + "`.`" + name + "`")
  173. } else {
  174. rows, err = db.Query("SHOW CREATE VIEW `" + name + "`")
  175. }
  176. if err != nil {
  177. fail("读视图 %s 失败:%v", full, err)
  178. }
  179. defer func() { _ = rows.Close() }()
  180. body, ok := findCreateColumn(rows, "Create View")
  181. if !ok {
  182. fail("视图 %s 不存在或没有定义", full)
  183. }
  184. fmt.Printf("===== %s =====\n%s\n\n", full, body)
  185. }
  186. // cmdList 列出某类型的对象(VIEW / BASE TABLE 之外的自定义筛选交给 cmdTables)。
  187. func cmdList(db *sql.DB, dbName, tableType, like string) {
  188. q := `SELECT TABLE_NAME, TABLE_TYPE, IFNULL(TABLE_COMMENT,'')
  189. FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = ?`
  190. args := []any{dbName, tableType}
  191. if like != "" {
  192. q += " AND TABLE_NAME LIKE ?"
  193. args = append(args, like)
  194. }
  195. q += " ORDER BY TABLE_NAME"
  196. printQuery(db, q, args...)
  197. }
  198. // cmdTables 列出表与视图(含 TABLE_TYPE —— 视图和表在这里一眼能分开)。
  199. func cmdTables(db *sql.DB, dbName, like string) {
  200. q := `SELECT TABLE_NAME, TABLE_TYPE, ENGINE, TABLE_ROWS, IFNULL(TABLE_COMMENT,'')
  201. FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`
  202. args := []any{dbName}
  203. if like != "" {
  204. q += " AND TABLE_NAME LIKE ?"
  205. args = append(args, like)
  206. }
  207. q += " ORDER BY TABLE_TYPE, TABLE_NAME"
  208. printQuery(db, q, args...)
  209. }
  210. // cmdColumns 打印列定义。
  211. //
  212. // 特意带上 COLUMN_DEFAULT / IS_NULLABLE —— 这两列正是 GORM 建表与 DDL 不一致的地方
  213. // (`created_at` 少了 `DEFAULT CURRENT_TIMESTAMP(3)` → Error 1364)。
  214. func cmdColumns(db *sql.DB, dbName string, args []string) {
  215. if len(args) == 0 {
  216. fail("columns 需要一个表名")
  217. }
  218. table := args[0]
  219. q := `SELECT ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
  220. IFNULL(COLUMN_DEFAULT,'<NULL>'), IFNULL(EXTRA,''), IFNULL(COLUMN_COMMENT,'')
  221. FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
  222. ORDER BY ORDINAL_POSITION`
  223. printQuery(db, q, dbName, table)
  224. }
  225. // readOnlyPrefixes 是 `sql` 子命令允许的语句前缀。
  226. //
  227. // 白名单而不是黑名单:本工具的全部价值是"读",写语句不属于它的能力范围,
  228. // 与其在代码里"尽量拦",不如让"没被允许"成为默认。
  229. var readOnlyPrefixes = []string{"select", "show", "desc", "describe", "explain", "with"}
  230. // allowedSQL 判断一条语句是否属于「只读」。
  231. //
  232. // 规则:必须以上面的前缀开头,且**去掉末尾分号后不能再有分号**(否则是两条语句)。
  233. func allowedSQL(stmt string) bool {
  234. s := strings.TrimSpace(stmt)
  235. if s == "" {
  236. return false
  237. }
  238. lower := strings.ToLower(s)
  239. if strings.Contains(strings.TrimSuffix(lower, ";"), ";") {
  240. return false
  241. }
  242. for _, p := range readOnlyPrefixes {
  243. if strings.HasPrefix(lower, p) {
  244. return true
  245. }
  246. }
  247. return false
  248. }
  249. // cmdSQL 执行一条只读查询并打印。
  250. func cmdSQL(db *sql.DB, args []string) {
  251. if len(args) == 0 {
  252. fail("sql 需要一条语句")
  253. }
  254. stmt := strings.TrimSpace(args[0])
  255. if !allowedSQL(stmt) {
  256. fail("sql 只接受单条只读语句(SELECT/SHOW/DESC/EXPLAIN/WITH),拒绝:%q", stmt)
  257. }
  258. printQuery(db, stmt)
  259. }
  260. // findCreateColumn 在 SHOW CREATE 的结果里找出定义正文那一列。
  261. //
  262. // 不用固定列序号:PROCEDURE 的定义列名是 "Create Procedure",VIEW 是 "Create View",
  263. // 且 MySQL 各版本的列顺序不稳。按列名取比按位置稳。
  264. func findCreateColumn(rows *sql.Rows, want string) (string, bool) {
  265. cols, err := rows.Columns()
  266. if err != nil {
  267. return "", false
  268. }
  269. idx := -1
  270. for i, c := range cols {
  271. if strings.EqualFold(c, want) {
  272. idx = i
  273. break
  274. }
  275. }
  276. if idx < 0 {
  277. return "", false
  278. }
  279. for rows.Next() {
  280. vals := make([]any, len(cols))
  281. ptrs := make([]any, len(cols))
  282. for i := range vals {
  283. ptrs[i] = &vals[i]
  284. }
  285. if err := rows.Scan(ptrs...); err != nil {
  286. return "", false
  287. }
  288. if b, ok := vals[idx].([]byte); ok {
  289. return strings.TrimSpace(string(b)), true
  290. }
  291. if s, ok := vals[idx].(string); ok {
  292. return strings.TrimSpace(s), true
  293. }
  294. }
  295. return "", false
  296. }
  297. // printQuery 用对齐的表格形式打印任意查询结果(NULL 显示为 <NULL>,不会是空串)。
  298. func printQuery(db *sql.DB, q string, args ...any) {
  299. rows, err := db.Query(q, args...)
  300. if err != nil {
  301. fail("查询失败:%v\n语句:%s", err, q)
  302. }
  303. defer func() { _ = rows.Close() }()
  304. cols, err := rows.Columns()
  305. if err != nil {
  306. fail("取列名失败:%v", err)
  307. }
  308. var out [][]string
  309. for rows.Next() {
  310. vals := make([]any, len(cols))
  311. ptrs := make([]any, len(cols))
  312. for i := range vals {
  313. ptrs[i] = &vals[i]
  314. }
  315. if err := rows.Scan(ptrs...); err != nil {
  316. fail("扫描行失败:%v", err)
  317. }
  318. line := make([]string, len(cols))
  319. for i, v := range vals {
  320. line[i] = cell(v)
  321. }
  322. out = append(out, line)
  323. }
  324. if err := rows.Err(); err != nil {
  325. fail("遍历结果失败:%v", err)
  326. }
  327. width := make([]int, len(cols))
  328. for i, c := range cols {
  329. width[i] = len(c)
  330. }
  331. for _, line := range out {
  332. for i, c := range line {
  333. if n := displayWidth(c); n > width[i] {
  334. width[i] = n
  335. }
  336. }
  337. }
  338. var head []string
  339. for i, c := range cols {
  340. head = append(head, pad(c, width[i]))
  341. }
  342. fmt.Println(strings.Join(head, " | "))
  343. sep := make([]string, len(cols))
  344. for i := range cols {
  345. sep[i] = strings.Repeat("-", width[i])
  346. }
  347. fmt.Println(strings.Join(sep, "-+-"))
  348. for _, line := range out {
  349. cells := make([]string, len(line))
  350. for i, c := range line {
  351. cells[i] = pad(c, width[i])
  352. }
  353. fmt.Println(strings.Join(cells, " | "))
  354. }
  355. fmt.Printf("(%d 行)\n", len(out))
  356. }
  357. // cell 把任意驱动值转成字符串;NULL 显式标出(空串与 NULL 必须能区分)。
  358. func cell(v any) string {
  359. switch t := v.(type) {
  360. case nil:
  361. return "<NULL>"
  362. case []byte:
  363. return string(t)
  364. case string:
  365. return t
  366. case int64:
  367. return strconv.FormatInt(t, 10)
  368. case uint64:
  369. return strconv.FormatUint(t, 10)
  370. case float64:
  371. return strconv.FormatFloat(t, 'f', -1, 64)
  372. case bool:
  373. return strconv.FormatBool(t)
  374. default:
  375. return fmt.Sprintf("%v", t)
  376. }
  377. }
  378. // displayWidth 按**字符数**算宽度(中文一字占两列宽在终端里更常见,这里按 2 折算)。
  379. func displayWidth(s string) int {
  380. w := 0
  381. for _, r := range s {
  382. if r > 0x2E80 { // CJK 及全角标点
  383. w += 2
  384. continue
  385. }
  386. w++
  387. }
  388. return w
  389. }
  390. // pad 用空格补齐到指定显示宽度。
  391. func pad(s string, w int) string {
  392. if n := displayWidth(s); n < w {
  393. return s + strings.Repeat(" ", w-n)
  394. }
  395. return s
  396. }
  397. // identOK 是「把名字拼进 SQL」前的字符白名单(标识符只允许字母数字下划线)。
  398. var identOK = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
  399. // splitFlagsAndArgs 把「位置参数」与「flags」分开,使二者**任意顺序**都能生效。
  400. //
  401. // 背景:Go 的 flag 包遇到第一个非 flag 参数即停止解析。而本工具的直觉用法恰好是
  402. // `spdump columns t_campus -db yuansiwei_2026`(先对象、后库名),若不重排,
  403. // `-db` 会被静默丢弃 → 连到默认库 → 查不到 → 打印 "(0 行)"。
  404. // 这个错误**看起来像正常结果**("这表没有列"),比报错危险得多。
  405. //
  406. // 入参:args 原始参数;valueFlags 需要吃掉下一个 token 作为值的 flag(如 -db 是 string)。
  407. // 出参:flags 在前、位置参数在后的 token 序列(flag 包对"flag 在前"没有意见)。
  408. func splitFlagsAndArgs(args []string, valueFlags map[string]bool) (flags, pos []string) {
  409. for i := 0; i < len(args); i++ {
  410. a := args[i]
  411. if strings.HasPrefix(a, "-") && a != "-" && a != "--" {
  412. if strings.Contains(a, "=") {
  413. flags = append(flags, a)
  414. continue
  415. }
  416. flags = append(flags, a)
  417. // 值型 flag:把下一个 token 一起归入 flags(即使它不以 - 开头)。
  418. if valueFlags[a] && i+1 < len(args) {
  419. flags = append(flags, args[i+1])
  420. i++
  421. }
  422. continue
  423. }
  424. pos = append(pos, a)
  425. }
  426. return flags, pos
  427. }
  428. // fail 打印到 stderr 并以非零码退出(CLI 语义:失败必须能被 shell 看见)。
  429. func fail(format string, args ...any) {
  430. fmt.Fprintf(os.Stderr, format+"\n", args...)
  431. os.Exit(1)
  432. }