完善数据库 SQL 约束与运维治理

This commit is contained in:
yml2213
2026-08-31 12:58:28 +08:00
parent 1ee7d17712
commit ad279049a0
12 changed files with 283 additions and 8 deletions
+2
View File
@@ -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` 做局部调试。 `./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: 需要在本地推送前自动执行同一套检查时,启用 Git hook:
```bash ```bash
+7
View File
@@ -8,6 +8,13 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=secret MYSQL_PASSWORD=secret
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local 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_ADDR=127.0.0.1:16379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
+7
View File
@@ -19,6 +19,13 @@ MYSQL_USER=hfb
MYSQL_PASSWORD=change-hfb-password MYSQL_PASSWORD=change-hfb-password
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local 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_ADDR=redis:6379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
+7 -1
View File
@@ -58,7 +58,13 @@ func main() {
logAuthRuntimeIdentity(logger, cfg) logAuthRuntimeIdentity(logger, cfg)
var deps router.Dependencies 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 { if err != nil {
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
} else { } else {
+25
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Config struct { type Config struct {
@@ -29,6 +30,7 @@ type Config struct {
Realname RealnameConfig Realname RealnameConfig
Log LogConfig Log LogConfig
RateLimit RateLimitConfig RateLimit RateLimitConfig
Database DatabaseConfig
BackupStatusFile string BackupStatusFile string
} }
@@ -70,6 +72,14 @@ type RateLimitConfig struct {
RequestsPerMinute int RequestsPerMinute int
} }
type DatabaseConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getEnv("APP_ENV", "development"), AppEnv: getEnv("APP_ENV", "development"),
@@ -131,6 +141,13 @@ func Load() Config {
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), 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"), 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 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 { func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
+35
View File
@@ -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 时 // TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 // 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
+8 -1
View File
@@ -21,6 +21,10 @@ type structuredGormLogger struct {
} }
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface { 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 level := gormLogger.Warn
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") { if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
level = gormLogger.Info level = gormLogger.Info
@@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
if logger == nil { if logger == nil {
logger = zap.L() logger = zap.L()
} }
if slowThreshold <= 0 {
slowThreshold = 500 * time.Millisecond
}
return &structuredGormLogger{ return &structuredGormLogger{
logger: logger.With(zap.String("module", "database")), logger: logger.With(zap.String("module", "database")),
level: level, level: level,
slowThreshold: 500 * time.Millisecond, slowThreshold: slowThreshold,
} }
} }
+47 -6
View File
@@ -8,9 +8,50 @@ import (
"gorm.io/gorm" "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) { 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{ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newGormLogger(logLevel, appLogger), Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 限制连接池,避免本地压测瞬间打满 MySQL max_connections。 // 限制连接池,避免单个实例耗尽 MySQL max_connections。
sqlDB.SetMaxOpenConns(50) sqlDB.SetMaxOpenConns(options.MaxOpenConns)
sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxIdleConns(options.MaxIdleConns)
sqlDB.SetConnMaxLifetime(30 * time.Minute) sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(5 * time.Minute) sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime)
return db, nil return db, nil
} }
+6
View File
@@ -9,6 +9,12 @@ character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci collation-server = utf8mb4_unicode_ci
init-connect = 'SET NAMES utf8mb4' 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 备份桶。 # 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。
# 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。 # 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。
server-id = 1 server-id = 1
+77
View File
@@ -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 <complete.env-OSS-URI>
```
备份完成标识、SHA-256、manifest、server UUID 和 Goose 版本必须一致;恢复演练至少按月验证一次,并记录恢复时间点和结果。
+60
View File
@@ -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'
+2
View File
@@ -6,6 +6,7 @@
# - 手动校验:开发时手动跑 ./scripts/check.sh # - 手动校验:开发时手动跑 ./scripts/check.sh
# #
# 包含: # 包含:
# 数据库:SQL 约束与迁移完整性检查
# 后端:go vet + go build + go test # 后端:go vet + go build + go test
# 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build # 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build
# #
@@ -91,6 +92,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then
log "后端检查目录: ${BACKEND_DIR}" log "后端检查目录: ${BACKEND_DIR}"
cd "${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 vet ./..." go vet ./... || FAILED=1
run_step "go build ./..." go build ./... || FAILED=1 run_step "go build ./..." go build ./... || FAILED=1
if [[ "${SKIP_TESTS}" == "0" ]]; then if [[ "${SKIP_TESTS}" == "0" ]]; then