feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS

站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 05:27:33 +08:00
parent 5f7193042f
commit d784b0ea7a
68 changed files with 2588 additions and 768 deletions

25
deploy/.env.example Normal file
View File

@@ -0,0 +1,25 @@
# 官方运行时环境变量。复制为 .env 后填写,不要提交 .env。
# 契约见 CONTRACT.md。反代由宿主机 Nginx 等承担,见 nginx.example.conf。
JIANG13_VERSION=0.1.0
# 镜像名。未推仓库时 docker compose up --build 会打到这些本地 tag。
# 发布后改为 registry.example.com/jiang13-bbs-api 等即可 docker compose pull。
JIANG13_IMAGE_API=jiang13-bbs-api
JIANG13_IMAGE_WEB=jiang13-bbs-web
# 对外 origin,须与浏览器地址栏一致(含 https、无尾斜杠)。CORS 兜底。
SITE_URL=https://bbs.example.com
# 发布到宿主机的地址/端口,供本机 Nginx 反代。默认只绑 loopback。
JIANG13_BIND=127.0.0.1
JIANG13_WEB_PORT=3000
JIANG13_API_PORT=3001
# Postgres(官方 Compose 不把 5432 打到宿主机)
POSTGRES_USER=jiang13
POSTGRES_PASSWORD=change-me
POSTGRES_DB=jiang13
# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。
JWT_SECRET=change-me-to-a-long-random-string

90
deploy/CONTRACT.md Normal file
View File

@@ -0,0 +1,90 @@
# 官方运行时契约
变更本文件中的服务名、端口、volume、环境变量,视为**运行时大版本**,须在 changelog 写明,并给出最低可升级的应用版本。不要为了省事改本地开发的 `start.bat` / 仓库根 `docker-compose.yml`。
当前应用版本见仓库根 `VERSION`(现 `0.1.0`)。最低可升级版本:无(首个官方运行时)。
## 与本地开发的边界
| | 本地开发 | 官方运行时 |
|--|----------|------------|
| Compose | 仓库根 `docker-compose.yml`,只起 Postgres,映射 `5432` | `deploy/docker-compose.yml`,项目名 `jiang13` |
| 后端 | `cd backend && go run ./cmd/jiang13`,数据 `backend/data/` | 容器 `api`,`JIANG13_WORK_PATH=/var/lib/jiang13` |
| 前端 | `cd frontend && npm run dev`,`:3000` | 容器 `web`,Next standalone `:3000` |
| 入口 | 浏览器 `http://localhost:3000` | 宿主机反代(Nginx 等)同源反代 `127.0.0.1:3000` + `127.0.0.1:3001` |
两套 Postgres volume 名字空间不同,可以并存,互不覆盖。官方 Compose **不包含**反代容器。
## 服务名(Compose)
- `postgres` — PostgreSQL 17(不映射宿主机 5432)
- `api` — Go,容器内 `3001`,默认发布 `127.0.0.1:3001`
- `web` — Next.js standalone,容器内 `3000`,默认发布 `127.0.0.1:3000`
禁止把 API 单独做到另一个子域。`/api` 与 `/api/ws` 必须由反代直达 `api`,不能进 Next(Upgrade 无法经 Next rewrite)。
## Volume
| 名 | 容器路径 | 内容 |
|----|----------|------|
| `pgdata` | `/var/lib/postgresql/data` | 数据库 |
| `appdata` | `/var/lib/jiang13/data` | 上传、`private/` 附件、`.jwt_secret` |
镜像内不放真实 `app.ini`、不放用户数据。
## 环境变量
### api
| 变量 | 必需 | 说明 |
|------|------|------|
| `HTTP_PORT` | 否 | 默认 `3001` |
| `DEV_MODE` | 是(Compose 写死 `false`) | 生产 cookie 走 `__Host-`,须反代 HTTPS |
| `JIANG13_WORK_PATH` | 是 | 固定 `/var/lib/jiang13` |
| `DATA_DIR` | 是 | 固定 `/var/lib/jiang13/data` |
| `DB_DSN` | 是 | 指向服务名 `postgres` |
| `JWT_SECRET` | 是 | 强随机;勿依赖自动生成做多副本 |
| `SITE_URL` | 建议 | 对外 origin,无尾斜杠,如 `https://bbs.example.com` |
| `CORS_ORIGINS` | 否 | 额外 origin,逗号分隔 |
### web
| 变量 | 必需 | 说明 |
|------|------|------|
| `BACKEND_URL` | 是 | 固定 `http://api:3001`(SSR / middleware,勿写成公网 URL) |
| `PORT` / `HOSTNAME` | 否 | `3000` / `0.0.0.0` |
| `NEXT_PUBLIC_APP_VERSION` | 构建期 | 与 `VERSION` 相同;**不要**设置 `NEXT_PUBLIC_API_URL=http://api:3001`(会泄漏到浏览器) |
浏览器 API 走同源 `/api/*`(由宿主机反代直达 Go)。WebSocket 走同源 `/api/ws`,不要设 `NEXT_PUBLIC_WS_URL`。
### 宿主机发布
| 变量 | 默认 | 说明 |
|------|------|------|
| `JIANG13_BIND` | `127.0.0.1` | 只给本机反代;需要对外直连再改 |
| `JIANG13_WEB_PORT` | `3000` | web 宿主机端口 |
| `JIANG13_API_PORT` | `3001` | api 宿主机端口 |
### 镜像 tag
`JIANG13_VERSION` 同时作为 `api`/`web` 镜像 tag。`JIANG13_IMAGE_API` / `JIANG13_IMAGE_WEB` 默认为本地名;推仓库后改成 registry 路径即可 `docker compose pull`。
`POSTGRES_PASSWORD` 会拼进 `DB_DSN`,不要包含 `@ : / # ?` 等 URL 保留字符。
## 健康检查
- `api`:`GET http://127.0.0.1:3001/health`(含 DB ping、`version`)
- `web`:`GET http://127.0.0.1:3000/healthz`(不访问后端)
## 更新
应用升级:换同一契约下的新镜像 tag,然后:
```
docker compose pull
docker compose up -d
```
新 `api` 启动时执行既有 `AutoMigrate`。接受短暂停机。上线前不为双版本并存写兼容垫片。
改 Compose / 环境变量 / volume 路径属于运行时升级,不能只 pull。

