feat: 新增 Docker 容器化部署,支持 Docker Hub 镜像发布
Some checks failed
Docker / docker (push) Has been cancelled

多阶段 Dockerfile、Compose、/health 探活与 JIANG13_* 环境变量;
entrypoint 自动修正 /data 卷权限,适配 1Panel 等平台;更新 README 部署文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-23 18:30:53 +08:00
parent 4f85a86077
commit c07d1fd2bd
16 changed files with 398 additions and 13 deletions

25
.dockerignore Normal file
View File

@@ -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

58
.github/workflows/docker.yml vendored Normal file
View File

@@ -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 }}

55
Dockerfile Normal file
View File

@@ -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 SQLiteCGO_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"]

View File

@@ -6,11 +6,12 @@ MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist BUILD_DIR := dist
VERSION := 1.0.0 VERSION := 1.0.0
LDFLAGS := -s -w -X main.version=$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION)
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go GO := go
GOFLAGS := -trimpath 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 all: build
@@ -70,6 +71,18 @@ dev:
clean: clean:
rm -rf $(BUILD_DIR) 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: help:
@echo "姜十三论坛编译命令:" @echo "姜十三论坛编译命令:"
@echo " make build - 编译当前平台" @echo " make build - 编译当前平台"
@@ -79,3 +92,6 @@ help:
@echo " make build-all - 编译全部平台" @echo " make build-all - 编译全部平台"
@echo " make run - 仅启动后端(:3000" @echo " make run - 仅启动后端(:3000"
@echo " make dev - 前端热更新开发(:5173 + :3000" @echo " make dev - 前端热更新开发(:5173 + :3000"
@echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动"
@echo " make compose-down - Docker Compose 停止"

View File

@@ -11,6 +11,7 @@
[![在线演示](https://img.shields.io/badge/Demo-bbs.iioio.com-18a058?style=flat-square)](https://bbs.iioio.com/) [![在线演示](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) [![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) [![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) [![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)](#) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
@@ -116,6 +117,7 @@
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 - **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
- **跨平台** — Windows / Linux / macOS 一键编译 - **跨平台** — Windows / Linux / macOS 一键编译
- **系统服务** — 内置 Linux systemd / Windows Service 注册 - **系统服务** — 内置 Linux systemd / Windows Service 注册
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
--- ---
@@ -152,7 +154,82 @@ build.bat -Target build-linux
build.bat -Target build-all 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` 把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`
@@ -172,7 +249,7 @@ cp app.ini.example /opt/jiang13/app.ini
./jiang13 ./jiang13
``` ```
### 3. 首次使用 ### 4. 首次使用
1. 浏览器打开 `http://localhost:3000/register` 注册账号 1. 浏览器打开 `http://localhost:3000/register` 注册账号
2. **第一个注册的用户自动成为管理员** 2. **第一个注册的用户自动成为管理员**
@@ -208,7 +285,9 @@ JWT_SECRET =
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | | `--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` 并重启服务,不必重新安装。 将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
@@ -295,6 +374,10 @@ jiang13-forum/
├── cmd/jiang13/ # 程序入口(含系统服务注册) ├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # app.ini 与命令行配置 ├── config/ # app.ini 与命令行配置
├── app.ini.example # 配置文件示例 ├── app.ini.example # 配置文件示例
├── Dockerfile # 多阶段 Docker 构建
├── docker-compose.yml # 单容器 Compose 部署
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
├── .dockerignore
├── model/ # GORM 模型与数据库迁移 ├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑 ├── service/ # 业务逻辑
├── handler/ # HTTP 处理器(前台 + 后台) ├── handler/ # HTTP 处理器(前台 + 后台)

View File

@@ -3,7 +3,7 @@
# .\build.ps1 -Target build-windows # .\build.ps1 -Target build-windows
param( 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' [string]$Target = 'build'
) )
@@ -12,6 +12,7 @@ $AppName = 'jiang13'
$MainPkg = './cmd/jiang13' $MainPkg = './cmd/jiang13'
$BuildDir = 'dist' $BuildDir = 'dist'
$Version = '1.0.0' $Version = '1.0.0'
$RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version" $Ldlags = "-s -w -X main.version=$Version"
function Ensure-Dir($path) { 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 dev backend + Vite HMR (recommended for frontend dev)'
Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean' 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 ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.' 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' Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
Write-Host '[ok] all platforms done' -ForegroundColor Green 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
}
} }

