From c07d1fd2bd6c254ceac5c60e70669c27324b051f Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 23 Aug 2026 18:30:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Docker=20=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E5=8C=96=E9=83=A8=E7=BD=B2=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Docker=20Hub=20=E9=95=9C=E5=83=8F=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多阶段 Dockerfile、Compose、/health 探活与 JIANG13_* 环境变量; entrypoint 自动修正 /data 卷权限,适配 1Panel 等平台;更新 README 部署文档。 Co-authored-by: Cursor --- .dockerignore | 25 ++++++++++ .github/workflows/docker.yml | 58 +++++++++++++++++++++++ Dockerfile | 55 ++++++++++++++++++++++ Makefile | 18 +++++++- README.md | 89 ++++++++++++++++++++++++++++++++++-- build.ps1 | 22 ++++++++- cmd/jiang13/main.go | 3 ++ cmd/jiang13/program.go | 1 + config/config.go | 31 ++++++++++--- config/env.go | 32 +++++++++++++ docker-compose.yml | 33 +++++++++++++ docker-entrypoint.sh | 15 ++++++ embed_static/embed.go | 2 +- handler/api.go | 12 +++++ model/db.go | 12 +++++ router/router.go | 3 ++ 16 files changed, 398 insertions(+), 13 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 config/env.go create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96abb60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Git 与编辑器 +.git +.gitignore +.idea +.vscode +.cursor + +# 运行时数据与本地配置 +data/ +app.ini +tmp-cookie.txt + +# 编译产物与前端缓存 +dist/ +frontend/node_modules/ +frontend/dist/ +embed_static/static/spa/ +node_modules/ +.vite/ + +# 文档与截图(不参与镜像构建) +docs/ + +# 计划文件 +*.plan.md diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..df2c8af --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,58 @@ +# 构建并推送 Docker 镜像到 Docker Hub +# 前置条件:在 GitHub 仓库 Secrets 中配置 +# DOCKERHUB_USERNAME — Docker Hub 用户名 +# DOCKERHUB_TOKEN — Docker Hub Access Token(需 Read & Write 权限) + +name: Docker + +on: + push: + branches: + - main + tags: + - 'v*' + +env: + IMAGE_NAME: hangzhang714128/jiang13-forum + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: 计算镜像标签与版本 + id: meta + run: | + IMAGE="${{ env.IMAGE_NAME }}" + TAGS="${IMAGE}:latest" + if [[ "${{ github.ref_type }}" == "tag" ]]; then + RAW="${GITHUB_REF#refs/tags/}" + VER="${RAW#v}" + TAGS="${TAGS},${IMAGE}:${VER},${IMAGE}:${RAW}" + echo "version=${VER}" >> "$GITHUB_OUTPUT" + else + SHA="${GITHUB_SHA::7}" + TAGS="${TAGS},${IMAGE}:sha-${SHA}" + echo "version=dev" >> "$GITHUB_OUTPUT" + fi + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + + - name: 登录 Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: 构建并推送镜像 + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64 + build-args: | + VERSION=${{ steps.meta.outputs.version }} + IMAGE_PREFIX= + tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61cedfc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像 +# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile +# +# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理 +# 海外或已配置 Docker registry-mirrors 时可传空前缀: +# docker build --build-arg IMAGE_PREFIX= ... + +ARG IMAGE_PREFIX=docker.m.daocloud.io/library/ +ARG VERSION=dev + +# ── Stage 1: 前端构建(Vite → embed_static/static/spa)──────────────────── +FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm config set registry https://registry.npmmirror.com \ + && npm ci +COPY frontend/ ./ +RUN npm run build + +# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)──────────────────────── +FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder +ARG VERSION +ENV GOPROXY=https://goproxy.cn,direct +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o /out/jiang13 ./cmd/jiang13 + +# ── Stage 3: 运行镜像 ─────────────────────────────────────────────────────── +FROM ${IMAGE_PREFIX}alpine:3.21 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk add --no-cache ca-certificates tzdata wget su-exec \ + && adduser -D -u 1000 -h /app jiang13 + +ENV TZ=Asia/Shanghai + +WORKDIR /app +COPY --from=builder /out/jiang13 /app/jiang13 +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chown jiang13:jiang13 /app/jiang13 \ + && chmod +x /app/jiang13 /docker-entrypoint.sh + +# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13 +VOLUME ["/data"] +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/health || exit 1 + +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"] diff --git a/Makefile b/Makefile index 41948f6..61b56be 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,12 @@ MAIN_PKG := ./cmd/jiang13 BUILD_DIR := dist VERSION := 1.0.0 LDFLAGS := -s -w -X main.version=$(VERSION) +REGISTRY_IMAGE := hangzhang714128/jiang13-forum GO := go GOFLAGS := -trimpath -.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build +.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down all: build @@ -70,6 +71,18 @@ dev: clean: rm -rf $(BUILD_DIR) +## 构建 Docker 镜像 +docker: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest . + +## Docker Compose 启动 +compose-up: + docker compose up -d --build + +## Docker Compose 停止 +compose-down: + docker compose down + help: @echo "姜十三论坛编译命令:" @echo " make build - 编译当前平台" @@ -79,3 +92,6 @@ help: @echo " make build-all - 编译全部平台" @echo " make run - 仅启动后端(:3000)" @echo " make dev - 前端热更新开发(:5173 + :3000)" + @echo " make docker - 构建 Docker 镜像" + @echo " make compose-up - Docker Compose 启动" + @echo " make compose-down - Docker Compose 停止" diff --git a/README.md b/README.md index 1d31973..c883ff9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ [![在线演示](https://img.shields.io/badge/Demo-bbs.iioio.com-18a058?style=flat-square)](https://bbs.iioio.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE) +[![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) [![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#) @@ -116,6 +117,7 @@ - **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 - **跨平台** — Windows / Linux / macOS 一键编译 - **系统服务** — 内置 Linux systemd / Windows Service 注册 +- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化 --- @@ -152,7 +154,82 @@ build.bat -Target build-linux build.bat -Target build-all ``` -### 2. 启动 +### 2. Docker 部署(推荐) + +**一键启动(本地构建):** + +```bash +docker compose up -d --build +``` + +```bat +.\build.bat -Target compose-up +``` + +```bash +make compose-up +``` + +浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。 + +**拉取已构建镜像(Docker Hub):** + +```bash +docker pull hangzhang714128/jiang13-forum:latest +docker run -d --name jiang13 \ + -p 3000:3000 \ + -v jiang13-data:/data \ + --restart unless-stopped \ + hangzhang714128/jiang13-forum:latest +``` + +**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。 + +**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录` + +**可选环境变量(容器编排):** + +| 变量 | 说明 | +|------|------| +| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) | +| `JIANG13_DATA` | 数据目录(默认 `/data`) | +| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) | +| `JIANG13_CONFIG` | 配置文件路径 | +| `JIANG13_WORK_PATH` | 工作目录 | + +**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。 + +**CI 自动构建镜像:** 向 GitHub `main` 分支 push 或打 `v*` 标签后,GitHub Actions 会构建并推送到 [Docker Hub](https://hub.docker.com/r/hangzhang714128/jiang13-forum)。需在 GitHub 仓库 Secrets 中配置 `DOCKERHUB_USERNAME` 与 `DOCKERHUB_TOKEN`(Access Token,需 Read & Write 权限)。 + +**手动推送:** + +```bash +docker login +docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest . +docker push hangzhang714128/jiang13-forum:1.0.0 +docker push hangzhang714128/jiang13-forum:latest +``` + +停止服务:`docker compose down` / `.\build.bat -Target compose-down` / `make compose-down` + +**构建失败(无法连接 auth.docker.io)?** Dockerfile 已默认经 DaoCloud 拉取基础镜像。若仍超时,可在 Docker Desktop → Settings → Docker Engine 添加: + +```json +{ + "registry-mirrors": ["https://docker.m.daocloud.io"] +} +``` + +保存并重启 Docker 后重试 `.\build.bat -Target docker`。PowerShell 中请用 `.\build.bat`(带 `.\` 前缀)。 + +**1Panel 部署提示:** + +1. 容器镜像填 `hangzhang714128/jiang13-forum:latest` +2. 端口映射 `3000:3000` +3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限) +4. 首次访问 `http://服务器IP:3000/register` 注册管理员 + +### 3. 直接启动(二进制) 把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`): @@ -172,7 +249,7 @@ cp app.ini.example /opt/jiang13/app.ini ./jiang13 ``` -### 3. 首次使用 +### 4. 首次使用 1. 浏览器打开 `http://localhost:3000/register` 注册账号 2. **第一个注册的用户自动成为管理员** @@ -208,7 +285,9 @@ JWT_SECRET = | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) | | `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | -### 4. 注册为系统服务(可选) +**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH` + +### 5. 注册为系统服务(可选) 将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。 @@ -295,6 +374,10 @@ jiang13-forum/ ├── cmd/jiang13/ # 程序入口(含系统服务注册) ├── config/ # app.ini 与命令行配置 ├── app.ini.example # 配置文件示例 +├── Dockerfile # 多阶段 Docker 构建 +├── docker-compose.yml # 单容器 Compose 部署 +├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限) +├── .dockerignore ├── model/ # GORM 模型与数据库迁移 ├── service/ # 业务逻辑 ├── handler/ # HTTP 处理器(前台 + 后台) diff --git a/build.ps1 b/build.ps1 index 39dc949..b13a7d2 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,7 +3,7 @@ # .\build.ps1 -Target build-windows param( - [ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')] + [ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')] [string]$Target = 'build' ) @@ -12,6 +12,7 @@ $AppName = 'jiang13' $MainPkg = './cmd/jiang13' $BuildDir = 'dist' $Version = '1.0.0' +$RegistryImage = 'hangzhang714128/jiang13-forum' $Ldlags = "-s -w -X main.version=$Version" function Ensure-Dir($path) { @@ -76,6 +77,9 @@ switch ($Target) { Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)' Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target clean' + Write-Host '.\build.ps1 -Target docker build Docker image' + Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build' + Write-Host '.\build.ps1 -Target compose-down docker compose down' Write-Host '' Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.' } @@ -132,4 +136,20 @@ switch ($Target) { Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' Write-Host '[ok] all platforms done' -ForegroundColor Green } + 'docker' { + Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan + docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" . + if ($LASTEXITCODE -ne 0) { throw 'docker build failed' } + Write-Host '[ok] docker image built' -ForegroundColor Green + } + 'compose-up' { + docker compose up -d --build + if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' } + Write-Host '[ok] compose started' -ForegroundColor Green + } + 'compose-down' { + docker compose down + if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' } + Write-Host '[ok] compose stopped' -ForegroundColor Green + } } diff --git a/cmd/jiang13/main.go b/cmd/jiang13/main.go index cd2229c..1fd57d9 100644 --- a/cmd/jiang13/main.go +++ b/cmd/jiang13/main.go @@ -10,6 +10,9 @@ import ( "git.iioio.com/freefire/jiang13-forum/config" ) +// version 由构建脚本通过 -ldflags "-X main.version=..." 注入 +var version = "dev" + func main() { cfg, err := config.Parse() if err != nil { diff --git a/cmd/jiang13/program.go b/cmd/jiang13/program.go index 6ae794e..b68391d 100644 --- a/cmd/jiang13/program.go +++ b/cmd/jiang13/program.go @@ -72,6 +72,7 @@ func (p *program) setup() error { log.Println("========================================") log.Println(" 姜十三论坛 Jiang13 Forum 启动中...") + log.Printf(" 版本: %s", version) log.Println("========================================") if err := model.InitDB(cfg.DBPath()); err != nil { diff --git a/config/config.go b/config/config.go index f77bc80..e84e4fa 100644 --- a/config/config.go +++ b/config/config.go @@ -34,9 +34,9 @@ type Config struct { DevMode bool } -// Parse 解析命令行与 app.ini,并初始化数据目录 +// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录 // -// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值 +// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值 func Parse() (*Config, error) { configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)") workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)") @@ -52,12 +52,20 @@ func Parse() (*Config, error) { return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag) } - workPath, err := resolveWorkPath(*workFlag) + workPathInput := strings.TrimSpace(*workFlag) + if workPathInput == "" { + workPathInput = envOrDefault(envWorkPath) + } + workPath, err := resolveWorkPath(workPathInput) if err != nil { return nil, err } - configFile, err := resolveConfigPath(workPath, *configFlag) + configInput := strings.TrimSpace(*configFlag) + if configInput == "" { + configInput = envOrDefault(envConfig) + } + configFile, err := resolveConfigPath(workPath, configInput) if err != nil { return nil, err } @@ -73,11 +81,17 @@ func Parse() (*Config, error) { } port := fileCfg.Port + if p := envIntOrZero(envHTTPPort); p > 0 { + port = p + } if *portFlag > 0 { port = *portFlag } dataInput := fileCfg.DataRel + if v := envOrDefault(envData); v != "" { + dataInput = v + } if strings.TrimSpace(*dataFlag) != "" { dataInput = *dataFlag } @@ -86,9 +100,12 @@ func Parse() (*Config, error) { return nil, fmt.Errorf("解析数据目录失败: %w", err) } - jwtSecret := strings.TrimSpace(*jwtFlag) - if jwtSecret == "" { - jwtSecret = fileCfg.JWTSecret + jwtSecret := fileCfg.JWTSecret + if v := envOrDefault(envJWTSecret); v != "" { + jwtSecret = v + } + if strings.TrimSpace(*jwtFlag) != "" { + jwtSecret = strings.TrimSpace(*jwtFlag) } cfg := &Config{ diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..db1ac4e --- /dev/null +++ b/config/env.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认) +const ( + envWorkPath = "JIANG13_WORK_PATH" + envConfig = "JIANG13_CONFIG" + envHTTPPort = "JIANG13_HTTP_PORT" + envData = "JIANG13_DATA" + envJWTSecret = "JIANG13_JWT_SECRET" +) + +func envOrDefault(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func envIntOrZero(key string) int { + v := envOrDefault(key) + if v == "" { + return 0 + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 || n > 65535 { + return 0 + } + return n +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51ab7b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# 姜十三论坛 — Docker Compose 单服务部署 +# 启动:docker compose up -d --build +# 或:make compose-up / build.bat -Target compose-up + +services: + jiang13: + image: hangzhang714128/jiang13-forum:latest + build: + context: . + dockerfile: Dockerfile + args: + VERSION: dev + ports: + - "3000:3000" + volumes: + - jiang13-data:/data + # 可选:挂载自定义 app.ini(只读) + # - ./app.ini:/app/app.ini:ro + environment: + TZ: Asia/Shanghai + # 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret) + # JIANG13_JWT_SECRET: your-secret-here + restart: unless-stopped + stop_grace_period: 15s + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +volumes: + jiang13-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..bbdbe8e --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0), +# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。 + +DATA_DIR="/data" + +if [ "$(id -u)" = '0' ]; then + mkdir -p "$DATA_DIR" + chown -R jiang13:jiang13 "$DATA_DIR" + exec su-exec jiang13 /app/jiang13 "$@" +fi + +exec /app/jiang13 "$@" diff --git a/embed_static/embed.go b/embed_static/embed.go index d3724ee..c41c1d7 100644 --- a/embed_static/embed.go +++ b/embed_static/embed.go @@ -70,7 +70,7 @@ func ServeSPANoIndex(c *gin.Context) { // IsSPARoute 判断是否应由 SPA 处理 func IsSPARoute(path string) bool { - if path == "/robots.txt" || path == "/sitemap.xml" { + if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" { return false } if strings.HasPrefix(path, "/api") || diff --git a/handler/api.go b/handler/api.go index 420f790..d351333 100644 --- a/handler/api.go +++ b/handler/api.go @@ -56,6 +56,18 @@ func (h *Handlers) APIBoards(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"boards": boards}) } +// APIHealth 健康检查(容器探活 / 负载均衡) +func (h *Handlers) APIHealth(c *gin.Context) { + if err := model.PingDB(); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unavailable", + "error": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + // APIStats 论坛概览统计 func (h *Handlers) APIStats(c *gin.Context) { var userCount, postCount, boardCount int64 diff --git a/model/db.go b/model/db.go index 118299a..48f1ef4 100644 --- a/model/db.go +++ b/model/db.go @@ -58,6 +58,18 @@ func InitDB(dbPath string) error { return nil } +// PingDB 检测数据库连接是否可用(供健康检查使用) +func PingDB() error { + if DB == nil { + return fmt.Errorf("数据库未初始化") + } + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Ping() +} + // seedDefaultBadges 写入内置自动徽章(已存在则跳过) func seedDefaultBadges(db *gorm.DB) { defs := []BadgeDef{ diff --git a/router/router.go b/router/router.go index 97b0ac8..0fb517c 100644 --- a/router/router.go +++ b/router/router.go @@ -98,6 +98,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/media/thumb/*filepath", h.ServeImageThumb) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) + // 健康检查(容器 / 负载均衡探活) + r.GET("/health", h.APIHealth) + // SEO:抓取规则与站点地图 r.GET("/robots.txt", h.RobotsTxt) r.GET("/sitemap.xml", h.SitemapXML)