diff --git a/backend/.env.example b/backend/.env.example index 2eea1aa..bb50d6f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,10 @@ REDIS_DB=0 JWT_SECRET=change-me +# API 限流:默认开启,每个 IP 每分钟 300 次。 +RATE_LIMIT_ENABLED=true +RATE_LIMIT_REQUESTS_PER_MINUTE=300 + LOG_LEVEL=info LOG_DIR=logs LOG_ENABLE_CONSOLE=true diff --git a/backend/.env.prod.example b/backend/.env.prod.example index 3e818ba..a19d697 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -2,6 +2,10 @@ APP_ENV=production APP_ADDR=:8080 +# Caddy 自动申请和续签 HTTPS 证书使用。 +CADDY_DOMAIN=hfb.221329.cc.cd +CADDY_EMAIL=admin@example.com + # MySQL 容器初始化变量,同时供后端 DSN 使用。 MYSQL_ROOT_PASSWORD=change-root-password MYSQL_DATABASE=hfb_sys @@ -15,6 +19,10 @@ REDIS_DB=0 JWT_SECRET=change-to-a-long-random-secret +# API 限流:默认开启,每个 IP 每分钟 300 次。 +RATE_LIMIT_ENABLED=true +RATE_LIMIT_REQUESTS_PER_MINUTE=300 + LOG_LEVEL=info LOG_DIR=/app/logs LOG_ENABLE_CONSOLE=true diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b49a9ab..f1ccb91 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -48,6 +48,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } engine := gin.New() + // 生产环境前置 Caddy,已用实测对端地址覆盖 X-Real-IP, + // 直接据此取真实客户端 IP,避免客户端伪造 X-Forwarded-For。 + engine.TrustedPlatform = "X-Real-IP" engine.Use(middleware.RequestID()) engine.Use(middleware.RequestLogger(logger)) engine.Use(middleware.Recovery(logger)) diff --git a/deploy/README.md b/deploy/README.md index 229cba9..a90acdd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,10 +3,17 @@ 目标访问地址: ```text -http://hfb.221329.cc.cd:7890 +https://hfb.221329.cc.cd ``` -生产部署只暴露宿主机 `7890` 端口,避免和服务器已有的 `80`、`443` 服务冲突。后端、MySQL、Redis、MinIO 均通过 Docker 内网通信。 +生产部署使用 Caddy 作为公网入口,只暴露宿主机 `80`、`443` 端口。Caddy 会自动申请和续签 HTTPS 证书,并直接托管前端静态资源(前端 `dist` 在构建 Caddy 镜像时打入);后端、MySQL、Redis、MinIO 均通过 Docker 内网通信。 + +部署前请确认: + +- 域名 A 记录已解析到服务器公网 IP。 +- 服务器安全组或防火墙已放行 `80/tcp` 和 `443/tcp`。 +- 服务器上没有其他服务占用 `80`、`443`。 +- 国内服务器如需对外访问,请先确认域名备案和云厂商限制。 ## 1. 准备环境变量 @@ -17,6 +24,8 @@ cp backend/.env.prod.example backend/.env 编辑 `backend/.env`,至少替换以下占位值: ```text +CADDY_DOMAIN +CADDY_EMAIL MYSQL_ROOT_PASSWORD MYSQL_PASSWORD MYSQL_DSN @@ -39,9 +48,10 @@ STORAGE_SECRET_ACCESS_KEY - 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。 - 构建并启动生产容器。 +- 通过 Caddy 自动申请或续签 HTTPS 证书。 - 等待 MySQL、Redis、MinIO 就绪。 - 按顺序执行尚未应用的数据库迁移。 -- 重启后端并检查 `http://127.0.0.1:7890/api/health`。 +- 重启后端并检查 `https://你的域名/api/health`。 如需部署完成后直接跟随查看后端日志: @@ -64,9 +74,14 @@ backend/logs/app-YYYY-MM-DD.log ## 3. 手动启动服务 ```bash +set -a +source backend/.env +set +a docker compose -f deploy/docker-compose.prod.yml up -d --build ``` +手动执行 `docker compose` 时需要先导出 `CADDY_DOMAIN` 和 `CADDY_EMAIL`,否则 Caddy 无法读取证书域名配置。优先推荐使用一键部署脚本,它会自动从 `backend/.env` 导出这两个变量。 + ## 4. 手动执行数据库迁移 通常直接使用一键部署脚本即可。确实需要手动迁移时,使用后端镜像内置的 goose: @@ -86,15 +101,15 @@ docker compose -f deploy/docker-compose.prod.yml run --rm --no-deps backend \ ## 5. 验证 ```bash -curl http://127.0.0.1:7890/health -curl http://127.0.0.1:7890/api/health +curl https://你的域名/health +curl https://你的域名/api/health ``` 浏览器访问: ```text -http://hfb.221329.cc.cd:7890 -http://hfb.221329.cc.cd:7890/admin/login +https://hfb.221329.cc.cd +https://hfb.221329.cc.cd/admin/login ``` -服务器安全组或防火墙需要放行 `7890/tcp`。 +Caddy 证书和 ACME 账号数据保存在 Docker 卷 `caddy_data`、`caddy_config` 中。不要随意删除这两个卷,否则 Caddy 会重新申请证书,频繁重建可能触发证书签发频率限制。 diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..274719f --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,56 @@ +{ + email {$CADDY_EMAIL} +} + +# 反代到后端时,用 Caddy 实测到的对端地址覆盖 X-Real-IP, +# 防止客户端伪造 X-Forwarded-For 影响限流、审计和支付下单的 client_ip。 +(backend_proxy) { + header_up X-Real-IP {remote_host} +} + +{$CADDY_DOMAIN} { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + } + + # 聊天 SSE 长连接:关闭缓冲,逐条推送。 + @api_sse { + path /api/chats/events /api/admin/chats/events + } + + handle @api_sse { + reverse_proxy backend:8080 { + flush_interval -1 + import backend_proxy + } + } + + handle /api/* { + # 后端上传上限 10MB,这里前置拦掉超大请求体(留 1MB 余量)。 + request_body { + max_size 11MB + } + reverse_proxy backend:8080 { + import backend_proxy + } + } + + handle /health { + reverse_proxy backend:8080 { + import backend_proxy + } + } + + # 其余流量返回前端静态资源,未命中的路径交给 SPA 处理。 + handle { + root * /srv/dist + try_files {path} /index.html + file_server + } +} diff --git a/deploy/caddy/Dockerfile b/deploy/caddy/Dockerfile new file mode 100644 index 0000000..b118227 --- /dev/null +++ b/deploy/caddy/Dockerfile @@ -0,0 +1,14 @@ +# 第一阶段:构建前端静态资源 +FROM node:24-alpine AS build + +WORKDIR /src +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ . +RUN npm run build + +# 第二阶段:Caddy 作为公网入口,同时托管前端 dist +FROM caddy:2.10-alpine + +COPY --from=build /src/dist /srv/dist +EXPOSE 80 443 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index c58356a..50e713a 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -1,4 +1,31 @@ services: + caddy: + build: + context: .. + dockerfile: deploy/caddy/Dockerfile + restart: unless-stopped + environment: + TZ: Asia/Shanghai + CADDY_DOMAIN: ${CADDY_DOMAIN:?请先导出 CADDY_DOMAIN,或使用 scripts/deploy-prod.sh 部署} + CADDY_EMAIL: ${CADDY_EMAIL:?请先导出 CADDY_EMAIL,或使用 scripts/deploy-prod.sh 部署} + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 128M + cpus: '0.25' + depends_on: + - backend + mysql: image: mysql:8.4 restart: unless-stopped @@ -95,25 +122,9 @@ services: expose: - "8080" - frontend: - build: - context: .. - dockerfile: frontend/Dockerfile - restart: unless-stopped - deploy: - resources: - limits: - memory: 256M - cpus: '0.5' - reservations: - memory: 128M - cpus: '0.25' - depends_on: - - backend - ports: - - "7890:80" - volumes: + caddy_data: + caddy_config: mysql_data: redis_data: minio_data: diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf deleted file mode 100644 index 0061e88..0000000 --- a/deploy/nginx/default.conf +++ /dev/null @@ -1,30 +0,0 @@ -server { - listen 80; - server_name hfb.221329.cc.cd; - - client_max_body_size 20m; - - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - location /api/ { - proxy_pass http://backend:8080/api/; - proxy_http_version 1.1; - proxy_buffering off; - proxy_read_timeout 3600s; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $http_host; - } - - location /health { - proxy_pass http://backend:8080/health; - proxy_set_header Host $http_host; - } -} diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index f4f0a4b..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM node:24-alpine AS build - -WORKDIR /src -COPY frontend/package*.json ./ -RUN npm install -COPY frontend/ . -RUN npm run build - -FROM nginx:1.29-alpine - -COPY --from=build /src/dist /usr/share/nginx/html -COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 1576e33..6d385e0 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -5,8 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml" BACKEND_ENV="${ROOT_DIR}/backend/.env" BACKEND_LOG_DIR="${ROOT_DIR}/backend/logs" -HEALTH_URL="${PROD_HEALTH_URL:-http://127.0.0.1:7890/api/health}" -PUBLIC_URL="${PROD_PUBLIC_URL:-http://hfb.221329.cc.cd:7890}" +HEALTH_URL="${PROD_HEALTH_URL:-}" +PUBLIC_URL="${PROD_PUBLIC_URL:-}" READY_TIMEOUT="${PROD_READY_TIMEOUT:-120}" RED='\033[0;31m' @@ -66,9 +66,9 @@ show_help() { -h, --help 显示帮助信息 环境变量: - PROD_HEALTH_URL=http://127.0.0.1:7890/api/health - 部署后健康检查地址 - PROD_PUBLIC_URL=http://hfb.221329.cc.cd:7890 + PROD_HEALTH_URL=https://你的域名/api/health + 部署后健康检查地址,默认使用 CADDY_DOMAIN + PROD_PUBLIC_URL=https://你的域名 输出给用户看的访问地址 PROD_READY_TIMEOUT=120 等待服务就绪秒数 @@ -127,8 +127,10 @@ validate_env() { exit 1 fi - local app_env mysql_dsn redis_addr storage_endpoint + local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint app_env="$(require_env APP_ENV)" + caddy_domain="$(require_env CADDY_DOMAIN)" + caddy_email="$(require_env CADDY_EMAIL)" mysql_dsn="$(require_env MYSQL_DSN)" redis_addr="$(require_env REDIS_ADDR)" storage_endpoint="$(require_env STORAGE_ENDPOINT)" @@ -146,6 +148,19 @@ validate_env() { log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production" fi + if [[ "${caddy_domain}" == http://* || "${caddy_domain}" == https://* || "${caddy_domain}" == */* ]]; then + log_error "CADDY_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${caddy_domain}" + exit 1 + fi + if [[ "${caddy_domain}" == *"127.0.0.1"* || "${caddy_domain}" == *"localhost"* || "${caddy_domain}" != *"."* ]]; then + log_error "CADDY_DOMAIN 必须填写可公网解析到本机的正式域名,当前值:${caddy_domain}" + exit 1 + fi + if [[ "${caddy_email}" == "admin@example.com" || "${caddy_email}" != *"@"* ]]; then + log_error "CADDY_EMAIL 需要填写真实邮箱,用于 ACME 证书通知" + exit 1 + fi + if [[ "${mysql_dsn}" == *"127.0.0.1"* || "${mysql_dsn}" == *"localhost"* ]]; then log_error "MYSQL_DSN 仍指向本机,请改为 tcp(mysql:3306)" exit 1 @@ -168,6 +183,29 @@ compose() { docker compose -f "${COMPOSE_FILE}" "$@" } +export_caddy_env() { + export CADDY_DOMAIN + export CADDY_EMAIL + CADDY_DOMAIN="$(require_env CADDY_DOMAIN)" + CADDY_EMAIL="$(require_env CADDY_EMAIL)" +} + +public_url() { + if [[ -n "${PUBLIC_URL}" ]]; then + printf "%s" "${PUBLIC_URL}" + return + fi + printf "https://%s" "$(require_env CADDY_DOMAIN)" +} + +health_url() { + if [[ -n "${HEALTH_URL}" ]]; then + printf "%s" "${HEALTH_URL}" + return + fi + printf "%s/api/health" "$(public_url)" +} + prepare_log_dir() { mkdir -p "${BACKEND_LOG_DIR}" } @@ -246,16 +284,18 @@ run_migrations() { wait_http_ready() { local attempt + local url + url="$(health_url)" log "等待接口健康检查通过..." for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do - if curl -fsS --max-time 3 "${HEALTH_URL}" >/dev/null 2>&1; then - log_success "健康检查通过:${HEALTH_URL}" + if curl -fsS --max-time 5 "${url}" >/dev/null 2>&1; then + log_success "健康检查通过:${url}" return 0 fi sleep 1 done - log_error "健康检查超时:${HEALTH_URL}" + log_error "健康检查超时:${url}" exit 1 } @@ -264,6 +304,7 @@ main() { need_cmd docker need_cmd curl validate_env + export_caddy_env prepare_log_dir log "检查 Docker Compose 配置..." @@ -276,8 +317,8 @@ main() { log "构建后端镜像..." compose build backend - log "构建前端镜像..." - compose build frontend + log "构建 Caddy 镜像(含前端静态资源)..." + compose build caddy log "启动生产服务..." compose up -d --no-build @@ -286,16 +327,19 @@ main() { wait_service_healthy mysql "MySQL" wait_service_healthy redis "Redis" wait_service_healthy minio "MinIO" + wait_service_healthy caddy "Caddy" reset_database run_migrations log "重启后端以加载最新数据库状态..." compose up -d --no-deps --force-recreate backend wait_service_healthy backend "后端" + compose up -d --no-deps --force-recreate caddy + wait_service_healthy caddy "Caddy" wait_http_ready - log_success "部署完成:${PUBLIC_URL}" - log "常用命令:docker compose -f deploy/docker-compose.prod.yml logs -f backend" + log_success "部署完成:$(public_url)" + log "常用命令:docker compose -f deploy/docker-compose.prod.yml logs -f caddy backend" if [[ "${FOLLOW_LOGS}" == "1" ]]; then compose logs -f backend