32
deploy/Dockerfile.api Normal file
View File

@@ -0,0 +1,32 @@
# 构建上下文:仓库根。不含源码与工具链。
# syntax=docker/dockerfile:1
FROM golang:1.27-bookworm AS build
WORKDIR /src
ENV GOTOOLCHAIN=local
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X github.com/freefire/jiang13-bbs/version.Version=${VERSION} -X github.com/freefire/jiang13-bbs/version.Commit=${COMMIT}" \
-o /out/jiang13 ./cmd/jiang13
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata wget su-exec \
&& adduser -D -H -u 1000 jiang13 \
&& mkdir -p /var/lib/jiang13/data
COPY --from=build /out/jiang13 /usr/local/bin/jiang13
COPY deploy/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/api-entrypoint.sh && chmod +x /usr/local/bin/api-entrypoint.sh
ENV JIANG13_WORK_PATH=/var/lib/jiang13 \
HTTP_PORT=3001 \
DEV_MODE=false \
TZ=UTC
WORKDIR /var/lib/jiang13
EXPOSE 3001
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
CMD wget -qO- http://127.0.0.1:3001/health || exit 1
ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"]

37
deploy/Dockerfile.web Normal file
View File

@@ -0,0 +1,37 @@
# 构建上下文:frontend/。standalone 产物,运行时不含完整 node_modules。
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
ARG VERSION=dev
ARG BACKEND_URL=http://api:3001
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production \
NEXT_PUBLIC_APP_VERSION=${VERSION} \
BACKEND_URL=${BACKEND_URL}
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1 \
BACKEND_URL=http://api:3001
RUN mkdir -p public .next && chown -R node:node /app
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]

74
deploy/README.md Normal file
View File

