feat: 新增 Docker 容器化部署,支持 Docker Hub 镜像发布
Some checks failed
Docker / docker (push) Has been cancelled
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:
25
.dockerignore
Normal file
25
.dockerignore
Normal 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
58
.github/workflows/docker.yml
vendored
Normal 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
55
Dockerfile
Normal 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 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"]
|
||||||
18
Makefile
18
Makefile
@@ -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 停止"
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
[](https://bbs.iioio.com/)
|
[](https://bbs.iioio.com/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](frontend/package.json)
|
[](frontend/package.json)
|
||||||
[](#)
|
[](#)
|
||||||
@@ -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 处理器(前台 + 后台)
|
||||||
|
|||||||
22
build.ps1
22
build.ps1
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
32
config/env.go
Normal 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
33
docker-compose.yml
Normal 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
15
docker-entrypoint.sh
Normal file
@@ -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 "$@"
|
||||||
@@ -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") ||
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
12
model/db.go
12
model/db.go
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user