|
|
@@ -0,0 +1,459 @@
|
|
|
+// spdump 是**读旧库定义**的常备工具(不是一次性探针)。
|
|
|
+//
|
|
|
+// 为什么它不是"方便一下"而是必须存在:
|
|
|
+//
|
|
|
+// AC-15 阶段 2 已经用三次实测证明「照抄 SP」不可行 —— 旧库里混着错注释和坏 SQL:
|
|
|
+// 1. `sp_user_expense_baddebt` 的 COMMENT 写的是 '用户余额充值'(从 recharge 复制忘改);
|
|
|
+// 2. `sp_user_expense_school_spend` 的 INSERT 分支是 `INSERT INTO t_user_expense(userid,spend)
|
|
|
+// VALUES(inputUserid, inputMoney, inputSpendMoney)` —— 2 列配 3 值,且 inputMoney 在该 SP 里不存在;
|
|
|
+// 3. `sp_user_expense_school_transfer_expenditure` 没有默认备注(与 SP1/2/3/4 都不同)。
|
|
|
+//
|
|
|
+// ⇒ 行为必须从**语句**里读,而不是从 SP 名字 / COMMENT / 表名里猜。
|
|
|
+// 这个工具就是"读语句"的入口,让下一个人也能查,而不是只有当时那个人会查。
|
|
|
+//
|
|
|
+// 用法:
|
|
|
+//
|
|
|
+// spdump proc <spName>... # SHOW CREATE PROCEDURE(默认库 legacy_oracle)
|
|
|
+// spdump routines [-db X] [-like a%b] # 列出 routine(名/类型/注释/修改时间)
|
|
|
+// spdump view <db.viewName> # SHOW CREATE VIEW(跨库视图要看它是怎么定义的)
|
|
|
+// spdump views [-db X] [-like %campus%] # 列出视图
|
|
|
+// spdump tables [-db X] [-like %expense%] # 列出表/视图(含 TABLE_TYPE)
|
|
|
+// spdump columns <table> [-db X] # 列出列定义(类型/是否可空/默认值/注释)
|
|
|
+// spdump sql "<SELECT ...>" [-db X] # 只读查询,表格输出
|
|
|
+//
|
|
|
+// 安全约束(硬性,不是约定):
|
|
|
+// - 只连 `fixture.allowedDatabases` 里的本机库;
|
|
|
+// - `sql` 子命令只接受 SELECT / SHOW / DESC / EXPLAIN 开头的语句(其余直接拒绝);
|
|
|
+// - 连接建立后 `SET SESSION TRANSACTION READ ONLY`,服务端也会拒绝写。
|
|
|
+//
|
|
|
+// 口令:与 fixture 一致(AC15_* 环境变量或仓库 config/dev.json),不打印、不进日志。
|
|
|
+package main
|
|
|
+
|
|
|
+import (
|
|
|
+ "database/sql"
|
|
|
+ "flag"
|
|
|
+ "fmt"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "regexp"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
+
|
|
|
+ "yuansiwei/unified-backend/internal/academic/dualrun/fixture"
|
|
|
+)
|
|
|
+
|
|
|
+// usage 是本工具的唯一文档入口(保持与文件头注释同步)。
|
|
|
+const usage = `spdump —— 读旧库定义(只读)
|
|
|
+
|
|
|
+子命令:
|
|
|
+ proc <spName>... SHOW CREATE PROCEDURE
|
|
|
+ routines [-db X] [-like <pat>] 列出 routine
|
|
|
+ view <db.viewName> SHOW CREATE VIEW
|
|
|
+ views [-db X] [-like <pat>] 列出视图
|
|
|
+ tables [-db X] [-like <pat>] 列出表/视图(LIKE 用 % 通配)
|
|
|
+ columns <table> [-db X] 列出列定义
|
|
|
+ sql "<SELECT ...>" [-db X] 只读查询
|
|
|
+
|
|
|
+通用参数:-db <库名>(默认 legacy_oracle)
|
|
|
+`
|
|
|
+
|
|
|
+func main() {
|
|
|
+ if len(os.Args) < 2 {
|
|
|
+ fmt.Fprint(os.Stderr, usage)
|
|
|
+ os.Exit(2)
|
|
|
+ }
|
|
|
+ sub := os.Args[1]
|
|
|
+ // ⚠ Go 的 flag 包**遇到第一个非 flag 参数就停止解析**,
|
|
|
+ // 所以 `spdump columns t_campus -db yuansiwei_2026` 里的 `-db` 会被当成位置参数丢掉,
|
|
|
+ // 结果连到默认库、查不到列、打印「(0 行)」—— 一个"看起来正常"的错误答案。
|
|
|
+ // 这里先把位置参数挪到后面,让 flags 与位置参数**任意顺序**都能生效。
|
|
|
+ fs := flag.NewFlagSet(sub, flag.ExitOnError)
|
|
|
+ dbName := fs.String("db", "legacy_oracle", "目标库(必须在 fixture 白名单内)")
|
|
|
+ like := fs.String("like", "", "LIKE 过滤(如 %expense%)")
|
|
|
+ flagArgs, posArgs := splitFlagsAndArgs(os.Args[2:], map[string]bool{"-db": true, "--db": true, "-like": true, "--like": true})
|
|
|
+ if err := fs.Parse(append(flagArgs, posArgs...)); err != nil {
|
|
|
+ fail("解析参数失败:%v", err)
|
|
|
+ }
|
|
|
+ args := fs.Args()
|
|
|
+
|
|
|
+ cfg, err := fixture.LoadConfig(repoRoot())
|
|
|
+ if err != nil {
|
|
|
+ fail("读取连接配置失败:%v", err)
|
|
|
+ }
|
|
|
+ db, err := cfg.OpenReadOnlyDB(*dbName)
|
|
|
+ if err != nil {
|
|
|
+ fail("%v", err)
|
|
|
+ }
|
|
|
+ defer func() { _ = db.Close() }()
|
|
|
+
|
|
|
+ switch sub {
|
|
|
+ case "proc":
|
|
|
+ cmdProc(db, args)
|
|
|
+ case "routines":
|
|
|
+ cmdRoutines(db, *dbName, *like)
|
|
|
+ case "view":
|
|
|
+ cmdView(db, args)
|
|
|
+ case "views":
|
|
|
+ cmdList(db, *dbName, "VIEW", *like)
|
|
|
+ case "tables":
|
|
|
+ cmdTables(db, *dbName, *like)
|
|
|
+ case "columns":
|
|
|
+ cmdColumns(db, *dbName, args)
|
|
|
+ case "sql":
|
|
|
+ cmdSQL(db, args)
|
|
|
+ default:
|
|
|
+ fmt.Fprint(os.Stderr, usage)
|
|
|
+ os.Exit(2)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// repoRoot 从工作目录向上找 config/dev.json(CLI 在仓库任意子目录下都能跑)。
|
|
|
+func repoRoot() string {
|
|
|
+ dir, err := os.Getwd()
|
|
|
+ if err != nil {
|
|
|
+ return "."
|
|
|
+ }
|
|
|
+ for i := 0; i < 8; i++ {
|
|
|
+ if _, err := os.Stat(filepath.Join(dir, "config", "dev.json")); err == nil {
|
|
|
+ return dir
|
|
|
+ }
|
|
|
+ parent := filepath.Dir(dir)
|
|
|
+ if parent == dir {
|
|
|
+ break
|
|
|
+ }
|
|
|
+ dir = parent
|
|
|
+ }
|
|
|
+ return "."
|
|
|
+}
|
|
|
+
|
|
|
+// cmdProc 打印存储过程/函数的完整定义(含 COMMENT —— 注意 COMMENT 可能是错的)。
|
|
|
+func cmdProc(db *sql.DB, names []string) {
|
|
|
+ if len(names) == 0 {
|
|
|
+ fail("proc 需要至少一个 SP 名字")
|
|
|
+ }
|
|
|
+ for _, name := range names {
|
|
|
+ // 名字进 SQL 只能是标识符:白名单字符 + 反引号,避免拼接注入。
|
|
|
+ if !identOK.MatchString(name) {
|
|
|
+ fail("非法的 routine 名:%q", name)
|
|
|
+ }
|
|
|
+ rows, err := db.Query("SHOW CREATE PROCEDURE `" + name + "`")
|
|
|
+ if err != nil {
|
|
|
+ fmt.Printf("===== %s =====\n<读取失败:%v>\n\n", name, err)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ body, ok := findCreateColumn(rows, "Create Procedure")
|
|
|
+ _ = rows.Close()
|
|
|
+ if !ok || body == "" {
|
|
|
+ fmt.Printf("===== %s =====\n<不存在或没有定义>\n\n", name)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ fmt.Printf("===== %s =====\n%s\n\n", name, body)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// cmdRoutines 列出 routine 清单(先看有哪些,再决定读哪几个)。
|
|
|
+func cmdRoutines(db *sql.DB, dbName, like string) {
|
|
|
+ q := `SELECT ROUTINE_TYPE, ROUTINE_NAME, IFNULL(ROUTINE_COMMENT,''), IFNULL(DEFINER,''), IFNULL(LAST_ALTERED,'')
|
|
|
+ FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?`
|
|
|
+ args := []any{dbName}
|
|
|
+ if like != "" {
|
|
|
+ q += " AND ROUTINE_NAME LIKE ?"
|
|
|
+ args = append(args, like)
|
|
|
+ }
|
|
|
+ q += " ORDER BY ROUTINE_TYPE, ROUTINE_NAME"
|
|
|
+ printQuery(db, q, args...)
|
|
|
+}
|
|
|
+
|
|
|
+// cmdView 打印视图定义(跨库视图要看清它指向哪张物理表)。
|
|
|
+func cmdView(db *sql.DB, args []string) {
|
|
|
+ if len(args) == 0 {
|
|
|
+ fail("view 需要一个视图全名(db.name)")
|
|
|
+ }
|
|
|
+ full := args[0]
|
|
|
+ parts := strings.SplitN(full, ".", 2)
|
|
|
+ schema, name := "", full
|
|
|
+ if len(parts) == 2 {
|
|
|
+ schema, name = parts[0], parts[1]
|
|
|
+ }
|
|
|
+ if !identOK.MatchString(name) || (schema != "" && !identOK.MatchString(schema)) {
|
|
|
+ fail("非法的视图名:%q", full)
|
|
|
+ }
|
|
|
+ var rows *sql.Rows
|
|
|
+ var err error
|
|
|
+ if schema != "" {
|
|
|
+ rows, err = db.Query("SHOW CREATE VIEW `" + schema + "`.`" + name + "`")
|
|
|
+ } else {
|
|
|
+ rows, err = db.Query("SHOW CREATE VIEW `" + name + "`")
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ fail("读视图 %s 失败:%v", full, err)
|
|
|
+ }
|
|
|
+ defer func() { _ = rows.Close() }()
|
|
|
+ body, ok := findCreateColumn(rows, "Create View")
|
|
|
+ if !ok {
|
|
|
+ fail("视图 %s 不存在或没有定义", full)
|
|
|
+ }
|
|
|
+ fmt.Printf("===== %s =====\n%s\n\n", full, body)
|
|
|
+}
|
|
|
+
|
|
|
+// cmdList 列出某类型的对象(VIEW / BASE TABLE 之外的自定义筛选交给 cmdTables)。
|
|
|
+func cmdList(db *sql.DB, dbName, tableType, like string) {
|
|
|
+ q := `SELECT TABLE_NAME, TABLE_TYPE, IFNULL(TABLE_COMMENT,'')
|
|
|
+ FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = ?`
|
|
|
+ args := []any{dbName, tableType}
|
|
|
+ if like != "" {
|
|
|
+ q += " AND TABLE_NAME LIKE ?"
|
|
|
+ args = append(args, like)
|
|
|
+ }
|
|
|
+ q += " ORDER BY TABLE_NAME"
|
|
|
+ printQuery(db, q, args...)
|
|
|
+}
|
|
|
+
|
|
|
+// cmdTables 列出表与视图(含 TABLE_TYPE —— 视图和表在这里一眼能分开)。
|
|
|
+func cmdTables(db *sql.DB, dbName, like string) {
|
|
|
+ q := `SELECT TABLE_NAME, TABLE_TYPE, ENGINE, TABLE_ROWS, IFNULL(TABLE_COMMENT,'')
|
|
|
+ FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`
|
|
|
+ args := []any{dbName}
|
|
|
+ if like != "" {
|
|
|
+ q += " AND TABLE_NAME LIKE ?"
|
|
|
+ args = append(args, like)
|
|
|
+ }
|
|
|
+ q += " ORDER BY TABLE_TYPE, TABLE_NAME"
|
|
|
+ printQuery(db, q, args...)
|
|
|
+}
|
|
|
+
|
|
|
+// cmdColumns 打印列定义。
|
|
|
+//
|
|
|
+// 特意带上 COLUMN_DEFAULT / IS_NULLABLE —— 这两列正是 GORM 建表与 DDL 不一致的地方
|
|
|
+// (`created_at` 少了 `DEFAULT CURRENT_TIMESTAMP(3)` → Error 1364)。
|
|
|
+func cmdColumns(db *sql.DB, dbName string, args []string) {
|
|
|
+ if len(args) == 0 {
|
|
|
+ fail("columns 需要一个表名")
|
|
|
+ }
|
|
|
+ table := args[0]
|
|
|
+ q := `SELECT ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
|
|
+ IFNULL(COLUMN_DEFAULT,'<NULL>'), IFNULL(EXTRA,''), IFNULL(COLUMN_COMMENT,'')
|
|
|
+ FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
|
|
+ ORDER BY ORDINAL_POSITION`
|
|
|
+ printQuery(db, q, dbName, table)
|
|
|
+}
|
|
|
+
|
|
|
+// readOnlyPrefixes 是 `sql` 子命令允许的语句前缀。
|
|
|
+//
|
|
|
+// 白名单而不是黑名单:本工具的全部价值是"读",写语句不属于它的能力范围,
|
|
|
+// 与其在代码里"尽量拦",不如让"没被允许"成为默认。
|
|
|
+var readOnlyPrefixes = []string{"select", "show", "desc", "describe", "explain", "with"}
|
|
|
+
|
|
|
+// allowedSQL 判断一条语句是否属于「只读」。
|
|
|
+//
|
|
|
+// 规则:必须以上面的前缀开头,且**去掉末尾分号后不能再有分号**(否则是两条语句)。
|
|
|
+func allowedSQL(stmt string) bool {
|
|
|
+ s := strings.TrimSpace(stmt)
|
|
|
+ if s == "" {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ lower := strings.ToLower(s)
|
|
|
+ if strings.Contains(strings.TrimSuffix(lower, ";"), ";") {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for _, p := range readOnlyPrefixes {
|
|
|
+ if strings.HasPrefix(lower, p) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+// cmdSQL 执行一条只读查询并打印。
|
|
|
+func cmdSQL(db *sql.DB, args []string) {
|
|
|
+ if len(args) == 0 {
|
|
|
+ fail("sql 需要一条语句")
|
|
|
+ }
|
|
|
+ stmt := strings.TrimSpace(args[0])
|
|
|
+ if !allowedSQL(stmt) {
|
|
|
+ fail("sql 只接受单条只读语句(SELECT/SHOW/DESC/EXPLAIN/WITH),拒绝:%q", stmt)
|
|
|
+ }
|
|
|
+ printQuery(db, stmt)
|
|
|
+}
|
|
|
+
|
|
|
+// findCreateColumn 在 SHOW CREATE 的结果里找出定义正文那一列。
|
|
|
+//
|
|
|
+// 不用固定列序号:PROCEDURE 的定义列名是 "Create Procedure",VIEW 是 "Create View",
|
|
|
+// 且 MySQL 各版本的列顺序不稳。按列名取比按位置稳。
|
|
|
+func findCreateColumn(rows *sql.Rows, want string) (string, bool) {
|
|
|
+ cols, err := rows.Columns()
|
|
|
+ if err != nil {
|
|
|
+ return "", false
|
|
|
+ }
|
|
|
+ idx := -1
|
|
|
+ for i, c := range cols {
|
|
|
+ if strings.EqualFold(c, want) {
|
|
|
+ idx = i
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if idx < 0 {
|
|
|
+ return "", false
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ vals := make([]any, len(cols))
|
|
|
+ ptrs := make([]any, len(cols))
|
|
|
+ for i := range vals {
|
|
|
+ ptrs[i] = &vals[i]
|
|
|
+ }
|
|
|
+ if err := rows.Scan(ptrs...); err != nil {
|
|
|
+ return "", false
|
|
|
+ }
|
|
|
+ if b, ok := vals[idx].([]byte); ok {
|
|
|
+ return strings.TrimSpace(string(b)), true
|
|
|
+ }
|
|
|
+ if s, ok := vals[idx].(string); ok {
|
|
|
+ return strings.TrimSpace(s), true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "", false
|
|
|
+}
|
|
|
+
|
|
|
+// printQuery 用对齐的表格形式打印任意查询结果(NULL 显示为 <NULL>,不会是空串)。
|
|
|
+func printQuery(db *sql.DB, q string, args ...any) {
|
|
|
+ rows, err := db.Query(q, args...)
|
|
|
+ if err != nil {
|
|
|
+ fail("查询失败:%v\n语句:%s", err, q)
|
|
|
+ }
|
|
|
+ defer func() { _ = rows.Close() }()
|
|
|
+ cols, err := rows.Columns()
|
|
|
+ if err != nil {
|
|
|
+ fail("取列名失败:%v", err)
|
|
|
+ }
|
|
|
+ var out [][]string
|
|
|
+ for rows.Next() {
|
|
|
+ vals := make([]any, len(cols))
|
|
|
+ ptrs := make([]any, len(cols))
|
|
|
+ for i := range vals {
|
|
|
+ ptrs[i] = &vals[i]
|
|
|
+ }
|
|
|
+ if err := rows.Scan(ptrs...); err != nil {
|
|
|
+ fail("扫描行失败:%v", err)
|
|
|
+ }
|
|
|
+ line := make([]string, len(cols))
|
|
|
+ for i, v := range vals {
|
|
|
+ line[i] = cell(v)
|
|
|
+ }
|
|
|
+ out = append(out, line)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ fail("遍历结果失败:%v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ width := make([]int, len(cols))
|
|
|
+ for i, c := range cols {
|
|
|
+ width[i] = len(c)
|
|
|
+ }
|
|
|
+ for _, line := range out {
|
|
|
+ for i, c := range line {
|
|
|
+ if n := displayWidth(c); n > width[i] {
|
|
|
+ width[i] = n
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ var head []string
|
|
|
+ for i, c := range cols {
|
|
|
+ head = append(head, pad(c, width[i]))
|
|
|
+ }
|
|
|
+ fmt.Println(strings.Join(head, " | "))
|
|
|
+ sep := make([]string, len(cols))
|
|
|
+ for i := range cols {
|
|
|
+ sep[i] = strings.Repeat("-", width[i])
|
|
|
+ }
|
|
|
+ fmt.Println(strings.Join(sep, "-+-"))
|
|
|
+ for _, line := range out {
|
|
|
+ cells := make([]string, len(line))
|
|
|
+ for i, c := range line {
|
|
|
+ cells[i] = pad(c, width[i])
|
|
|
+ }
|
|
|
+ fmt.Println(strings.Join(cells, " | "))
|
|
|
+ }
|
|
|
+ fmt.Printf("(%d 行)\n", len(out))
|
|
|
+}
|
|
|
+
|
|
|
+// cell 把任意驱动值转成字符串;NULL 显式标出(空串与 NULL 必须能区分)。
|
|
|
+func cell(v any) string {
|
|
|
+ switch t := v.(type) {
|
|
|
+ case nil:
|
|
|
+ return "<NULL>"
|
|
|
+ case []byte:
|
|
|
+ return string(t)
|
|
|
+ case string:
|
|
|
+ return t
|
|
|
+ case int64:
|
|
|
+ return strconv.FormatInt(t, 10)
|
|
|
+ case uint64:
|
|
|
+ return strconv.FormatUint(t, 10)
|
|
|
+ case float64:
|
|
|
+ return strconv.FormatFloat(t, 'f', -1, 64)
|
|
|
+ case bool:
|
|
|
+ return strconv.FormatBool(t)
|
|
|
+ default:
|
|
|
+ return fmt.Sprintf("%v", t)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// displayWidth 按**字符数**算宽度(中文一字占两列宽在终端里更常见,这里按 2 折算)。
|
|
|
+func displayWidth(s string) int {
|
|
|
+ w := 0
|
|
|
+ for _, r := range s {
|
|
|
+ if r > 0x2E80 { // CJK 及全角标点
|
|
|
+ w += 2
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ w++
|
|
|
+ }
|
|
|
+ return w
|
|
|
+}
|
|
|
+
|
|
|
+// pad 用空格补齐到指定显示宽度。
|
|
|
+func pad(s string, w int) string {
|
|
|
+ if n := displayWidth(s); n < w {
|
|
|
+ return s + strings.Repeat(" ", w-n)
|
|
|
+ }
|
|
|
+ return s
|
|
|
+}
|
|
|
+
|
|
|
+// identOK 是「把名字拼进 SQL」前的字符白名单(标识符只允许字母数字下划线)。
|
|
|
+var identOK = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
|
+
|
|
|
+// splitFlagsAndArgs 把「位置参数」与「flags」分开,使二者**任意顺序**都能生效。
|
|
|
+//
|
|
|
+// 背景:Go 的 flag 包遇到第一个非 flag 参数即停止解析。而本工具的直觉用法恰好是
|
|
|
+// `spdump columns t_campus -db yuansiwei_2026`(先对象、后库名),若不重排,
|
|
|
+// `-db` 会被静默丢弃 → 连到默认库 → 查不到 → 打印 "(0 行)"。
|
|
|
+// 这个错误**看起来像正常结果**("这表没有列"),比报错危险得多。
|
|
|
+//
|
|
|
+// 入参:args 原始参数;valueFlags 需要吃掉下一个 token 作为值的 flag(如 -db 是 string)。
|
|
|
+// 出参:flags 在前、位置参数在后的 token 序列(flag 包对"flag 在前"没有意见)。
|
|
|
+func splitFlagsAndArgs(args []string, valueFlags map[string]bool) (flags, pos []string) {
|
|
|
+ for i := 0; i < len(args); i++ {
|
|
|
+ a := args[i]
|
|
|
+ if strings.HasPrefix(a, "-") && a != "-" && a != "--" {
|
|
|
+ if strings.Contains(a, "=") {
|
|
|
+ flags = append(flags, a)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ flags = append(flags, a)
|
|
|
+ // 值型 flag:把下一个 token 一起归入 flags(即使它不以 - 开头)。
|
|
|
+ if valueFlags[a] && i+1 < len(args) {
|
|
|
+ flags = append(flags, args[i+1])
|
|
|
+ i++
|
|
|
+ }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ pos = append(pos, a)
|
|
|
+ }
|
|
|
+ return flags, pos
|
|
|
+}
|
|
|
+
|
|
|
+// fail 打印到 stderr 并以非零码退出(CLI 语义:失败必须能被 shell 看见)。
|
|
|
+func fail(format string, args ...any) {
|
|
|
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
|
|
|
+ os.Exit(1)
|
|
|
+}
|