diff --git a/README.md b/README.md index 520d5b6..a369adc 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ npm run dev ## 开发态短信与实名 -- 后端日志使用单行可读文本。开发环境默认只输出控制台,生产示例只输出 `backend/logs/app-YYYY-MM-DD.log`,避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。 +- 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。 - 短信验证码默认使用 mock 适配器,本地验证码会以 INFO 日志输出,手机号保持脱敏。生产环境强制使用 `SMS_PROVIDER=aliyun`。 - 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME` 和 `ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`。 - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 diff --git a/backend/.env.example b/backend/.env.example index 37d6f10..e4cebae 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,7 +30,7 @@ RATE_LIMIT_REQUESTS_PER_MINUTE=300 LOG_LEVEL=info LOG_DIR=logs LOG_ENABLE_CONSOLE=true -LOG_ENABLE_FILE=false +LOG_ENABLE_FILE=true LOG_RETAIN_DAYS=14 # MinIO 容器初始化变量,同时供后端对象存储使用。 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index aa16483..f77f0f0 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -38,6 +38,10 @@ ADMIN_BOOTSTRAP_NICKNAME=超级管理员 RATE_LIMIT_ENABLED=true RATE_LIMIT_REQUESTS_PER_MINUTE=300 +# 后端容器使用宿主机业务用户的 UID/GID,服务器上可用 id -u yml、id -g yml 查询。 +BACKEND_UID=1000 +BACKEND_GID=1000 + LOG_LEVEL=info LOG_DIR=/app/logs LOG_ENABLE_CONSOLE=false diff --git a/backend/Dockerfile b/backend/Dockerfile index 6552a21..4a4114b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -24,9 +24,14 @@ ENV TZ=Asia/Shanghai # 国内服务器访问 dl-cdn.alpinelinux.org 常极慢,改用阿里云镜像 RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.aliyun.com/alpine#g' \ /etc/apk/repositories \ - && apk add --no-cache tzdata + && apk add --no-cache tzdata \ + && addgroup -S -g 10001 app \ + && adduser -S -D -H -u 10001 -G app app \ + && mkdir -p /app/logs \ + && chown app:app /app/logs COPY --from=build /out/hfb-api /app/hfb-api COPY --from=build /go/bin/goose /app/goose COPY --from=build /src/migrations /app/migrations +USER app:app EXPOSE 8080 CMD ["/app/hfb-api"] diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9061540..286d1f8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -109,7 +109,7 @@ func Load() Config { Level: getEnv("LOG_LEVEL", "info"), Dir: getEnv("LOG_DIR", "logs"), EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true), - EnableFile: getEnvBool("LOG_ENABLE_FILE", false), + EnableFile: getEnvBool("LOG_ENABLE_FILE", true), RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14), }, RateLimit: RateLimitConfig{ diff --git a/backend/internal/middleware/request_logger.go b/backend/internal/middleware/request_logger.go index 56e9cf4..b1061f7 100644 --- a/backend/internal/middleware/request_logger.go +++ b/backend/internal/middleware/request_logger.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "strings" "time" "hfb_sys/backend/pkg/response" @@ -10,7 +11,7 @@ import ( "go.uber.org/zap" ) -// 正常请求不记录访问日志;只有慢请求、服务端错误、限流和有诊断价值的认证失败会输出。 +// 查询请求默认静默;成功写操作、慢请求、服务端错误、限流和有诊断价值的认证失败会输出。 const slowRequestThresholdMs = 500 func RequestLogger(logger *zap.Logger) gin.HandlerFunc { @@ -23,8 +24,9 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc { path := c.Request.URL.Path route := c.FullPath() authFailure := meaningfulAuthFailure(c) + operation := meaningfulOperation(c.Request.Method, path, status) - if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure { + if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure && !operation { return } @@ -78,10 +80,33 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc { logger.Warn("后台认证失败", fields...) case latencyMs >= slowRequestThresholdMs: logger.Warn("HTTP 慢请求", fields...) + case operation: + logger.Info("HTTP 操作完成", fields...) } } } +func meaningfulOperation(method, path string, status int) bool { + if status < 200 || status >= 400 { + return false + } + // 支付回调已有更完整的业务结果日志,不再重复记录一条访问日志。 + if isPaymentNotifyPath(path) { + return false + } + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func isPaymentNotifyPath(path string) bool { + return (strings.Contains(path, "/payment/") || strings.Contains(path, "/payments/")) && + strings.HasSuffix(strings.TrimSuffix(path, "/"), "/notify") +} + // shouldSkipHTTPLog 判断是否为无需记录的普通请求。 func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool { if status >= 500 || status == http.StatusTooManyRequests { diff --git a/backend/internal/middleware/request_logger_test.go b/backend/internal/middleware/request_logger_test.go index 359b5ea..c368904 100644 --- a/backend/internal/middleware/request_logger_test.go +++ b/backend/internal/middleware/request_logger_test.go @@ -29,3 +29,28 @@ func TestShouldSkipHTTPLog(t *testing.T) { }) } } + +func TestMeaningfulOperation(t *testing.T) { + tests := []struct { + name string + method string + path string + status int + want bool + }{ + {name: "创建成功", method: "POST", path: "/api/orders", status: 201, want: true}, + {name: "更新成功", method: "PUT", path: "/api/orders/1", status: 200, want: true}, + {name: "删除成功", method: "DELETE", path: "/api/orders/1", status: 204, want: true}, + {name: "普通查询", method: "GET", path: "/api/orders", status: 200, want: false}, + {name: "失败写操作", method: "POST", path: "/api/orders", status: 400, want: false}, + {name: "支付回调已有业务日志", method: "POST", path: "/api/payments/lakala/notify", status: 200, want: false}, + {name: "支付回调尾部斜杠", method: "POST", path: "/api/payments/leshua/notify/", status: 200, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := meaningfulOperation(tt.method, tt.path, tt.status); got != tt.want { + t.Fatalf("meaningfulOperation() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/deploy/README.md b/deploy/README.md index 62418e2..316f728 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -36,8 +36,19 @@ MINIO_ROOT_USER MINIO_ROOT_PASSWORD STORAGE_ACCESS_KEY_ID STORAGE_SECRET_ACCESS_KEY +BACKEND_UID +BACKEND_GID ``` +`BACKEND_UID`、`BACKEND_GID` 填写服务器业务用户的数值 ID,例如: + +```bash +id -u yml +id -g yml +``` + +后端容器会使用该 UID/GID 运行。部署脚本由 root 执行时会自动把已有日志修正为该属主;非 root 执行且发现旧的 root 日志时,会给出需要执行的 `chown` 命令后停止。 + ### 同机部署选号网(hfb_show,可选) 在 `backend/.env` 增加(域名不要带 `https://`): @@ -76,6 +87,7 @@ HFB_SHOW_DIR=../hfb_show - 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。 - 使用内置 MinIO 时,检查 `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` 是否和 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 一致。 - 构建并启动生产容器。 +- 使用配置的非 root UID/GID 运行后端,并检查日志目录属主。 - 通过 Caddy 自动申请或续签 HTTPS 证书。 - 等待 MySQL、Redis、MinIO 就绪。 - 按顺序执行尚未应用的数据库迁移。 @@ -87,7 +99,7 @@ HFB_SHOW_DIR=../hfb_show ./scripts/deploy-prod.sh --logs ``` -后端运行日志会同时输出到 Docker 控制台,并写入宿主机: +后端运行日志会写入宿主机: ```text backend/logs/app-YYYY-MM-DD.log diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index f38f91e..7a224b8 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -103,6 +103,8 @@ services: build: context: ../backend restart: unless-stopped + # 使用宿主机业务用户的 UID/GID,避免绑定挂载的日志被 root 创建。 + user: "${BACKEND_UID:?请在 backend/.env 配置 BACKEND_UID}:${BACKEND_GID:?请在 backend/.env 配置 BACKEND_GID}" env_file: - ../backend/.env environment: diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index f967971..8fee5fa 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -146,7 +146,7 @@ validate_env() { exit 1 fi - local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint + local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid local minio_root_user minio_root_password storage_access_key_id storage_secret_access_key app_env="$(require_env APP_ENV)" caddy_domain="$(require_env CADDY_DOMAIN)" @@ -163,6 +163,13 @@ validate_env() { minio_root_password="$(require_env MINIO_ROOT_PASSWORD)" storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)" storage_secret_access_key="$(require_env STORAGE_SECRET_ACCESS_KEY)" + backend_uid="$(require_env BACKEND_UID)" + backend_gid="$(require_env BACKEND_GID)" + + if [[ ! "${backend_uid}" =~ ^[1-9][0-9]*$ || ! "${backend_gid}" =~ ^[1-9][0-9]*$ ]]; then + log_error "BACKEND_UID 和 BACKEND_GID 必须是大于 0 的数字,禁止后端容器使用 root" + exit 1 + fi if [[ "${app_env}" != "production" ]]; then log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production" @@ -353,7 +360,21 @@ health_url() { } prepare_log_dir() { + local backend_uid backend_gid owner_mismatch + backend_uid="$(require_env BACKEND_UID)" + backend_gid="$(require_env BACKEND_GID)" mkdir -p "${BACKEND_LOG_DIR}" + owner_mismatch="$(find "${BACKEND_LOG_DIR}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)" + if [[ -n "${owner_mismatch}" ]]; then + if [[ "${EUID}" -ne 0 ]]; then + log_error "日志目录存在属主不匹配的文件:${owner_mismatch}" + log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${BACKEND_LOG_DIR}" + exit 1 + fi + log "修正日志目录属主为 ${backend_uid}:${backend_gid}..." + chown -R "${backend_uid}:${backend_gid}" "${BACKEND_LOG_DIR}" + fi + chmod 700 "${BACKEND_LOG_DIR}" } wait_service_healthy() {