@@ -0,0 +1,74 @@
# 官方运行时(Docker Compose)
一体交付:Next.js SSR + Go API + PostgreSQL 17。 **不含反代**:TLS 与同源路由由你现有的 Nginx / Caddy 处理,示例见 [nginx.example.conf](./nginx.example.conf)。契约见 [CONTRACT.md](./CONTRACT.md)。
本地开发**不要**用这份 Compose。请继续用仓库根 `start.bat` / `start.sh`(只起 Postgres,前后端热重载)。
## 首次安装
1. 安装 Docker Compose v2。
2. 复制环境文件并改密码:
```bash
cd deploy
cp .env.example .env
# 编辑 .env:POSTGRES_PASSWORD、JWT_SECRET、SITE_URL
```
`SITE_URL` 填浏览器看到的 HTTPS origin(如 `https://bbs.example.com`)。`DEV_MODE=false` 会启用 `__Host-` cookie,**必须由你的反代提供 HTTPS**。
3. 启动(尚未推镜像仓库时从源码构建):
```bash
docker compose up -d --build
```
默认只把 `127.0.0.1:3000`(web)和 `127.0.0.1:3001`(api)交给本机反代。与本地 `start.bat` 的 3000/3001 冲突时,先停开发进程,或改 `.env` 里的 `JIANG13_WEB_PORT` / `JIANG13_API_PORT`。
4. 按 [nginx.example.conf](./nginx.example.conf) 把同一 Host 指过来:
- `/` → web `:3000`
- `/api/`(含 `/api/ws`)、`/uploads/`、`/health` → api `:3001`
`GET https://你的域名/health` 应返回 `"status":"ok"` 和 `version`。空库首次注册的账号自动成为站长。不要把 API 放到另一个子域。
数据在 Docker volume `appdata` / `pgdata`。不要把 `backend/data` 或真实 `app.ini` 打进镜像。
## 更新应用(官方路径)
镜像已推到仓库、且 `.env` 里 `JIANG13_IMAGE_*` 指向该仓库时:
```bash
cd deploy
docker compose pull
docker compose up -d
```
只换应用 tag,不改本目录 Compose / 环境变量表。启动时自动迁移。建议先备份:
```bash
docker compose exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
```
`appdata` 用 `docker volume` 备份。失败则把 `JIANG13_VERSION` 改回上一版再 `up -d`。
尚未发布镜像时,在更新源码后执行 `docker compose up -d --build`。
Compose 文件本身有变更时,按新版本 changelog **合并** `deploy/docker-compose.yml` 与 `.env`,不能只 pull。
## 停止
```bash
cd deploy
docker compose down
```
加 `-v` 会删数据库和上传,仅在你确认要清空时使用。不要对仓库根的开发 Postgres 执行这条。
## 不做本期
- 后台一键更新
- 预编译包 / watchdog
- 把 Docker socket 交给 Web 进程
- 在 Compose 里内置 Nginx / Caddy
- Cloudflare Workers 作为官方运行时

6
deploy/api-entrypoint.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
# 以 root 校正 volume 属主后降权运行。数据目录契约:/var/lib/jiang13/data
set -e
mkdir -p /var/lib/jiang13/data/uploads /var/lib/jiang13/data/private
chown -R jiang13:jiang13 /var/lib/jiang13
exec su-exec jiang13 /usr/local/bin/jiang13

72
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,72 @@
# 官方运行时契约(变更视为运行时大版本,须写 changelog)
#
# 本地开发不使用本文件:仓库根 docker-compose.yml 只起 Postgres,
# 配合 start.bat / cd backend && go run ./cmd/jiang13 + frontend npm run dev。
# 本 Compose 不含反代:页面 :3000、API :3001 交给宿主机 Nginx / Caddy。
name: jiang13
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-jiang13}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
POSTGRES_DB: ${POSTGRES_DB:-jiang13}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-jiang13} -d ${POSTGRES_DB:-jiang13}"]
interval: 5s
timeout: 5s
retries: 5
api:
image: ${JIANG13_IMAGE_API:-jiang13-bbs-api}:${JIANG13_VERSION:-0.1.0}
build:
context: ..
dockerfile: deploy/Dockerfile.api
args:
VERSION: ${JIANG13_VERSION:-0.1.0}
COMMIT: ${JIANG13_COMMIT:-unknown}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
HTTP_PORT: "3001"
DEV_MODE: "false"
JIANG13_WORK_PATH: /var/lib/jiang13
DATA_DIR: /var/lib/jiang13/data
DB_DSN: postgres://${POSTGRES_USER:-jiang13}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-jiang13}?sslmode=disable
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in deploy/.env}
SITE_URL: ${SITE_URL:-https://localhost}
volumes:
- appdata:/var/lib/jiang13/data
ports:
- "${JIANG13_BIND:-127.0.0.1}:${JIANG13_API_PORT:-3001}:3001"
web:
image: ${JIANG13_IMAGE_WEB:-jiang13-bbs-web}:${JIANG13_VERSION:-0.1.0}
build:
context: ../frontend
dockerfile: ../deploy/Dockerfile.web
args:
VERSION: ${JIANG13_VERSION:-0.1.0}
BACKEND_URL: http://api:3001
restart: unless-stopped
depends_on:
api:
condition: service_healthy
environment:
BACKEND_URL: http://api:3001
NODE_ENV: production
PORT: "3000"
HOSTNAME: 0.0.0.0
ports:
- "${JIANG13_BIND:-127.0.0.1}:${JIANG13_WEB_PORT:-3000}:3000"
volumes:
pgdata:
appdata:

49
deploy/nginx.example.conf Normal file
View File

@@ -0,0 +1,49 @@
# 宿主机 Nginx 示例(官方 Compose 不含反代)。
# 页面、/api、/api/ws、/uploads 必须同一 Host;/api/ws 直达 Go,不要进 Next。
# 把 bbs.example.com 与证书路径换成实际值,upstream 端口与 .env 里 JIANG13_*_PORT 一致。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name bbs.example.com;
# ssl_certificate /path/to/fullchain.pem;
# ssl_certificate_key /path/to/privkey.pem;
client_max_body_size 512m;
location /api/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
location /uploads/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /health {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}