View File

@@ -10,6 +10,9 @@ import (
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
) )
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
var version = "dev"
func main() { func main() {
cfg, err := config.Parse() cfg, err := config.Parse()
if err != nil { if err != nil {

View File

@@ -72,6 +72,7 @@ func (p *program) setup() error {
log.Println("========================================") log.Println("========================================")
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...") log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
log.Printf(" 版本: %s", version)
log.Println("========================================") log.Println("========================================")
if err := model.InitDB(cfg.DBPath()); err != nil { if err := model.InitDB(cfg.DBPath()); err != nil {

View File

@@ -34,9 +34,9 @@ type Config struct {
DevMode bool DevMode bool
} }
// Parse 解析命令行与 app.ini并初始化数据目录 // Parse 解析命令行、环境变量与 app.ini并初始化数据目录
// //
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值 // 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
func Parse() (*Config, error) { func Parse() (*Config, error) {
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini") configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini")
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)") 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) 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -73,11 +81,17 @@ func Parse() (*Config, error) {
} }
port := fileCfg.Port port := fileCfg.Port
if p := envIntOrZero(envHTTPPort); p > 0 {
port = p
}
if *portFlag > 0 { if *portFlag > 0 {
port = *portFlag port = *portFlag
} }
dataInput := fileCfg.DataRel dataInput := fileCfg.DataRel
if v := envOrDefault(envData); v != "" {
dataInput = v
}
if strings.TrimSpace(*dataFlag) != "" { if strings.TrimSpace(*dataFlag) != "" {
dataInput = *dataFlag dataInput = *dataFlag
} }
@@ -86,9 +100,12 @@ func Parse() (*Config, error) {
return nil, fmt.Errorf("解析数据目录失败: %w", err) return nil, fmt.Errorf("解析数据目录失败: %w", err)
} }
jwtSecret := strings.TrimSpace(*jwtFlag) jwtSecret := fileCfg.JWTSecret
if jwtSecret == "" { if v := envOrDefault(envJWTSecret); v != "" {
jwtSecret = fileCfg.JWTSecret jwtSecret = v
}
if strings.TrimSpace(*jwtFlag) != "" {
jwtSecret = strings.TrimSpace(*jwtFlag)
} }
cfg := &Config{ cfg := &Config{

32
config/env.go Normal file
View File

@@ -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
}

33
docker-compose.yml Normal file
View File

@@ -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:

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有uid 0
# 而应用以 jiang13uid 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 "$@"

View File

@@ -70,7 +70,7 @@ func ServeSPANoIndex(c *gin.Context) {
// IsSPARoute 判断是否应由 SPA 处理 // IsSPARoute 判断是否应由 SPA 处理
func IsSPARoute(path string) bool { func IsSPARoute(path string) bool {
if path == "/robots.txt" || path == "/sitemap.xml" { if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
return false return false
} }
if strings.HasPrefix(path, "/api") || if strings.HasPrefix(path, "/api") ||

View File

@@ -56,6 +56,18 @@ func (h *Handlers) APIBoards(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"boards": boards}) 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 论坛概览统计 // APIStats 论坛概览统计
func (h *Handlers) APIStats(c *gin.Context) { func (h *Handlers) APIStats(c *gin.Context) {
var userCount, postCount, boardCount int64 var userCount, postCount, boardCount int64

View File

@@ -58,6 +58,18 @@ func InitDB(dbPath string) error {
return nil 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 写入内置自动徽章(已存在则跳过) // seedDefaultBadges 写入内置自动徽章(已存在则跳过)
func seedDefaultBadges(db *gorm.DB) { func seedDefaultBadges(db *gorm.DB) {
defs := []BadgeDef{ defs := []BadgeDef{

View File

@@ -98,6 +98,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.GET("/media/thumb/*filepath", h.ServeImageThumb) r.GET("/media/thumb/*filepath", h.ServeImageThumb)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// 健康检查(容器 / 负载均衡探活)
r.GET("/health", h.APIHealth)
// SEO抓取规则与站点地图 // SEO抓取规则与站点地图
r.GET("/robots.txt", h.RobotsTxt) r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML) r.GET("/sitemap.xml", h.SitemapXML)