From ad279049a01ecb1749ae3c9c92888461185f314f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 31 Aug 2026 12:58:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=20SQL=20=E7=BA=A6=E6=9D=9F=E4=B8=8E=E8=BF=90=E7=BB=B4=E6=B2=BB?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + backend/.env.example | 7 +++ backend/.env.prod.example | 7 +++ backend/cmd/api/main.go | 8 ++- backend/internal/config/config.go | 25 ++++++++ backend/internal/config/config_test.go | 35 +++++++++++ backend/internal/database/gorm_logger.go | 9 ++- backend/internal/database/mysql.go | 53 ++++++++++++++-- deploy/mysql/my.cnf | 6 ++ docs/数据库SQL约束与运维.md | 77 ++++++++++++++++++++++++ scripts/check-sql-guard.sh | 60 ++++++++++++++++++ scripts/check.sh | 2 + 12 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 docs/数据库SQL约束与运维.md create mode 100755 scripts/check-sql-guard.sh diff --git a/README.md b/README.md index cad86bc..6f62fae 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 +数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。 + 需要在本地推送前自动执行同一套检查时,启用 Git hook: ```bash diff --git a/backend/.env.example b/backend/.env.example index 915f436..b68228a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,13 @@ MYSQL_USER=hfb MYSQL_PASSWORD=secret MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local +# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。 +DATABASE_MAX_OPEN_CONNS=50 +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_CONN_MAX_LIFETIME_MINUTES=30 +DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5 +DATABASE_SLOW_QUERY_THRESHOLD_MS=500 + REDIS_ADDR=127.0.0.1:16379 REDIS_PASSWORD= REDIS_DB=0 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index af1fe7c..d5707b9 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -19,6 +19,13 @@ MYSQL_USER=hfb MYSQL_PASSWORD=change-hfb-password MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local +# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。 +DATABASE_MAX_OPEN_CONNS=50 +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_CONN_MAX_LIFETIME_MINUTES=30 +DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5 +DATABASE_SLOW_QUERY_THRESHOLD_MS=500 + REDIS_ADDR=redis:6379 REDIS_PASSWORD= REDIS_DB=0 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 2def695..43c1750 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -58,7 +58,13 @@ func main() { logAuthRuntimeIdentity(logger, cfg) var deps router.Dependencies - db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger) + db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{ + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime, + SlowQueryThreshold: cfg.Database.SlowQueryThreshold, + }) if err != nil { logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) } else { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ec59a67..ba4fa25 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "time" ) type Config struct { @@ -29,6 +30,7 @@ type Config struct { Realname RealnameConfig Log LogConfig RateLimit RateLimitConfig + Database DatabaseConfig BackupStatusFile string } @@ -70,6 +72,14 @@ type RateLimitConfig struct { RequestsPerMinute int } +type DatabaseConfig struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + SlowQueryThreshold time.Duration +} + func Load() Config { return Config{ AppEnv: getEnv("APP_ENV", "development"), @@ -131,6 +141,13 @@ func Load() Config { Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), }, + Database: DatabaseConfig{ + MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1), + MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0), + ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute, + ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute, + SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond, + }, BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"), } } @@ -208,6 +225,14 @@ func getEnvInt(key string, fallback int) int { return parsed } +func getEnvIntMin(key string, fallback, minimum int) int { + value := getEnvInt(key, fallback) + if value < minimum { + return fallback + } + return value +} + func getEnvBool(key string, fallback bool) bool { value := os.Getenv(key) if value == "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 53511be..e9ad4a4 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -26,6 +26,41 @@ func TestIsProductionEnv(t *testing.T) { } } +func TestLoadDatabaseConfigFromEnvironment(t *testing.T) { + t.Setenv("DATABASE_MAX_OPEN_CONNS", "80") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "16") + t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "45") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "7") + t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "750") + + cfg := Load() + if cfg.Database.MaxOpenConns != 80 || cfg.Database.MaxIdleConns != 16 { + t.Fatalf("database pool = %+v, want open=80 idle=16", cfg.Database) + } + if cfg.Database.ConnMaxLifetime.Minutes() != 45 || cfg.Database.ConnMaxIdleTime.Minutes() != 7 { + t.Fatalf("database lifetimes = %s/%s, want 45m/7m", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime) + } + if cfg.Database.SlowQueryThreshold.Milliseconds() != 750 { + t.Fatalf("slow query threshold = %s, want 750ms", cfg.Database.SlowQueryThreshold) + } +} + +func TestLoadDatabaseConfigInvalidValuesUseDefaults(t *testing.T) { + t.Setenv("DATABASE_MAX_OPEN_CONNS", "0") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1") + t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "0") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "-1") + t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "0") + + cfg := Load() + if cfg.Database.MaxOpenConns != 50 || cfg.Database.MaxIdleConns != 10 { + t.Fatalf("invalid pool values = %+v, want defaults open=50 idle=10", cfg.Database) + } + if cfg.Database.ConnMaxLifetime.Minutes() != 30 || cfg.Database.ConnMaxIdleTime.Minutes() != 5 || cfg.Database.SlowQueryThreshold.Milliseconds() != 500 { + t.Fatalf("invalid database durations = %+v, want defaults", cfg.Database) + } +} + // TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时 // 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { diff --git a/backend/internal/database/gorm_logger.go b/backend/internal/database/gorm_logger.go index e5ca36f..8d60c62 100644 --- a/backend/internal/database/gorm_logger.go +++ b/backend/internal/database/gorm_logger.go @@ -21,6 +21,10 @@ type structuredGormLogger struct { } func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { + return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond) +} + +func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface { level := gormLogger.Warn if strings.EqualFold(strings.TrimSpace(logLevel), "debug") { level = gormLogger.Info @@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { if logger == nil { logger = zap.L() } + if slowThreshold <= 0 { + slowThreshold = 500 * time.Millisecond + } return &structuredGormLogger{ logger: logger.With(zap.String("module", "database")), level: level, - slowThreshold: 500 * time.Millisecond, + slowThreshold: slowThreshold, } } diff --git a/backend/internal/database/mysql.go b/backend/internal/database/mysql.go index 3b62fad..22ccab7 100644 --- a/backend/internal/database/mysql.go +++ b/backend/internal/database/mysql.go @@ -8,9 +8,50 @@ import ( "gorm.io/gorm" ) +type MySQLOptions struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + SlowQueryThreshold time.Duration +} + +func DefaultMySQLOptions() MySQLOptions { + return MySQLOptions{ + MaxOpenConns: 50, + MaxIdleConns: 10, + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 5 * time.Minute, + SlowQueryThreshold: 500 * time.Millisecond, + } +} + func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) { + return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions()) +} + +func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) { + defaults := DefaultMySQLOptions() + if options.MaxOpenConns < 1 { + options.MaxOpenConns = defaults.MaxOpenConns + } + if options.MaxIdleConns < 0 { + options.MaxIdleConns = defaults.MaxIdleConns + } + if options.ConnMaxLifetime <= 0 { + options.ConnMaxLifetime = defaults.ConnMaxLifetime + } + if options.ConnMaxIdleTime < 0 { + options.ConnMaxIdleTime = defaults.ConnMaxIdleTime + } + if options.SlowQueryThreshold <= 0 { + options.SlowQueryThreshold = defaults.SlowQueryThreshold + } + if options.MaxIdleConns > options.MaxOpenConns { + options.MaxIdleConns = options.MaxOpenConns + } db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ - Logger: newGormLogger(logLevel, appLogger), + Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold), }) if err != nil { return nil, err @@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er if err != nil { return nil, err } - // 限制连接池,避免本地压测瞬间打满 MySQL max_connections。 - sqlDB.SetMaxOpenConns(50) - sqlDB.SetMaxIdleConns(10) - sqlDB.SetConnMaxLifetime(30 * time.Minute) - sqlDB.SetConnMaxIdleTime(5 * time.Minute) + // 限制连接池,避免单个实例耗尽 MySQL max_connections。 + sqlDB.SetMaxOpenConns(options.MaxOpenConns) + sqlDB.SetMaxIdleConns(options.MaxIdleConns) + sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime) + sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime) return db, nil } diff --git a/deploy/mysql/my.cnf b/deploy/mysql/my.cnf index 81c389d..c462f3d 100644 --- a/deploy/mysql/my.cnf +++ b/deploy/mysql/my.cnf @@ -9,6 +9,12 @@ character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci init-connect = 'SET NAMES utf8mb4' +# 生产慢查询观测:应用日志记录 SQL 形状,MySQL 原生日志记录超过阈值的语句。 +# performance_schema 默认开启,使用 events_statements_summary_by_digest 做聚合分析。 +slow_query_log = ON +long_query_time = 0.5 +log_slow_admin_statements = ON + # 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。 # 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。 server-id = 1 diff --git a/docs/数据库SQL约束与运维.md b/docs/数据库SQL约束与运维.md new file mode 100644 index 0000000..5ea65ad --- /dev/null +++ b/docs/数据库SQL约束与运维.md @@ -0,0 +1,77 @@ +# 数据库 SQL 约束与运维 + +本项目使用 MySQL 8.4、GORM 和 Goose。本文是提交、上线和故障排查的数据库基线;SQL 静态检查不能替代真实数据量下的执行计划。 + +## 提交前约束 + +新增或修改 SQL、GORM 查询或迁移必须满足: + +1. 列表查询必须有分页、最大数量或明确日期范围,禁止无界读取订单、流水、消息和日志历史。 +2. `GROUP BY`、`COUNT(DISTINCT)`、窗口函数、跨表聚合、`UNION` 和相关子查询必须在接近生产数据量的 MySQL 上保存 `EXPLAIN FORMAT=JSON` 或 `EXPLAIN ANALYZE` 结果。 +3. 高频查询先限定当前时间范围或当前页 ID,再做关联和聚合;禁止读取用户全部历史记录后在应用层过滤。 +4. 新增索引必须通过新的 Goose migration 提交,并在迁移注释中说明覆盖的查询条件、排序和基数;大表索引要评估锁影响和上线窗口。 +5. SQL 值必须使用 GORM 参数绑定或 `?` 占位符,禁止拼接请求参数。动态排序字段只能来自白名单。 +6. 已应用的迁移文件禁止修改或删除,只能新增迁移完成结构修复;除 `000001_init.sql` 外的新迁移必须包含 `-- +goose Up` 和 `-- +goose Down`。 +7. 角色、权限、推送渠道、客服组等小型配置表允许全量读取,但必须保持数量边界或在本文件的白名单中登记;业务数据不得套用该例外。 + +本地 `scripts/check.sh` 会执行 `scripts/check-sql-guard.sh`,保护关键索引、时间范围和迁移完整性。它只防止关键代码被重构移除,不判断执行计划是否优秀。 + +## 执行计划 + +在维护库或只读副本上替换真实参数后执行: + +```sql +EXPLAIN FORMAT=JSON +SELECT ...; + +EXPLAIN ANALYZE +SELECT ...; +``` + +重点检查扫描行数、实际返回行数、索引使用、临时表、文件排序和 join 顺序。财务首页的仪表盘、结算明细和提现合并列表属于必须定期复核的查询。 + +## 生产观测 + +生产 MySQL 应保持 `performance_schema` 开启,并启用慢查询日志。项目的 `deploy/mysql/my.cnf` 默认记录超过 500ms 的语句;应用日志只记录 SQL 形状、耗时和返回行数,不记录绑定参数值。 + +常用排查 SQL: + +```sql +SELECT + DIGEST_TEXT, + COUNT_STAR, + ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds, + ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms, + SUM_ROWS_SENT, + SUM_ROWS_EXAMINED +FROM performance_schema.events_statements_summary_by_digest +ORDER BY SUM_TIMER_WAIT DESC +LIMIT 30; + +SELECT THREAD_ID, EVENT_NAME, TIMER_WAIT, SQL_TEXT, ROWS_EXAMINED +FROM performance_schema.events_statements_current +WHERE SQL_TEXT IS NOT NULL +ORDER BY TIMER_WAIT DESC; +``` + +应用慢查询阈值由 `DATABASE_SLOW_QUERY_THRESHOLD_MS` 配置。阈值调整后要同步检查 MySQL `long_query_time`,避免应用和数据库观测口径长期不一致。 + +## 连接、超时与容量 + +- `DATABASE_MAX_OPEN_CONNS` 是单个 backend 实例的连接上限,总量必须按实例数核算,不能简单随实例扩容同比增加。 +- `DATABASE_MAX_IDLE_CONNS`、`DATABASE_CONN_MAX_LIFETIME_MINUTES` 和 `DATABASE_CONN_MAX_IDLE_TIME_MINUTES` 控制空闲连接和连接轮换。 +- 请求必须设置 Go `context` 截止时间;锁等待由 MySQL `innodb_lock_wait_timeout` 控制。长报表查询应进入异步任务或汇总表。 +- 数据量增长后,排行榜、财务历史统计和审计报表应迁移到日汇总表、缓存或只读副本,不能持续依赖在线全表聚合。 +- 修改连接池或容器资源后,用 `docker compose config` 核对最终插值;资源限额应高于稳定 RSS 峰值并留出数据库连接和备份余量。 + +## 备份与恢复 + +生产使用 XtraBackup 全量备份加 MySQL ROW binlog 归档实现时间点恢复。备份对象先客户端加密,再使用独立 OSS bucket 的服务端加密;恢复必须在隔离目录和隔离 MySQL 容器演练,禁止直接向生产实例回放。 + +```bash +./scripts/backup-online.sh status +./scripts/archive-binlog.sh verify +./scripts/restore-online.sh prepare +``` + +备份完成标识、SHA-256、manifest、server UUID 和 Goose 版本必须一致;恢复演练至少按月验证一次,并记录恢复时间点和结果。 diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh new file mode 100755 index 0000000..f9ebf11 --- /dev/null +++ b/scripts/check-sql-guard.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 检查关键 SQL 防线是否仍存在;真实数据量下的执行计划需要在 MySQL 上单独验证。 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations" +failures=() + +require_pattern() { + local file="$1" pattern="$2" name="$3" + if [[ ! -f "${ROOT_DIR}/${file}" ]]; then + failures+=("${name}: 文件不存在 ${file}") + elif ! grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then + failures+=("${name}: 缺少 ${pattern} (${file})") + fi +} + +# 数据库运行时配置和参数脱敏不能被重构删除。 +require_pattern "backend/internal/config/config.go" "DATABASE_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置" +require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置" +require_pattern "backend/internal/database/gorm_logger.go" "ParamsFilter" "SQL 参数脱敏" +require_pattern "backend/internal/database/mysql.go" "SetMaxOpenConns" "连接池应用" + +# 当前财务仪表盘的时间范围聚合和索引是高频查询防线。 +require_pattern "backend/internal/modules/adminfinance/subquery.go" "settled_at >= ? AND scoped_order.settled_at <= ?" "结算聚合时间范围" +require_pattern "backend/internal/modules/adminfinance/dashboard.go" "po.created_at >= ? AND po.created_at <= ?" "支付聚合时间范围" +require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_completed_at" "财务完成索引" +require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_cancelled_at" "财务取消索引" + +# 分页核心路径必须保留上限;配置型全量列表在运维文档中登记为白名单。 +require_pattern "backend/internal/modules/listing/public_query.go" "if pageSize > 50" "公开商品分页上限" +require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限" + +# 已发布迁移只允许新增,不允许修改或删除历史文件。 +if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then + while read -r status file _; do + [[ -z "${status:-}" ]] && continue + if [[ "${status}" != "A" ]]; then + failures+=("历史迁移文件不可修改或删除: ${status} ${file}") + fi + done <<< "${changed_migrations}" +else + failures+=("无法检查迁移文件变更") +fi + +# 初始合并迁移没有 Down,其余迁移必须提供可回滚段。 +while IFS= read -r file; do + [[ "$(basename "${file}")" == "000001_init.sql" ]] && continue + if ! grep -Fq -- '-- +goose Up' "${file}" || ! grep -Fq -- '-- +goose Down' "${file}"; then + failures+=("迁移缺少 Goose Up/Down: ${file#"${ROOT_DIR}/"}") + fi +done < <(find "${MIGRATIONS_DIR}" -maxdepth 1 -type f -name '*.sql' | sort) + +if (( ${#failures[@]} > 0 )); then + printf '[sql-guard] SQL 约束检查失败\n' >&2 + printf '[sql-guard] %s\n' "${failures[@]}" >&2 + exit 1 +fi + +printf '[sql-guard] SQL 约束检查通过\n' diff --git a/scripts/check.sh b/scripts/check.sh index e3a70a5..9329876 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -6,6 +6,7 @@ # - 手动校验:开发时手动跑 ./scripts/check.sh # # 包含: +# 数据库:SQL 约束与迁移完整性检查 # 后端:go vet + go build + go test # 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build # @@ -91,6 +92,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then log "后端检查目录: ${BACKEND_DIR}" cd "${BACKEND_DIR}" + run_step "SQL 约束检查" bash "${ROOT_DIR}/scripts/check-sql-guard.sh" || FAILED=1 run_step "go vet ./..." go vet ./... || FAILED=1 run_step "go build ./..." go build ./... || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then